devflow-kit 2.5.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/README.md +44 -19
  3. package/dist/agents/git.md +13 -15
  4. package/dist/cli/commands/ambient.js +160 -145
  5. package/dist/cli/commands/capture.js +29 -55
  6. package/dist/cli/commands/compliance.js +32 -61
  7. package/dist/cli/commands/context.js +17 -32
  8. package/dist/cli/commands/debug.js +65 -26
  9. package/dist/cli/commands/flags.js +3 -3
  10. package/dist/cli/commands/hud.js +34 -10
  11. package/dist/cli/commands/init-seed.js +40 -4
  12. package/dist/cli/commands/init.js +249 -271
  13. package/dist/cli/commands/install-report.js +10 -15
  14. package/dist/cli/commands/knowledge/index.js +1 -1
  15. package/dist/cli/commands/knowledge/toggle.js +11 -3
  16. package/dist/cli/commands/learning.js +52 -37
  17. package/dist/cli/commands/legacy-hooks.js +11 -14
  18. package/dist/cli/commands/memory.js +67 -78
  19. package/dist/cli/commands/proxy.js +23 -41
  20. package/dist/cli/commands/security.js +5 -13
  21. package/dist/cli/commands/skills.js +21 -3
  22. package/dist/cli/commands/tracker.js +100 -228
  23. package/dist/cli/commands/uninstall.js +343 -138
  24. package/dist/commands/bug-analysis.md +38 -12
  25. package/dist/commands/code-review.md +70 -21
  26. package/dist/commands/debug.md +37 -7
  27. package/dist/commands/dynamic-build.md +66 -17
  28. package/dist/commands/dynamic-plan.md +19 -8
  29. package/dist/commands/dynamic-profile.md +24 -10
  30. package/dist/commands/dynamic-tickets.md +22 -11
  31. package/dist/commands/explore.md +37 -7
  32. package/dist/commands/implement.md +96 -32
  33. package/dist/commands/plan.md +62 -19
  34. package/dist/commands/release.md +2 -2
  35. package/dist/commands/research.md +34 -8
  36. package/dist/commands/resolve.md +65 -17
  37. package/dist/commands/self-review.md +45 -9
  38. package/dist/core/compliance-compose.js +27 -27
  39. package/dist/core/evidence-policy.js +240 -24
  40. package/dist/core/feature-config.js +94 -25
  41. package/dist/core/feature-switch.js +1 -1
  42. package/dist/core/flags.js +30 -2
  43. package/dist/core/fs-atomic.js +27 -0
  44. package/dist/core/hook-log-dirs.js +104 -0
  45. package/dist/core/learning-tuning-config.js +5 -3
  46. package/dist/core/ledger-root.js +102 -0
  47. package/dist/core/manifest.js +6 -4
  48. package/dist/core/mds-variants.js +34 -97
  49. package/dist/core/migrations.js +49 -23
  50. package/dist/core/plugins.js +5 -4
  51. package/dist/core/project-paths.js +0 -17
  52. package/dist/core/same-location.js +25 -0
  53. package/dist/core/tracker.js +226 -139
  54. package/dist/hud/components/config-counts.js +15 -4
  55. package/dist/hud/components/learning-counts.js +14 -0
  56. package/dist/hud/config.js +2 -1
  57. package/dist/hud/cost-history.js +2 -4
  58. package/dist/hud/git.js +52 -7
  59. package/dist/hud/index.js +7 -9
  60. package/dist/skills/git/references/pr/check-merge-readiness.md +1 -1
  61. package/dist/skills/git/references/pr/ensure-pr-ready.md +1 -1
  62. package/dist/skills/git/references/pr/update-pr-evidence.md +1 -1
  63. package/dist/skills/git/references/tracker/_mcp.md +1 -1
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +1 -1
  65. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +1 -1
  66. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +2 -2
  67. package/dist/skills/git/references/tracker/github/manage-debt.md +3 -3
  68. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +1 -1
  69. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +1 -1
  70. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +2 -2
  71. package/dist/skills/git/references/tracker/jira/manage-debt.md +1 -1
  72. package/dist/skills/git/references/tracker/jira/post-wave-report.md +1 -1
  73. package/dist/skills/git/references/tracker/jira/setup-task.md +1 -1
  74. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +1 -1
  75. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +1 -1
  76. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +2 -2
  77. package/dist/skills/git/references/tracker/linear/manage-debt.md +1 -1
  78. package/dist/skills/git/references/tracker/linear/post-wave-report.md +1 -1
  79. package/dist/skills/git/references/tracker/linear/setup-task.md +1 -1
  80. package/dist/targets/claude-code/claude-paths.js +59 -57
  81. package/dist/targets/claude-code/compliance-install.js +49 -65
  82. package/dist/targets/claude-code/hooks.js +108 -3
  83. package/dist/targets/claude-code/installer.js +30 -57
  84. package/dist/targets/claude-code/post-install.js +232 -139
  85. package/dist/targets/claude-code/tracker-install.js +38 -65
  86. package/package.json +5 -4
  87. package/src/assets/agents/code.md +4 -3
  88. package/src/assets/agents/design.md +1 -0
  89. package/src/assets/agents/git.mds +55 -57
  90. package/src/assets/agents/knowledge.md +2 -2
  91. package/src/assets/agents/review.md +3 -1
  92. package/src/assets/agents/tracker.md +37 -30
  93. package/src/assets/commands/_partials/_compliance.mds +19 -1
  94. package/src/assets/commands/_partials/_decisions.mds +15 -3
  95. package/src/assets/commands/_partials/_docs_root.mds +35 -0
  96. package/src/assets/commands/_partials/_engine.mds +2 -2
  97. package/src/assets/commands/_partials/_evidence_policy.mds +3 -3
  98. package/src/assets/commands/_partials/_factory.mds +1 -1
  99. package/src/assets/commands/_partials/_knowledge.mds +27 -9
  100. package/src/assets/commands/_partials/_plan_contract.mds +2 -2
  101. package/src/assets/commands/_partials/_preamble.mds +1 -1
  102. package/src/assets/commands/_partials/_publication.mds +6 -2
  103. package/src/assets/commands/_partials/_settings.mds +28 -0
  104. package/src/assets/commands/_partials/_ticket_template.mds +3 -3
  105. package/src/assets/commands/_partials/_tracker.mds +4 -4
  106. package/src/assets/commands/_partials/_wave.mds +4 -4
  107. package/src/assets/commands/bug-analysis.mds +19 -17
  108. package/src/assets/commands/code-review.mds +39 -33
  109. package/src/assets/commands/debug.mds +4 -5
  110. package/src/assets/commands/dynamic-build.mds +75 -53
  111. package/src/assets/commands/dynamic-plan.mds +20 -15
  112. package/src/assets/commands/dynamic-profile.mds +24 -11
  113. package/src/assets/commands/dynamic-tickets.mds +25 -20
  114. package/src/assets/commands/explore.mds +4 -5
  115. package/src/assets/commands/implement.mds +58 -45
  116. package/src/assets/commands/plan.mds +34 -29
  117. package/src/assets/commands/release.md +2 -2
  118. package/src/assets/commands/research.mds +11 -9
  119. package/src/assets/commands/resolve.mds +41 -39
  120. package/src/assets/commands/self-review.mds +24 -25
  121. package/src/assets/mds/git/_pr.mds +61 -61
  122. package/src/assets/mds/git/_references.mds +19 -19
  123. package/src/assets/mds/tracker/_common.mds +8 -8
  124. package/src/assets/mds/tracker/_github.mds +71 -71
  125. package/src/assets/mds/tracker/_jira.mds +74 -74
  126. package/src/assets/mds/tracker/_linear.mds +75 -75
  127. package/src/assets/mds/tracker/_mcp.mds +23 -17
  128. package/src/assets/scripts/hooks/background-memory-update +35 -19
  129. package/src/assets/scripts/hooks/capture-prompt +18 -12
  130. package/src/assets/scripts/hooks/capture-question +18 -12
  131. package/src/assets/scripts/hooks/capture-turn +27 -17
  132. package/src/assets/scripts/hooks/debug-trace +11 -6
  133. package/src/assets/scripts/hooks/ensure-devflow-init +33 -6
  134. package/src/assets/scripts/hooks/ensure-proxy +9 -8
  135. package/src/assets/scripts/hooks/ensure-root-gitignore +111 -36
  136. package/src/assets/scripts/hooks/git-marker +48 -0
  137. package/src/assets/scripts/hooks/json-helper.cjs +6 -1
  138. package/src/assets/scripts/hooks/lib/project-paths.cjs +0 -19
  139. package/src/assets/scripts/hooks/log-paths +80 -0
  140. package/src/assets/scripts/hooks/memory-worker +17 -15
  141. package/src/assets/scripts/hooks/pre-compact-memory +41 -16
  142. package/src/assets/scripts/hooks/queue-append +104 -30
  143. package/src/assets/scripts/hooks/resolve-project-root +101 -7
  144. package/src/assets/scripts/hooks/session-start-context +289 -122
  145. package/src/assets/scripts/hooks/session-start-memory +35 -16
  146. package/src/assets/scripts/lib/project-config.cjs +633 -0
  147. package/src/assets/scripts/resolve-evidence-policy.cjs +300 -220
  148. package/src/assets/scripts/resolve-settings.cjs +1054 -0
  149. package/src/assets/scripts/verify-evidence.cjs +1 -1
  150. package/src/assets/skills/compliance/SKILL.md +2 -2
  151. package/src/assets/skills/docs-framework/SKILL.md +6 -7
  152. package/src/assets/skills/docs-framework/references/patterns.md +10 -17
  153. package/src/assets/skills/gap-analysis/SKILL.md +2 -2
  154. package/src/assets/skills/git/references/github-api.md +9 -9
  155. package/src/assets/skills/git/references/patterns.md +1 -1
  156. package/src/assets/skills/worktree-support/SKILL.md +1 -1
  157. package/src/assets/skills/worktree-support/references/roots.md +29 -0
  158. package/src/targets/claude-code/templates/managed-settings.json +25 -9
@@ -0,0 +1,633 @@
1
+ // src/assets/scripts/lib/project-config.cjs
2
+ //
3
+ // The ONE parser of devflow's per-repository config files — the team-committed
4
+ // `.devflow/project.json` and the personal, uncommitted `.devflow/config.json`
5
+ // (applies PF-023: the invariant lives at the sink every reader passes through).
6
+ // Installed beside its two callers as ~/.devflow/scripts/lib/project-config.cjs:
7
+ // resolve-evidence-policy.cjs reads `evidence` and `compliance` at every source
8
+ // resolve-settings.cjs reads every key of both files, locally
9
+ //
10
+ // Pure except the two readers (readBoundedRegularFile, readMachineManifest), which
11
+ // only ever read. Nothing here writes, spawns or prints: devflow never writes
12
+ // project.json (applies ADR-024).
13
+ //
14
+ // D-PROJECT-CONFIG: `.devflow/project.json` is a JSON object whose every key is
15
+ // optional and whose unknown keys are ignored:
16
+ // {"version":1,"evidence":"required|standard","compliance":["gdpr",…],
17
+ // "tracker":{"provider":"github|jira|linear","site":"https://…","key":"ACME"},
18
+ // "reviewPublication":"off|auto|full",
19
+ // "features":{"memory":false,"learning":false,"knowledge":false}}
20
+ // Each known key is classified on its own — absent, valid or malformed — so one
21
+ // bad value never takes its neighbours down with it (AC-26). What a malformed
22
+ // value MEANS is the consumer's decision, made once in each caller's fold:
23
+ // evidence ⇒ required, compliance ⇒ generic, tracker ⇒ TRACKER_WARN=invalid,
24
+ // reviewPublication ⇒ off, a feature switch ⇒ not narrowed.
25
+ //
26
+ // Whole-file rule: that per-key classification applies only INSIDE a file that
27
+ // reads as a JSON object. A file that exists but does not — empty, unparseable,
28
+ // not an object, a BOM, not UTF-8, over MAX_CONFIG_BYTES, too deeply nested, or
29
+ // (at the reader) a symlink or other non-regular file — is `invalid` as a whole,
30
+ // never `absent`, so no key of it can be mistaken for "not set". Each caller
31
+ // fails closed on it: resolve-evidence-policy.cjs resolves `required`,
32
+ // resolve-settings.cjs prints its fail-closed line, and the hooks' switch gate
33
+ // narrows nothing.
34
+ //
35
+ // D-PROJECT-STRICT-KEYS: keys are read with hasOwnProperty (a `__proto__` key is
36
+ // an own data property after JSON.parse, never the prototype), and a key that
37
+ // appears TWICE in the same object is malformed. JSON.parse keeps the last
38
+ // duplicate silently, so the file would say two things while the parser heard
39
+ // one; duplicates are therefore found in the raw text (collectDuplicateKeyPaths)
40
+ // before any value is trusted. A duplicate `evidence` is the case that matters —
41
+ // `{"evidence":"required","evidence":"standard"}` must not read as standard.
42
+ // Strictness is per key only inside a readable object: a file that is not one is
43
+ // `invalid` whole (the whole-file rule above), whatever keys it appears to hold.
44
+
45
+ 'use strict';
46
+
47
+ const fs = require('fs');
48
+ const os = require('os');
49
+ const path = require('path');
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Closed vocabularies
53
+ // ---------------------------------------------------------------------------
54
+
55
+ /** Largest config file read or decoded, in bytes — one bound for project.json and config.json. */
56
+ const MAX_CONFIG_BYTES = 4096;
57
+
58
+ /** Every evidence policy value, strictest first (resolve-evidence-policy.cjs POLICIES). */
59
+ const POLICIES = Object.freeze(['required', 'standard']);
60
+
61
+ /** Every review-publication value, in ascending order: off < auto < full. */
62
+ const PUBLICATIONS = Object.freeze(['off', 'auto', 'full']);
63
+
64
+ /**
65
+ * The registered tracker providers — transcribed from TRACKER_PROVIDERS in
66
+ * src/core/tracker.ts and pinned to it by a parity test.
67
+ */
68
+ const TRACKER_PROVIDER_IDS = Object.freeze(['github', 'jira', 'linear']);
69
+
70
+ /**
71
+ * The registered compliance frameworks, in registry order — transcribed from
72
+ * COMPLIANCE_FRAMEWORKS in src/core/compliance.ts and pinned to it by a parity test.
73
+ */
74
+ const COMPLIANCE_IDS = Object.freeze(['gdpr', 'hipaa', 'pci-dss', 'soc2', 'iso-27001', 'sox']);
75
+
76
+ /** The feature switches a repo layer may narrow. */
77
+ const FEATURE_SWITCHES = Object.freeze(['memory', 'learning', 'knowledge']);
78
+
79
+ /**
80
+ * A tracker site: the Jira setup-task gate (src/assets/mds/tracker/_jira.mds,
81
+ * `**Site.**`) exactly — https, a hostname, no userinfo, no port, no path. Each
82
+ * `+` run is separated by a literal `.`, so the match is linear.
83
+ */
84
+ const TRACKER_SITE_RE = /^https:\/\/[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9-]+)+$/;
85
+
86
+ /** Longest site admitted — a DNS name is at most 253 octets, plus the scheme. */
87
+ const MAX_SITE_LENGTH = 261;
88
+
89
+ /** A tracker project key: the Jira key shape, which the Linear team key also fits. */
90
+ const TRACKER_KEY_RE = /^[A-Z][A-Z0-9_]{1,9}$/;
91
+
92
+ /**
93
+ * A branch name either resolver will put into argv or onto stdout: an
94
+ * alphanumeric first character (so never an option and never a hidden path), then
95
+ * at most 254 of `[A-Za-z0-9._/-]`, and never `..`. The lookahead is bounded by
96
+ * the same class, so the scan is linear (no unbounded `.*`). No `:` is admitted,
97
+ * so `<ref>:<path>` revision syntax built from it names exactly that path.
98
+ */
99
+ const SAFE_REF_RE = /^(?![A-Za-z0-9._/-]{0,254}\.\.)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
100
+
101
+ /** The ref namespace of origin's remote-tracking branches, as `git symbolic-ref` prints it. */
102
+ const ORIGIN_TRACKING_PREFIX = 'refs/remotes/origin/';
103
+
104
+ /** Largest machine manifest read, in bytes. */
105
+ const MAX_MANIFEST_BYTES = 1048576;
106
+
107
+ /** Deepest nesting the duplicate-key scan follows; deeper is invalid. */
108
+ const MAX_JSON_DEPTH = 32;
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Shapes
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * @template T
116
+ * @typedef {{ kind: 'absent' } | { kind: 'malformed' } | { kind: 'valid', value: T }} Field
117
+ *
118
+ * @typedef {{ provider: Field<string>, site: Field<string>, key: Field<string> }} ProjectTracker
119
+ * @typedef {{ memory: Field<boolean>, learning: Field<boolean>, knowledge: Field<boolean> }} FeatureFields
120
+ *
121
+ * @typedef {{ kind: 'absent' } | { kind: 'invalid' } | {
122
+ * kind: 'parsed',
123
+ * version: Field<1>,
124
+ * evidence: Field<'required' | 'standard'>,
125
+ * compliance: Field<string[]>,
126
+ * tracker: Field<ProjectTracker>,
127
+ * reviewPublication: Field<'off' | 'auto' | 'full'>,
128
+ * features: Field<FeatureFields>,
129
+ * }} ProjectConfig
130
+ * `invalid` is a file that exists but is not a JSON object within the byte
131
+ * rules (size, BOM, UTF-8, grammar, depth) — every key of it is unknowable.
132
+ * `compliance` valid is the registry ids it names, normalized (possibly empty).
133
+ *
134
+ * @typedef {{ kind: 'absent' } | { kind: 'invalid' } | {
135
+ * kind: 'parsed',
136
+ * tracker: Field<string>,
137
+ * reviewPublication: Field<'off' | 'auto' | 'full'>,
138
+ * features: Field<FeatureFields>,
139
+ * }} PersonalConfig
140
+ * The personal file's `tracker` is the per-repo provider override: a provider
141
+ * id string (an empty string reads as absent, as parseTrackerOverride does).
142
+ *
143
+ * @typedef {{ kind: 'absent' } | { kind: 'refused' } | { kind: 'ok', bytes: Buffer }} BoundedRead
144
+ */
145
+
146
+ /** @type {{ kind: 'absent' }} */
147
+ const ABSENT_FIELD = Object.freeze({ kind: 'absent' });
148
+ /** @type {{ kind: 'malformed' }} */
149
+ const MALFORMED_FIELD = Object.freeze({ kind: 'malformed' });
150
+ /** @type {{ kind: 'absent' }} */
151
+ const ABSENT_FILE = Object.freeze({ kind: 'absent' });
152
+ /** @type {{ kind: 'invalid' }} */
153
+ const INVALID_FILE = Object.freeze({ kind: 'invalid' });
154
+
155
+ /**
156
+ * @template T
157
+ * @param {T} value
158
+ * @returns {Field<T>}
159
+ */
160
+ function validField(value) {
161
+ return Object.freeze({ kind: 'valid', value });
162
+ }
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Bytes → text (the byte rules both files share)
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Decode config-file bytes, or say why not. `null`/`undefined` is "no file"; an
170
+ * empty file is text (`''`), which every JSON consumer then refuses.
171
+ *
172
+ * The byte checks run before decoding — size, then a UTF-8 BOM, which is invalid
173
+ * rather than skipped — and decoding is fatal on a malformed sequence.
174
+ *
175
+ * @param {unknown} buf
176
+ * @returns {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'text', text: string }}
177
+ */
178
+ function decodeConfigBytes(buf) {
179
+ if (buf === null || buf === undefined) return ABSENT_FILE;
180
+ if (!(buf instanceof Uint8Array)) return INVALID_FILE;
181
+ if (buf.length > MAX_CONFIG_BYTES) return INVALID_FILE;
182
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return INVALID_FILE;
183
+ try {
184
+ // Fatal decoding would silently strip a leading BOM; the byte check above
185
+ // is what makes one invalid.
186
+ return { kind: 'text', text: new TextDecoder('utf-8', { fatal: true }).decode(buf) };
187
+ } catch (_) {
188
+ return INVALID_FILE;
189
+ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Bounded file reads
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Read a regular file of at most `maxBytes`, or say why not.
198
+ *
199
+ * The stat runs first and decides without opening: a non-regular file (with
200
+ * `followSymlinks` false this includes a symlink; always a directory, FIFO,
201
+ * socket or device) is refused unopened, and an oversize one unread. The open
202
+ * then adds O_NONBLOCK (a FIFO swapped in after the stat cannot block) and, when
203
+ * not following, O_NOFOLLOW (a symlink swapped in fails the open); the fstat
204
+ * re-checks the opened object. The read loop is bounded by the byte count.
205
+ *
206
+ * @param {string} filePath
207
+ * @param {number} maxBytes
208
+ * @param {boolean} followSymlinks
209
+ * @returns {BoundedRead}
210
+ */
211
+ function readBoundedRegularFile(filePath, maxBytes, followSymlinks) {
212
+ let st;
213
+ try {
214
+ st = followSymlinks ? fs.statSync(filePath) : fs.lstatSync(filePath);
215
+ } catch (/** @type {any} */ err) {
216
+ return err && (err.code === 'ENOENT' || err.code === 'ENOTDIR') ? { kind: 'absent' } : { kind: 'refused' };
217
+ }
218
+ if (!st.isFile() || st.size > maxBytes) return { kind: 'refused' };
219
+
220
+ const flags = fs.constants.O_RDONLY
221
+ | (fs.constants.O_NONBLOCK || 0)
222
+ | (followSymlinks ? 0 : (fs.constants.O_NOFOLLOW || 0));
223
+ let fd;
224
+ try {
225
+ fd = fs.openSync(filePath, flags);
226
+ } catch (_) {
227
+ return { kind: 'refused' };
228
+ }
229
+ try {
230
+ const fst = fs.fstatSync(fd);
231
+ if (!fst.isFile() || fst.size > maxBytes) return { kind: 'refused' };
232
+ // One byte past the stat'd size: a file that grew between the stat and the
233
+ // read is refused rather than truncated into something that parses.
234
+ const buf = Buffer.alloc(fst.size + 1);
235
+ let total = 0;
236
+ for (let i = 0; i < buf.length; i++) {
237
+ const n = fs.readSync(fd, buf, total, buf.length - total, null);
238
+ if (n === 0) break;
239
+ total += n;
240
+ if (total === buf.length) break;
241
+ }
242
+ if (total > fst.size) return { kind: 'refused' };
243
+ return { kind: 'ok', bytes: buf.subarray(0, total) };
244
+ } catch (_) {
245
+ return { kind: 'refused' };
246
+ } finally {
247
+ try { fs.closeSync(fd); } catch (_) { /* the read already decided; a close error changes nothing */ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * The devflow machine root: ~/.devflow, else null (a home directory that is not
253
+ * absolute would resolve against cwd). No environment variable relocates it
254
+ * (D-ONE-HOME in src/targets/claude-code/claude-paths.ts).
255
+ *
256
+ * @returns {string | null}
257
+ */
258
+ function machineDevflowDir() {
259
+ let home;
260
+ try {
261
+ home = os.homedir();
262
+ } catch (_) {
263
+ // No HOME and no passwd entry: there is no manifest to read, which is not an error.
264
+ return null;
265
+ }
266
+ return typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.devflow') : null;
267
+ }
268
+
269
+ /**
270
+ * The parsed machine manifest (~/.devflow/manifest.json), or undefined when it is
271
+ * absent, not a regular file, over MAX_MANIFEST_BYTES, not UTF-8 or not JSON.
272
+ * Read directly — never through the CLI's manifest reader, which heal-writes.
273
+ * Followed through a symlink: the manifest is the user's own file, not a
274
+ * repository's.
275
+ *
276
+ * @returns {unknown}
277
+ */
278
+ function readMachineManifest() {
279
+ const dir = machineDevflowDir();
280
+ if (dir === null) return undefined;
281
+ const read = readBoundedRegularFile(path.join(dir, 'manifest.json'), MAX_MANIFEST_BYTES, true);
282
+ if (read.kind !== 'ok') return undefined;
283
+ try {
284
+ return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(read.bytes));
285
+ } catch (_) {
286
+ return undefined;
287
+ }
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Duplicate keys (D-PROJECT-STRICT-KEYS)
292
+ // ---------------------------------------------------------------------------
293
+
294
+ /**
295
+ * The key path of an object member, as a lookup string: JSON of the key array,
296
+ * so no key spelling can collide with another path.
297
+ *
298
+ * @param {readonly string[]} keys
299
+ * @returns {string}
300
+ */
301
+ function keyPath(keys) {
302
+ return JSON.stringify(keys);
303
+ }
304
+
305
+ /**
306
+ * Every object-member path that occurs more than once in `text`, which must
307
+ * already be valid JSON (the caller runs JSON.parse first). Null when the text
308
+ * nests deeper than MAX_JSON_DEPTH or a key does not decode — the caller then
309
+ * treats the whole file as invalid.
310
+ *
311
+ * A key is the first string in an object after `{` or `,`, decoded with
312
+ * JSON.parse so an escaped spelling (`"evidence"`) is the key it spells.
313
+ * Array elements share the `[]` path segment. Both loops are bounded by the text
314
+ * length, which the caller bounds by MAX_CONFIG_BYTES.
315
+ *
316
+ * @param {string} text
317
+ * @returns {Set<string> | null}
318
+ */
319
+ function collectDuplicateKeyPaths(text) {
320
+ /** @type {Set<string>} */
321
+ const duplicates = new Set();
322
+ /** @type {Array<{ isObject: boolean, keys: Set<string>, path: string[], expectKey: boolean }>} */
323
+ const stack = [];
324
+ /** @type {string} */
325
+ let lastKey = '';
326
+ for (let i = 0; i < text.length; i++) {
327
+ const ch = text[i];
328
+ const top = stack.length > 0 ? stack[stack.length - 1] : undefined;
329
+ if (ch === '"') {
330
+ let j = i + 1;
331
+ while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1;
332
+ if (j >= text.length) return null;
333
+ if (top !== undefined && top.isObject && top.expectKey) {
334
+ let key;
335
+ try {
336
+ key = JSON.parse(text.slice(i, j + 1));
337
+ } catch (_) {
338
+ return null;
339
+ }
340
+ if (typeof key !== 'string') return null;
341
+ if (top.keys.has(key)) duplicates.add(keyPath(top.path.concat([key])));
342
+ top.keys.add(key);
343
+ top.expectKey = false;
344
+ lastKey = key;
345
+ }
346
+ i = j;
347
+ } else if (ch === '{' || ch === '[') {
348
+ if (stack.length >= MAX_JSON_DEPTH) return null;
349
+ const path = top === undefined ? [] : top.path.concat([top.isObject ? lastKey : '[]']);
350
+ stack.push({ isObject: ch === '{', keys: new Set(), path, expectKey: ch === '{' });
351
+ } else if (ch === '}' || ch === ']') {
352
+ stack.pop();
353
+ } else if (ch === ',' && top !== undefined && top.isObject) {
354
+ top.expectKey = true;
355
+ }
356
+ }
357
+ return duplicates;
358
+ }
359
+
360
+ /**
361
+ * Whether `path`, or any object containing it, is a duplicated member.
362
+ *
363
+ * @param {Set<string>} duplicates
364
+ * @param {readonly string[]} path
365
+ * @returns {boolean}
366
+ */
367
+ function isDuplicated(duplicates, path) {
368
+ for (let n = 1; n <= path.length; n++) {
369
+ if (duplicates.has(keyPath(path.slice(0, n)))) return true;
370
+ }
371
+ return false;
372
+ }
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Value classifiers
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /**
379
+ * @param {unknown} value
380
+ * @returns {value is Record<string, unknown>}
381
+ */
382
+ function isPlainObject(value) {
383
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
384
+ }
385
+
386
+ /**
387
+ * @param {Record<string, unknown>} obj
388
+ * @param {string} key
389
+ * @returns {boolean}
390
+ */
391
+ function own(obj, key) {
392
+ return Object.prototype.hasOwnProperty.call(obj, key);
393
+ }
394
+
395
+ /**
396
+ * Normalize one framework spelling exactly as src/core/compliance.ts normalizeId
397
+ * does: trim, lowercase, whitespace runs to `-`, and the `iso27001` alias.
398
+ *
399
+ * @param {string} s
400
+ * @returns {string}
401
+ */
402
+ function normalizeComplianceId(s) {
403
+ const normalized = s.trim().toLowerCase().replace(/\s+/g, '-');
404
+ return normalized === 'iso27001' ? 'iso-27001' : normalized;
405
+ }
406
+
407
+ /**
408
+ * The default branch a `git symbolic-ref --quiet refs/remotes/origin/HEAD` answer
409
+ * names — the branch `git clone` (or `git remote set-head`) last recorded for
410
+ * origin, read with no network call. Exactly one line, `refs/remotes/origin/`
411
+ * followed by a SAFE_REF_RE branch; anything else (another remote, a second line,
412
+ * a hostile name) is null. Pure: the caller runs git and hands over its stdout.
413
+ *
414
+ * @param {unknown} stdout
415
+ * @returns {string | null}
416
+ */
417
+ function parseOriginHeadRef(stdout) {
418
+ if (typeof stdout !== 'string') return null;
419
+ const line = stdout.endsWith('\n') ? stdout.slice(0, -1) : stdout;
420
+ if (!line.startsWith(ORIGIN_TRACKING_PREFIX)) return null;
421
+ const branch = line.slice(ORIGIN_TRACKING_PREFIX.length);
422
+ return SAFE_REF_RE.test(branch) ? branch : null;
423
+ }
424
+
425
+ /**
426
+ * The registry ids a framework list names — src/core/compliance.ts
427
+ * normalizeFrameworks exactly, behind a parity test: normalized, unknown ids
428
+ * dropped, first occurrence kept.
429
+ *
430
+ * @param {readonly string[]} frameworks
431
+ * @returns {string[]}
432
+ */
433
+ function normalizeComplianceIds(frameworks) {
434
+ /** @type {Set<string>} */
435
+ const seen = new Set();
436
+ /** @type {string[]} */
437
+ const out = [];
438
+ for (const raw of frameworks) {
439
+ const id = normalizeComplianceId(raw);
440
+ if (!COMPLIANCE_IDS.includes(id) || seen.has(id)) continue;
441
+ seen.add(id);
442
+ out.push(id);
443
+ }
444
+ return out;
445
+ }
446
+
447
+ /**
448
+ * The fields of one object, each classified on its own.
449
+ *
450
+ * @template T
451
+ * @param {Record<string, unknown>} obj
452
+ * @param {Set<string>} duplicates
453
+ * @param {readonly string[]} parentPath
454
+ * @param {string} key
455
+ * @param {(value: unknown) => { ok: true, value: T } | { ok: false }} classify
456
+ * @returns {Field<T>}
457
+ */
458
+ function fieldOf(obj, duplicates, parentPath, key, classify) {
459
+ if (!own(obj, key)) return ABSENT_FIELD;
460
+ const path = parentPath.concat([key]);
461
+ if (isDuplicated(duplicates, path)) return MALFORMED_FIELD;
462
+ const verdict = classify(obj[key]);
463
+ return verdict.ok ? validField(verdict.value) : MALFORMED_FIELD;
464
+ }
465
+
466
+ /**
467
+ * @template T
468
+ * @param {readonly T[]} allowed
469
+ * @returns {(value: unknown) => { ok: true, value: T } | { ok: false }}
470
+ */
471
+ function oneOf(allowed) {
472
+ return value => (allowed.includes(/** @type {T} */ (value)) ? { ok: true, value: /** @type {T} */ (value) } : { ok: false });
473
+ }
474
+
475
+ /** @param {unknown} value */
476
+ function asBoolean(value) {
477
+ return typeof value === 'boolean' ? { ok: /** @type {const} */ (true), value } : { ok: /** @type {const} */ (false) };
478
+ }
479
+
480
+ /** @param {unknown} value */
481
+ function asVersion(value) {
482
+ return value === 1 ? { ok: /** @type {const} */ (true), value: /** @type {1} */ (1) } : { ok: /** @type {const} */ (false) };
483
+ }
484
+
485
+ /** @param {unknown} value */
486
+ function asComplianceList(value) {
487
+ if (!Array.isArray(value) || !value.every(v => typeof v === 'string')) return { ok: /** @type {const} */ (false) };
488
+ return { ok: /** @type {const} */ (true), value: Object.freeze(normalizeComplianceIds(value)) };
489
+ }
490
+
491
+ /** @param {unknown} value */
492
+ function asSite(value) {
493
+ return typeof value === 'string' && value.length <= MAX_SITE_LENGTH && TRACKER_SITE_RE.test(value)
494
+ ? { ok: /** @type {const} */ (true), value }
495
+ : { ok: /** @type {const} */ (false) };
496
+ }
497
+
498
+ /** @param {unknown} value */
499
+ function asKey(value) {
500
+ return typeof value === 'string' && TRACKER_KEY_RE.test(value)
501
+ ? { ok: /** @type {const} */ (true), value }
502
+ : { ok: /** @type {const} */ (false) };
503
+ }
504
+
505
+ /**
506
+ * The `features` object: each switch its own field, boolean only. A consumer
507
+ * narrows on a literal `false` alone.
508
+ *
509
+ * @param {Record<string, unknown>} obj
510
+ * @param {Set<string>} duplicates
511
+ * @returns {Field<FeatureFields>}
512
+ */
513
+ function featuresField(obj, duplicates) {
514
+ return fieldOf(obj, duplicates, [], 'features', value => {
515
+ if (!isPlainObject(value)) return { ok: false };
516
+ const base = ['features'];
517
+ return {
518
+ ok: true,
519
+ value: Object.freeze({
520
+ memory: fieldOf(value, duplicates, base, 'memory', asBoolean),
521
+ learning: fieldOf(value, duplicates, base, 'learning', asBoolean),
522
+ knowledge: fieldOf(value, duplicates, base, 'knowledge', asBoolean),
523
+ }),
524
+ };
525
+ });
526
+ }
527
+
528
+ // ---------------------------------------------------------------------------
529
+ // The envelope
530
+ // ---------------------------------------------------------------------------
531
+
532
+ /**
533
+ * Bytes → a plain JSON object and its duplicated paths, or the file-level verdict.
534
+ *
535
+ * @param {unknown} buf
536
+ * @returns {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'object', obj: Record<string, unknown>, duplicates: Set<string> }}
537
+ */
538
+ function parseEnvelope(buf) {
539
+ const decoded = decodeConfigBytes(buf);
540
+ if (decoded.kind !== 'text') return decoded;
541
+ let parsed;
542
+ try {
543
+ parsed = JSON.parse(decoded.text);
544
+ } catch (_) {
545
+ return INVALID_FILE;
546
+ }
547
+ if (!isPlainObject(parsed)) return INVALID_FILE;
548
+ const duplicates = collectDuplicateKeyPaths(decoded.text);
549
+ if (duplicates === null) return INVALID_FILE;
550
+ return { kind: 'object', obj: parsed, duplicates };
551
+ }
552
+
553
+ /**
554
+ * Classify `.devflow/project.json` bytes (D-PROJECT-CONFIG, D-PROJECT-STRICT-KEYS).
555
+ * `null`/`undefined` is "no file". Never throws.
556
+ *
557
+ * @param {unknown} buf
558
+ * @returns {ProjectConfig}
559
+ */
560
+ function parseProjectBytes(buf) {
561
+ const env = parseEnvelope(buf);
562
+ if (env.kind !== 'object') return env;
563
+ const { obj, duplicates } = env;
564
+ return Object.freeze({
565
+ kind: 'parsed',
566
+ version: fieldOf(obj, duplicates, [], 'version', asVersion),
567
+ evidence: fieldOf(obj, duplicates, [], 'evidence', oneOf(POLICIES)),
568
+ compliance: fieldOf(obj, duplicates, [], 'compliance', asComplianceList),
569
+ tracker: fieldOf(obj, duplicates, [], 'tracker', value => {
570
+ if (!isPlainObject(value)) return { ok: false };
571
+ const base = ['tracker'];
572
+ return {
573
+ ok: true,
574
+ value: Object.freeze({
575
+ provider: fieldOf(value, duplicates, base, 'provider', oneOf(TRACKER_PROVIDER_IDS)),
576
+ site: fieldOf(value, duplicates, base, 'site', asSite),
577
+ key: fieldOf(value, duplicates, base, 'key', asKey),
578
+ }),
579
+ };
580
+ }),
581
+ reviewPublication: fieldOf(obj, duplicates, [], 'reviewPublication', oneOf(PUBLICATIONS)),
582
+ features: featuresField(obj, duplicates),
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Classify the personal `.devflow/config.json` bytes with the same rules. Only
588
+ * the keys a personal file may carry are read: `reviewPublication`, `features`
589
+ * and the `tracker` override. The retired top-level switches (`memory`,
590
+ * `learning`, `knowledge`, `decisions`) are never read — `features` is a new
591
+ * namespace, so a stale `learning: false` left from the per-repo-install era
592
+ * cannot come back to life. Never throws.
593
+ *
594
+ * @param {unknown} buf
595
+ * @returns {PersonalConfig}
596
+ */
597
+ function parsePersonalBytes(buf) {
598
+ const env = parseEnvelope(buf);
599
+ if (env.kind !== 'object') return env;
600
+ const { obj, duplicates } = env;
601
+ // `"tracker": ""` is an unset key with a character in it (parseTrackerOverride).
602
+ const tracker = own(obj, 'tracker') && obj.tracker === '' && !isDuplicated(duplicates, ['tracker'])
603
+ ? ABSENT_FIELD
604
+ : fieldOf(obj, duplicates, [], 'tracker', oneOf(TRACKER_PROVIDER_IDS));
605
+ return Object.freeze({
606
+ kind: 'parsed',
607
+ tracker,
608
+ reviewPublication: fieldOf(obj, duplicates, [], 'reviewPublication', oneOf(PUBLICATIONS)),
609
+ features: featuresField(obj, duplicates),
610
+ });
611
+ }
612
+
613
+ module.exports = Object.freeze({
614
+ MAX_CONFIG_BYTES,
615
+ POLICIES,
616
+ PUBLICATIONS,
617
+ TRACKER_PROVIDER_IDS,
618
+ COMPLIANCE_IDS,
619
+ FEATURE_SWITCHES,
620
+ TRACKER_SITE_RE,
621
+ TRACKER_KEY_RE,
622
+ SAFE_REF_RE,
623
+ ORIGIN_TRACKING_PREFIX,
624
+ decodeConfigBytes,
625
+ readBoundedRegularFile,
626
+ machineDevflowDir,
627
+ readMachineManifest,
628
+ collectDuplicateKeyPaths,
629
+ normalizeComplianceIds,
630
+ parseOriginHeadRef,
631
+ parseProjectBytes,
632
+ parsePersonalBytes,
633
+ });