devflow-kit 2.4.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 (166) hide show
  1. package/CHANGELOG.md +156 -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 +1 -1
  6. package/dist/cli/commands/compliance-prompts.js +1 -1
  7. package/dist/cli/commands/compliance.js +23 -1
  8. package/dist/cli/commands/init-seed.js +24 -26
  9. package/dist/cli/commands/init.js +502 -71
  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 +4 -4
  16. package/dist/cli/commands/security.js +76 -16
  17. package/dist/cli/commands/skills.js +53 -7
  18. package/dist/cli/commands/tracker-prompts.js +145 -0
  19. package/dist/cli/commands/tracker.js +405 -0
  20. package/dist/cli/commands/uninstall.js +211 -65
  21. package/dist/cli.js +2 -0
  22. package/dist/commands/bug-analysis.md +22 -4
  23. package/dist/commands/code-review.md +44 -15
  24. package/dist/commands/debug.md +20 -6
  25. package/dist/commands/dynamic-build.md +289 -67
  26. package/dist/commands/dynamic-plan.md +60 -21
  27. package/dist/commands/dynamic-profile.md +1 -1
  28. package/dist/commands/dynamic-tickets.md +58 -8
  29. package/dist/commands/explore.md +2 -2
  30. package/dist/commands/implement.md +241 -53
  31. package/dist/commands/plan.md +88 -17
  32. package/dist/commands/release.md +64 -17
  33. package/dist/commands/resolve.md +138 -58
  34. package/dist/commands/self-review.md +2 -2
  35. package/dist/core/agent-models.js +55 -12
  36. package/dist/core/assets.js +58 -2
  37. package/dist/core/evidence-policy.js +147 -0
  38. package/dist/core/feature-config.js +130 -64
  39. package/dist/core/feature-switch.js +112 -0
  40. package/dist/core/flags.js +4 -4
  41. package/dist/core/manifest.js +33 -7
  42. package/dist/core/mds-variants.js +861 -0
  43. package/dist/core/model-discovery.js +12 -1
  44. package/dist/core/plugins.js +357 -9
  45. package/dist/core/project-paths.js +1 -1
  46. package/dist/core/proxy-log.js +8 -6
  47. package/dist/core/proxy-state.js +11 -8
  48. package/dist/core/reference-sweep.js +136 -0
  49. package/dist/core/tracker.js +407 -0
  50. package/dist/skills/git/references/decision-markers.md +19 -0
  51. package/dist/skills/git/references/learn-conventions.md +56 -0
  52. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  53. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  54. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  55. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  56. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  57. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  58. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  59. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  60. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  61. package/dist/skills/git/references/publication-gate.md +13 -0
  62. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  63. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  65. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  66. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  67. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  68. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  69. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  70. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  71. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  72. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  73. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  74. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  75. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  76. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  77. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  78. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  79. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  80. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  81. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  82. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  83. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  84. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  85. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  87. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  88. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  89. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  90. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  91. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  92. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  93. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  94. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  95. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  96. package/dist/skills/git/references/trust-rule.md +7 -0
  97. package/dist/targets/claude-code/installer.js +1213 -31
  98. package/dist/targets/claude-code/legacy.js +5 -0
  99. package/dist/targets/claude-code/post-install.js +196 -74
  100. package/dist/targets/claude-code/tracker-install.js +161 -0
  101. package/package.json +4 -3
  102. package/src/assets/agents/code.md +42 -4
  103. package/src/assets/agents/design.md +1 -1
  104. package/src/assets/agents/git.mds +827 -0
  105. package/src/assets/agents/knowledge.md +1 -1
  106. package/src/assets/agents/learning.md +11 -0
  107. package/src/assets/agents/synthesize.md +1 -1
  108. package/src/assets/agents/test.md +16 -5
  109. package/src/assets/agents/tracker.md +467 -0
  110. package/src/assets/agents/validate.md +7 -5
  111. package/src/assets/commands/_partials/_engine.mds +11 -9
  112. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  113. package/src/assets/commands/_partials/_knowledge.mds +2 -2
  114. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  115. package/src/assets/commands/_partials/_preamble.mds +1 -1
  116. package/src/assets/commands/_partials/_publication.mds +3 -1
  117. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  118. package/src/assets/commands/_partials/_tracker.mds +18 -0
  119. package/src/assets/commands/_partials/_wave.mds +16 -10
  120. package/src/assets/commands/bug-analysis.mds +15 -5
  121. package/src/assets/commands/code-review.mds +34 -14
  122. package/src/assets/commands/debug.mds +11 -4
  123. package/src/assets/commands/dynamic-build.mds +227 -41
  124. package/src/assets/commands/dynamic-plan.mds +35 -13
  125. package/src/assets/commands/dynamic-tickets.mds +47 -5
  126. package/src/assets/commands/implement.mds +206 -52
  127. package/src/assets/commands/plan.mds +70 -17
  128. package/src/assets/commands/release.md +64 -17
  129. package/src/assets/commands/resolve.mds +126 -56
  130. package/src/assets/mds/git/_pr.mds +331 -0
  131. package/src/assets/mds/git/_references.mds +135 -0
  132. package/src/assets/mds/tracker/_common.mds +156 -0
  133. package/src/assets/mds/tracker/_github.mds +472 -0
  134. package/src/assets/mds/tracker/_jira.mds +407 -0
  135. package/src/assets/mds/tracker/_linear.mds +449 -0
  136. package/src/assets/mds/tracker/_mcp.mds +299 -0
  137. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  138. package/src/assets/scripts/hooks/background-memory-update +14 -9
  139. package/src/assets/scripts/hooks/capture-prompt +6 -2
  140. package/src/assets/scripts/hooks/capture-question +6 -2
  141. package/src/assets/scripts/hooks/capture-turn +6 -2
  142. package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
  143. package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
  144. package/src/assets/scripts/hooks/hook-log-init +3 -1
  145. package/src/assets/scripts/hooks/json-helper.cjs +223 -5
  146. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
  147. package/src/assets/scripts/hooks/memory-worker +15 -8
  148. package/src/assets/scripts/hooks/pre-compact-memory +12 -8
  149. package/src/assets/scripts/hooks/preamble +1 -4
  150. package/src/assets/scripts/hooks/queue-append +68 -24
  151. package/src/assets/scripts/hooks/session-start-context +355 -8
  152. package/src/assets/scripts/hooks/session-start-memory +12 -8
  153. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  154. package/src/assets/scripts/redact-secrets.cjs +490 -62
  155. package/src/assets/scripts/release-trace.cjs +1143 -0
  156. package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
  157. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  158. package/src/assets/skills/compliance/SKILL.md +2 -0
  159. package/src/assets/skills/docs-framework/SKILL.md +5 -3
  160. package/src/assets/skills/git/SKILL.md +8 -78
  161. package/src/assets/skills/git/references/github-api.md +179 -141
  162. package/src/assets/skills/git/references/patterns.md +11 -6
  163. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  164. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  165. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  166. package/src/assets/agents/git.md +0 -938
@@ -1,5 +1,6 @@
1
1
  import { join } from 'path';
2
2
  import { getPackageRoot } from './paths.js';
3
+ import { SKILL_REFS_OUTPUT_DIR } from './mds-variants.js';
3
4
  /**
4
5
  * Flat skills source directory: src/assets/skills/{name}/
5
6
  * All plugins' skills live here directly (no per-plugin subdirectory).
@@ -10,9 +11,13 @@ export function skillsDir() {
10
11
  /**
11
12
  * Flat agents source directory: src/assets/agents/{name}.md
12
13
  * All plugins' agents live here directly.
14
+ *
15
+ * @param root - Package root to resolve against. Injectable so a caller working
16
+ * on a temp tree (the test harness) reads the layout from here rather than
17
+ * spelling the path itself.
13
18
  */
14
- export function agentsDir() {
15
- return join(getPackageRoot(), 'src', 'assets', 'agents');
19
+ export function agentsDir(root = getPackageRoot()) {
20
+ return join(root, 'src', 'assets', 'agents');
16
21
  }
17
22
  /**
18
23
  * Flat rules source directory: src/assets/rules/{name}.md
@@ -36,4 +41,55 @@ export function scriptsDir() {
36
41
  export function commandsDir() {
37
42
  return join(getPackageRoot(), 'dist', 'commands');
38
43
  }
44
+ /**
45
+ * Compiled agents directory: dist/agents/{name}.md
46
+ *
47
+ * Output of the .mds generator hosts. The directory is absent until at least
48
+ * one generator host exists, so every reader must tolerate its absence.
49
+ *
50
+ * @param root - Package root to resolve against (see agentsDir).
51
+ */
52
+ export function compiledAgentsDir(root = getPackageRoot()) {
53
+ return join(root, 'dist', 'agents');
54
+ }
55
+ /**
56
+ * Compiled skill-reference directory: dist/skills/git/references/
57
+ *
58
+ * Output of the `.mds` reference modules — the generated `devflow:git` mechanics
59
+ * files, one per (provider, operation) pair under `tracker/{provider}/`. Like
60
+ * compiledAgentsDir(), the directory is absent until the build has run, so every
61
+ * reader must tolerate its absence.
62
+ *
63
+ * The spelling comes from SKILL_REFS_OUTPUT_DIR in src/core/mds-variants.ts —
64
+ * the build's own allowlist table — rather than being retyped here, so the
65
+ * destination has exactly one definition.
66
+ *
67
+ * @param root - Package root to resolve against (see agentsDir).
68
+ */
69
+ export function compiledSkillRefsDir(root = getPackageRoot()) {
70
+ return join(root, ...SKILL_REFS_OUTPUT_DIR.split('/'));
71
+ }
72
+ /**
73
+ * Agent source directories, MOST-PREFERRED FIRST.
74
+ *
75
+ * The single owner of the dist-first agent-resolution policy: a generator
76
+ * host's compiled artifact in dist/agents/ supersedes a hand-authored file of
77
+ * the same name in src/assets/agents/. Every consumer reads the order from
78
+ * here — the installer's first-hit-wins resolve, loadShippedDefaults's
79
+ * first-wins merge, and the test harness's resolveAgentSource — so the
80
+ * convention is stated once and cannot drift apart between call sites.
81
+ *
82
+ * Order is invisible to the type system: a list spelled least-preferred-first
83
+ * still typechecks and silently inverts the answer. Consumers therefore take
84
+ * this list as-is and never re-spell it; tests/guards/agent-source-precedence
85
+ * pins that they agree.
86
+ *
87
+ * The non-empty tuple makes an empty list a compile error at every call site:
88
+ * an empty list would survive a `??` default and resolve to nothing.
89
+ *
90
+ * @param root - Package root to resolve against (see agentsDir).
91
+ */
92
+ export function agentSourceDirs(root = getPackageRoot()) {
93
+ return [compiledAgentsDir(root), agentsDir(root)];
94
+ }
39
95
  //# sourceMappingURL=assets.js.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The CLI's view of the evidence policy — a typed seam onto the package's own
3
+ * `resolve-evidence-policy.cjs`, never a second implementation of it.
4
+ *
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
7
+ * TRANSCRIBED from its JSDoc typedefs and are the only shape authority on this
8
+ * side — open those typedefs before changing anything here. The module is loaded
9
+ * with `require()` from `scriptsDir()`, which resolves under the package root both
10
+ * from `dist/cli.js` and under vitest (`package.json` `files` ships src/assets/),
11
+ * so the CLI and the resolver it runs are always the same version. The installed
12
+ * `~/.devflow/scripts` copy is never loaded: it may be older than this CLI. There
13
+ * is deliberately no TypeScript copy of the parser, the fold or the grammar.
14
+ *
15
+ * D-POLICY-NO-WRITE (applies ADR-024): `.devflow/policy.json` is team-owned, and
16
+ * devflow never writes or replaces a shared file it cannot prove it wrote. This
17
+ * module therefore imports no fs API; the CLI only PRINTS the bytes a team may
18
+ * choose to commit (`evidencePolicySuggestion`).
19
+ */
20
+ import { createRequire } from 'module';
21
+ import { join } from 'path';
22
+ import { scriptsDir } from './assets.js';
23
+ // ── Transcribed shapes (resolve-evidence-policy.cjs JSDoc) ─────────────────────
24
+ /** Basename of the resolver under src/assets/scripts/ (and ~/.devflow/scripts/). */
25
+ export const RESOLVER_SCRIPT_NAME = 'resolve-evidence-policy.cjs';
26
+ /** The team file the CLI suggests committing, relative to a repository root. */
27
+ const POLICY_FILE = '.devflow/policy.json';
28
+ /**
29
+ * Every key of EvidencePolicyModule and the runtime kind the loader requires of
30
+ * it. `satisfies` makes the compiler reject an interface key missing here.
31
+ */
32
+ export const EVIDENCE_POLICY_MODULE_SURFACE = Object.freeze({
33
+ POLICIES: 'string-array',
34
+ SOURCES: 'string-array',
35
+ WARNINGS: 'string-array',
36
+ MECHANISM_INPUTS: 'object',
37
+ OUTPUT_LINE_RE: 'regexp',
38
+ FAIL_CLOSED_LINE: 'string',
39
+ complianceDefault: 'function',
40
+ resolve: 'function',
41
+ serializePolicy: 'function',
42
+ });
43
+ function hasKind(value, kind) {
44
+ switch (kind) {
45
+ case 'string-array': return Array.isArray(value) && value.every(v => typeof v === 'string');
46
+ case 'object': return typeof value === 'object' && value !== null;
47
+ case 'regexp': return value instanceof RegExp;
48
+ case 'string': return typeof value === 'string';
49
+ case 'function': return typeof value === 'function';
50
+ default: {
51
+ const exhaustive = kind;
52
+ return exhaustive;
53
+ }
54
+ }
55
+ }
56
+ /** Surface keys that are absent or of the wrong kind on `value`, in surface order. */
57
+ function surfaceMismatches(value) {
58
+ if (typeof value !== 'object' || value === null)
59
+ return Object.keys(EVIDENCE_POLICY_MODULE_SURFACE);
60
+ const record = value;
61
+ return Object.entries(EVIDENCE_POLICY_MODULE_SURFACE)
62
+ .filter(([key, kind]) => !hasKind(record[key], kind))
63
+ .map(([key]) => key);
64
+ }
65
+ /**
66
+ * Load the resolver from `dir` (default: the package's own scripts directory) and
67
+ * shape-check its surface. Never throws: a missing file is `not-found`; a module
68
+ * that throws on load or lacks a surface key is `unusable`.
69
+ */
70
+ export function loadEvidencePolicyModule(dir = scriptsDir()) {
71
+ const file = join(dir, RESOLVER_SCRIPT_NAME);
72
+ let loaded;
73
+ try {
74
+ loaded = createRequire(import.meta.url)(file);
75
+ }
76
+ catch (err) {
77
+ const code = err.code;
78
+ if (code === 'MODULE_NOT_FOUND')
79
+ return { ok: false, error: { kind: 'not-found', path: file } };
80
+ const detail = err instanceof Error ? err.message : String(err);
81
+ return { ok: false, error: { kind: 'unusable', path: file, detail } };
82
+ }
83
+ const mismatches = surfaceMismatches(loaded);
84
+ if (mismatches.length > 0) {
85
+ return { ok: false, error: { kind: 'unusable', path: file, detail: `missing or mistyped: ${mismatches.join(', ')}` } };
86
+ }
87
+ return { ok: true, value: loaded };
88
+ }
89
+ // ── Presentation (pure) ────────────────────────────────────────────────────────
90
+ /** `Evidence policy: <policy> (source: <source>)`, plus ` [warn: a, b]` when warnings exist. */
91
+ export function formatEvidencePolicyStatus(r) {
92
+ const warn = r.warnings.length > 0 ? ` [warn: ${r.warnings.join(', ')}]` : '';
93
+ return `Evidence policy: ${r.policy} (source: ${r.source})${warn}`;
94
+ }
95
+ /**
96
+ * The line shown in place of a policy when the resolver cannot be loaded. The
97
+ * remedy is a package reinstall: the CLI loads the package's own copy, which
98
+ * `devflow init` does not restore.
99
+ */
100
+ export function formatEvidencePolicyUnavailable(error) {
101
+ switch (error.kind) {
102
+ case 'not-found': return 'Evidence policy: unavailable (resolver not found — reinstall devflow-kit)';
103
+ case 'unusable': return 'Evidence policy: unavailable (resolver failed to load — reinstall devflow-kit)';
104
+ default: {
105
+ const exhaustive = error;
106
+ return exhaustive;
107
+ }
108
+ }
109
+ }
110
+ /**
111
+ * The `compliance --status` line: the resolved policy for `opts.dir`, or the
112
+ * unavailable line when the loader failed — that line is the whole handling
113
+ * (ADR-028). The caller passes the compliance state it already read, so the
114
+ * manifest is never read twice. `resolve()` makes at most two `gh` calls and
115
+ * bounds every subprocess with a timeout, so an offline machine degrades to a
116
+ * flagged result rather than a hang.
117
+ */
118
+ export function evidencePolicyStatusLine(loaded, opts) {
119
+ if (!loaded.ok)
120
+ return formatEvidencePolicyUnavailable(loaded.error);
121
+ return formatEvidencePolicyStatus(loaded.value.resolve(opts));
122
+ }
123
+ /**
124
+ * What `--enable`/`--set` print when compliance is on: the file a team may commit
125
+ * to pin the policy it now gets by default. Returned only when the resolver's own
126
+ * `complianceDefault` says `required` (compliance enabled, at any framework
127
+ * count); `null` otherwise. The bytes come from the resolver's `serializePolicy`,
128
+ * so they always parse as a valid policy file. Nothing is written
129
+ * (D-POLICY-NO-WRITE).
130
+ */
131
+ export function evidencePolicySuggestion(complianceState, mod) {
132
+ if (mod.complianceDefault(complianceState) !== 'required')
133
+ return null;
134
+ const body = mod.serializePolicy('required');
135
+ if (body === null)
136
+ return null;
137
+ return [
138
+ 'Compliance is enabled on this machine, so repositories without a committed',
139
+ 'policy default to the required evidence policy here. To apply it for everyone',
140
+ `working in a repository, commit this as ${POLICY_FILE} on its default branch:`,
141
+ '',
142
+ `${body}`,
143
+ 'devflow never writes this file: the team owns it, and once committed it applies',
144
+ 'repo-wide.',
145
+ ].join('\n');
146
+ }
147
+ //# sourceMappingURL=evidence-policy.js.map
@@ -1,125 +1,191 @@
1
1
  import * as path from 'path';
2
2
  import { promises as fs } from 'fs';
3
3
  import { getFeatureConfigPath } from './project-paths.js';
4
+ import { parseTrackerId } from './tracker.js';
5
+ /**
6
+ * Keys devflow itself once wrote and has retired. A managed write drops them
7
+ * rather than carrying them; no reader consults them.
8
+ *
9
+ * D-FEATURES-MACHINE-WIDE: `memory`, `learning` and `knowledge` were per-repo
10
+ * feature switches from the per-repo-install era. A feature is now on or off
11
+ * for the whole machine, in the manifest, so a stale per-repo value must
12
+ * neither decide anything nor linger to be mistaken for a switch: carrying it
13
+ * would leave a `learning: false` in the file that no longer does what it says.
14
+ * `decisions` is the pre-rename spelling of `learning`; `autoCommit` is inert.
15
+ */
16
+ const RETIRED_CONFIG_KEYS = new Set([
17
+ 'memory', 'learning', 'knowledge', 'decisions', 'autoCommit',
18
+ ]);
4
19
  export const DEFAULT_CONFIG = {
5
- memory: true,
6
- learning: true,
7
- knowledge: true,
8
20
  reviewPublication: 'auto',
9
21
  };
10
22
  export function getConfigPath(projectRoot) {
11
23
  return getFeatureConfigPath(projectRoot);
12
24
  }
25
+ /**
26
+ * Parse the per-repo tracker override from the raw config value.
27
+ *
28
+ * Pure. Delegates membership to `parseTrackerId` rather than re-spelling a
29
+ * closed-domain ternary the way `reviewPublication` does: `reviewPublication`
30
+ * self-heals any invalid value to `'auto'`, which is correct for a publication
31
+ * mode and wrong for a provider — a repaired provider is the laundering path
32
+ * GAP-10 names, and §14.2 gives the invalid case its own DEGRADED reason, which
33
+ * only exists if the parse REFUSES instead of healing.
34
+ *
35
+ * An empty string reads as absent: `"tracker": ""` is an unset key with a
36
+ * character in it, not an attempt to name a provider.
37
+ *
38
+ * A non-string JSON value (`42`, `true`, `null`, an array, an object) is
39
+ * `invalid`, not `absent` — a present-but-wrong-typed value means the file was
40
+ * edited, and reporting it as absent would make the whole class silent.
41
+ */
42
+ export function parseTrackerOverride(raw) {
43
+ if (raw === undefined || raw === '')
44
+ return { kind: 'absent' };
45
+ // `unknown` all the way from the field: this is a boundary parse over
46
+ // hand-edited JSON, and a signature that promised a string would make the
47
+ // wrong-type arm unreachable to the compiler while it stays entirely reachable
48
+ // to a user with a text editor.
49
+ if (typeof raw !== 'string') {
50
+ return { kind: 'invalid', raw: JSON.stringify(raw) ?? String(raw) };
51
+ }
52
+ const parsed = parseTrackerId(raw);
53
+ return parsed.ok ? { kind: 'valid', provider: parsed.value } : { kind: 'invalid', raw };
54
+ }
55
+ /**
56
+ * Whether a parsed JSON value is an object a config can be read from — the one
57
+ * test for "the file holds a config", shared by the reader and the managed
58
+ * write so the two never disagree about which files count as empty.
59
+ */
60
+ function isJsonObject(value) {
61
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
62
+ }
13
63
  /**
14
64
  * Parse and narrow an unknown JSON value into a FeatureConfig, merging onto
15
65
  * DEFAULT_CONFIG. Pure function — no I/O, no side effects.
16
66
  *
17
- * Coalesces legacy `decisions` key into `learning` when both are present:
18
- * `decisions` wins (legacy-decisions-wins semantics; intentionally opposite to
19
- * manifest.ts's new-key-wins self-heal — migration-compat requires the old key
20
- * to take precedence so old configs with `decisions: false` are not silently
21
- * re-enabled by a newer `learning: true` key).
22
- * Silently ignores `autoCommit` — old configs may still contain it.
67
+ * The retired keys ({@link RETIRED_CONFIG_KEYS}) are ignored: an old config may
68
+ * still hold them, and none of them decides anything (D-FEATURES-MACHINE-WIDE).
23
69
  *
24
70
  * Returns null when `parsed` is not a plain object (caller falls through to
25
71
  * the next candidate path).
26
72
  */
27
73
  function coerceConfig(parsed) {
28
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
74
+ if (!isJsonObject(parsed))
29
75
  return null;
30
76
  const p = parsed;
31
- // Coalesce decisions (legacy key) → learning. decisions wins when both present.
32
- let learning = DEFAULT_CONFIG.learning;
33
- if (typeof p.learning === 'boolean')
34
- learning = p.learning;
35
- if (typeof p.decisions === 'boolean')
36
- learning = p.decisions; // decisions wins
37
77
  // Coerce reviewPublication: any invalid or absent value → 'auto' (self-heal, ADR-014 idiom).
38
78
  const rp = p.reviewPublication;
39
79
  const reviewPublication = rp === 'auto' || rp === 'full' || rp === 'off' ? rp : 'auto';
80
+ // The per-repo tracker override is carried through VERBATIM — never coerced,
81
+ // never type-filtered. Two reasons, and the second is the one a reader is
82
+ // likely to miss:
83
+ // (a) repair is forbidden for a provider value (§14.9-6), so there is no
84
+ // healed value to fall back to the way reviewPublication has 'auto';
85
+ // (b) a non-string is a state parseTrackerOverride CLASSIFIES (`invalid`),
86
+ // not a state this function repairs. Filtering by type here would leave
87
+ // that arm reachable from a direct call to the parser and unreachable
88
+ // from the file, which is the only place it can actually be written.
89
+ // Key PRESENCE is the whole rule: a present key is carried as written, and an
90
+ // absent one stays absent. `hasOwnProperty` rather than `in` because the
91
+ // object comes from JSON.parse at a trust boundary. Retention on disk is not
92
+ // decided here: the writers carry every unmanaged key from the file itself
93
+ // (D-CONFIG-PRESERVE-UNMANAGED).
94
+ const hasTracker = Object.prototype.hasOwnProperty.call(p, 'tracker');
40
95
  return {
41
- memory: typeof p.memory === 'boolean' ? p.memory : DEFAULT_CONFIG.memory,
42
- learning,
43
- knowledge: typeof p.knowledge === 'boolean' ? p.knowledge : DEFAULT_CONFIG.knowledge,
44
96
  reviewPublication,
97
+ ...(hasTracker ? { tracker: p.tracker } : {}),
45
98
  };
46
99
  }
47
100
  /**
48
- * Read the feature config for a project root.
101
+ * Read the per-repo config for a project root.
49
102
  * Returns DEFAULT_CONFIG when the file is missing or unreadable.
50
- * Applies ADR-001: .devflow/config.json is the sole source of truth;
51
- * `devflow init` writes it directly on first install. An absent file falls
52
- * through to DEFAULT_CONFIG (all features enabled by default).
53
103
  */
54
104
  export async function readConfig(projectRoot) {
55
- const configPath = getFeatureConfigPath(projectRoot);
105
+ return coerceConfig(await readConfigBody(projectRoot)) ?? { ...DEFAULT_CONFIG };
106
+ }
107
+ /**
108
+ * The parsed JSON body of a project's config file, or `undefined` when the file
109
+ * is absent, unreadable or malformed. Every read of the file goes through here,
110
+ * so readConfig, readConfigIfPresent and writeManagedConfig agree on what an
111
+ * unusable file means.
112
+ */
113
+ async function readConfigBody(projectRoot) {
56
114
  try {
57
- const config = coerceConfig(JSON.parse(await fs.readFile(configPath, 'utf-8')));
58
- if (config !== null)
59
- return config;
60
- return { ...DEFAULT_CONFIG };
115
+ return JSON.parse(await fs.readFile(getFeatureConfigPath(projectRoot), 'utf-8'));
61
116
  }
62
117
  catch {
63
- return { ...DEFAULT_CONFIG };
118
+ // ENOENT (absent) or SyntaxError (malformed) — treat as not present
119
+ return undefined;
64
120
  }
65
121
  }
66
122
  /**
67
- * Write the feature config for a project root.
123
+ * Serialise a config body to a project's config file.
68
124
  * Creates the .devflow/ directory if missing.
69
125
  * Uses an atomic temp+rename pattern to prevent partial reads under concurrent writes.
70
126
  */
71
- export async function writeConfig(projectRoot, config) {
127
+ async function writeConfigBody(projectRoot, body) {
72
128
  const configPath = getFeatureConfigPath(projectRoot);
73
129
  await fs.mkdir(path.join(projectRoot, '.devflow'), { recursive: true });
74
130
  const tmpPath = configPath + '.tmp.' + process.pid;
75
- await fs.writeFile(tmpPath, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
131
+ await fs.writeFile(tmpPath, JSON.stringify(body, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
76
132
  await fs.rename(tmpPath, configPath);
77
133
  }
78
134
  /**
79
- * Toggle a single feature in the feature config.
80
- * Reads current config, applies the change, and writes back.
135
+ * Merge devflow's managed keys over the config body the file already holds.
136
+ * Pure — returns a new object and never mutates `existing`.
137
+ *
138
+ * D-CONFIG-PRESERVE-UNMANAGED (avoids PF-071): `.devflow/config.json` is a
139
+ * user-editable file that devflow only PARTLY owns. The managed keys come from
140
+ * `managed`; every other key comes from the file, verbatim and by key presence
141
+ * — the hand-written per-repo `tracker` override (whose invalid values must
142
+ * survive so their DEGRADED report can name them) and any key devflow does not
143
+ * know. The alternative, writing the declared shape, is a silent delete of all
144
+ * of them on every `devflow init`. Every writer of the file goes through here
145
+ * (writeManagedConfig, called by init). Only the managed key is copied from
146
+ * `managed`, by name, so a caller holding a whole FeatureConfig still cannot
147
+ * overwrite the file's override with its in-memory copy. The retired keys in
148
+ * RETIRED_CONFIG_KEYS are dropped, not carried. A body that is
149
+ * not a JSON object (absent, malformed, an array) reads as empty, exactly as
150
+ * readConfigIfPresent treats it.
81
151
  *
82
- * D1: Non-atomic read-modify-write. Concurrent invocations of `updateFeature`
83
- * could lose each other's writes. Acceptable here because: (a) devflow CLI
84
- * commands are single-threaded user-initiated actions, and (b) the window is
85
- * milliseconds on a local filesystem with no concurrent writers in normal use.
86
- * If concurrent safety is ever required, replace with an atomic file-swap or
87
- * a lock file.
152
+ * This holds under `devflow init --reset` too: a factory reset returns
153
+ * devflow's own settings to their defaults through `managed`, and leaves the
154
+ * keys devflow never wrote alone.
88
155
  */
89
- export async function updateFeature(projectRoot, feature, enabled) {
90
- const config = await readConfig(projectRoot);
91
- await writeConfig(projectRoot, { ...config, [feature]: enabled });
156
+ export function mergeManagedConfig(existing, managed) {
157
+ const fileKeys = isJsonObject(existing)
158
+ ? Object.fromEntries(Object.entries(existing).filter(([key]) => !RETIRED_CONFIG_KEYS.has(key)))
159
+ : {};
160
+ return {
161
+ ...fileKeys,
162
+ reviewPublication: managed.reviewPublication,
163
+ };
92
164
  }
93
165
  /**
94
- * Check whether a specific feature is enabled for the given project root.
166
+ * Write devflow's managed keys to a project's config, keeping every key it
167
+ * does not manage (D-CONFIG-PRESERVE-UNMANAGED).
168
+ *
169
+ * D1: Non-atomic read-modify-write. A concurrent writer could lose the other's
170
+ * change. Acceptable because init is a single-threaded, user-initiated command
171
+ * and the window is milliseconds on a local filesystem; the file swap itself is
172
+ * atomic (temp + rename), so a reader never sees a partial file.
95
173
  */
96
- export async function isFeatureEnabled(projectRoot, feature) {
97
- const config = await readConfig(projectRoot);
98
- return config[feature];
174
+ export async function writeManagedConfig(projectRoot, managed) {
175
+ const existing = await readConfigBody(projectRoot);
176
+ await writeConfigBody(projectRoot, mergeManagedConfig(existing, managed));
99
177
  }
100
178
  /**
101
- * Read the feature config for a project root, returning null when the file
179
+ * Read the per-repo config for a project root, returning null when the file
102
180
  * is absent or malformed.
103
181
  *
104
182
  * Unlike readConfig (which falls back to DEFAULT_CONFIG on any error),
105
183
  * readConfigIfPresent distinguishes "not configured yet" (null) from
106
- * "configured with specific values" (FeatureConfig). The distinction
107
- * matters for init-seed resolution: a present config overrides the
108
- * manifest for memory/learning/knowledge even when the manifest is absent.
109
- *
110
- * Applies ADR-001: .devflow/config.json is the source of truth; null means
111
- * the config file does not exist or is unreadable — not that all features
112
- * are disabled.
184
+ * "configured with specific values" (FeatureConfig). init relies on the
185
+ * distinction to carry a repo's own reviewPublication across a re-init.
113
186
  */
114
187
  export async function readConfigIfPresent(projectRoot) {
115
- const configPath = getFeatureConfigPath(projectRoot);
116
- try {
117
- const text = await fs.readFile(configPath, 'utf-8');
118
- return coerceConfig(JSON.parse(text)); // null when JSON is not a plain object
119
- }
120
- catch {
121
- // ENOENT (absent) or SyntaxError (malformed) — treat as not present
122
- return null;
123
- }
188
+ // null when the file is absent or malformed, or its JSON is not a plain object
189
+ return coerceConfig(await readConfigBody(projectRoot));
124
190
  }
125
191
  //# sourceMappingURL=feature-config.js.map
@@ -0,0 +1,112 @@
1
+ import { promises as fs } from 'fs';
2
+ import * as path from 'path';
3
+ import { writeFileAtomicExclusive } from './fs-atomic.js';
4
+ function isJsonObject(value) {
5
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
6
+ }
7
+ /**
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
10
+ * command has rewritten since the rename can still hold only the legacy key.
11
+ * `memory` was never renamed.
12
+ */
13
+ const LEGACY_KEYS = {
14
+ learning: 'decisions',
15
+ knowledge: 'kb',
16
+ };
17
+ /**
18
+ * Whether a RAW parsed manifest leaves `feature` switched on. Pure.
19
+ *
20
+ * Only an explicit boolean `false` switches a feature off. A missing key, a
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
23
+ * shell hooks, so the CLI's status and the runtime never disagree about the
24
+ * same file.
25
+ *
26
+ * D-LEARNING-LEGACY-DECISIONS (a sub-decision of D-FEATURES-MACHINE-WIDE):
27
+ * `learning` is read as `features.learning` when that is a boolean, else the
28
+ * legacy `features.decisions` when THAT is a boolean, else ON — readManifest's
29
+ * migration precedence exactly. The legacy key is otherwise honoured only once
30
+ * some command happens to run readManifest and heal it, so a `decisions: false`
31
+ * would keep learning running until then. queue_read_gates applies the same
32
+ * precedence.
33
+ *
34
+ * D-KNOWLEDGE-LEGACY-KB (the same sub-decision for the other renamed key):
35
+ * `knowledge` is read as `features.knowledge` when that is a boolean, else the
36
+ * legacy `features.kb` when THAT is a boolean, else ON — again readManifest's
37
+ * precedence exactly, so `devflow knowledge --status` reports a `kb: false`
38
+ * as disabled. queue_read_gates never reads knowledge, so there is no shell
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
41
+ * can hold); readManifest rewrites `kb` to `knowledge` on the next CLI run
42
+ * that loads the manifest, after which that gate reads the healed key.
43
+ *
44
+ * Deliberately NOT built on readManifest(): it returns null for a manifest
45
+ * missing any of its required fields — reported as "on" here, as the hooks read
46
+ * it — and it writes its heals back to disk, which a read-only status must not.
47
+ */
48
+ export function isMachineFeatureOn(rawManifest, feature) {
49
+ if (!isJsonObject(rawManifest))
50
+ return true;
51
+ const features = rawManifest.features;
52
+ if (!isJsonObject(features))
53
+ return true;
54
+ const legacyKey = LEGACY_KEYS[feature];
55
+ const value = legacyKey !== undefined && typeof features[feature] !== 'boolean'
56
+ ? features[legacyKey]
57
+ : features[feature];
58
+ return value !== false;
59
+ }
60
+ /**
61
+ * Set `feature` in a RAW parsed manifest. Pure — returns a new object and never
62
+ * mutates its input. Null when the value is not a manifest-shaped object (there
63
+ * is no `features` record to write into).
64
+ *
65
+ * Only `features.<feature>` and `updatedAt` change; every other key is carried
66
+ * verbatim. Writing `learning` leaves a legacy `decisions` key in place, and
67
+ * writing `knowledge` a legacy `kb` key, inert: the boolean just written wins
68
+ * over it (D-LEARNING-LEGACY-DECISIONS, D-KNOWLEDGE-LEGACY-KB). Going through
69
+ * readManifest()/writeManifest() instead would refuse a manifest that reader
70
+ * rejects, drop every key ManifestData does not model (one a newer devflow
71
+ * wrote, say), and persist that reader's unrelated heals as a side effect of a
72
+ * one-key toggle.
73
+ */
74
+ export function setMachineFeature(rawManifest, feature, enabled, now) {
75
+ if (!isJsonObject(rawManifest) || !isJsonObject(rawManifest.features))
76
+ return null;
77
+ return {
78
+ ...rawManifest,
79
+ features: { ...rawManifest.features, [feature]: enabled },
80
+ updatedAt: now,
81
+ };
82
+ }
83
+ async function readRawManifest(devflowDir) {
84
+ try {
85
+ return JSON.parse(await fs.readFile(path.join(devflowDir, 'manifest.json'), 'utf-8'));
86
+ }
87
+ catch {
88
+ // ENOENT, EACCES or SyntaxError — no usable manifest.
89
+ return undefined;
90
+ }
91
+ }
92
+ /**
93
+ * Read the machine-wide switch from `<devflowDir>/manifest.json`. Read-only (no
94
+ * heal write); an absent, unreadable or malformed manifest reads as ON.
95
+ */
96
+ export async function readMachineFeature(devflowDir, feature) {
97
+ return isMachineFeatureOn(await readRawManifest(devflowDir), feature);
98
+ }
99
+ /**
100
+ * Write the machine-wide switch to `<devflowDir>/manifest.json` (atomic
101
+ * temp + rename). Refuses with `not-installed` when there is no manifest to
102
+ * write into — a manifest is created by `devflow init`, never by a toggle,
103
+ * because a bare `{features: {...}}` file is not a manifest any reader accepts.
104
+ */
105
+ export async function writeMachineFeature(devflowDir, feature, enabled) {
106
+ const next = setMachineFeature(await readRawManifest(devflowDir), feature, enabled, new Date().toISOString());
107
+ if (next === null)
108
+ return { ok: false, error: 'not-installed' };
109
+ await writeFileAtomicExclusive(path.join(devflowDir, 'manifest.json'), JSON.stringify(next, null, 2) + '\n');
110
+ return { ok: true, value: undefined };
111
+ }
112
+ //# sourceMappingURL=feature-switch.js.map
@@ -121,8 +121,8 @@ export const FLAG_REGISTRY = [
121
121
  kind: 'boolean',
122
122
  target: { type: 'setting', key: 'disableBundledSkills' },
123
123
  onPayload: true,
124
- recommended: true,
125
- defaultValue: true,
124
+ recommended: false,
125
+ defaultValue: false,
126
126
  },
127
127
  {
128
128
  id: 'pin-sonnet-4-6',
@@ -133,8 +133,8 @@ export const FLAG_REGISTRY = [
133
133
  kind: 'boolean',
134
134
  target: { type: 'env', key: 'ANTHROPIC_DEFAULT_SONNET_MODEL' },
135
135
  onPayload: 'claude-sonnet-4-6',
136
- recommended: true,
137
- defaultValue: true,
136
+ recommended: false,
137
+ defaultValue: false,
138
138
  },
139
139
  {
140
140
  // Devflow fan-outs routinely exceed the upstream default of 20.