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,1054 @@
1
+ #!/usr/bin/env node
2
+ // src/assets/scripts/resolve-settings.cjs
3
+ //
4
+ // Resolves the per-repository SETTINGS a prompt or the CLI acts on — tracker,
5
+ // review publication, compliance lens and the three feature switches — from
6
+ // LOCAL layers, and prints them on ONE closed-vocabulary line. Installed as a
7
+ // top-level sibling of resolve-evidence-policy.cjs under ~/.devflow/scripts/,
8
+ // sharing its parser (lib/project-config.cjs).
9
+ //
10
+ // Usage: node resolve-settings.cjs [<dir>] (<dir> defaults to cwd)
11
+ //
12
+ // The layers, in the order they are folded:
13
+ // project <toplevel>/.devflow/project.json team-committed, this worktree's copy
14
+ // default refs/remotes/origin/<D>:.devflow/project.json
15
+ // the default branch's tracking copy —
16
+ // its compliance ids only (D-LENS-UNION)
17
+ // personal <toplevel>/.devflow/config.json untracked, narrow-only — a copy git
18
+ // tracks is ignored (D-PERSONAL-UNTRACKED)
19
+ // machine ~/.devflow/manifest.json the machine-wide install
20
+ //
21
+ // D-SETTINGS-LOCAL-ONLY: local git subprocesses only, and never gh, never the
22
+ // network, never a git command that refreshes the index. At most four, in order:
23
+ // git rev-parse --show-toplevel always
24
+ // git -c core.fsmonitor=false ls-files --error-unmatch -- .devflow/config.json
25
+ // only when config.json exists
26
+ // git symbolic-ref --quiet refs/remotes/origin/HEAD in a repository
27
+ // git cat-file blob refs/remotes/origin/<D>:.devflow/project.json
28
+ // only when origin/HEAD names D
29
+ // The team's evidence FLOOR is the default branch's and is
30
+ // resolve-evidence-policy.cjs's job (it runs over the network, from commands).
31
+ // This script reads the WORKTREE's copy for every other key, so a branch that
32
+ // edits project.json changes what it resolves for that branch: the feature
33
+ // switches can only narrow, the compliance lens can only gain scrutiny — the
34
+ // default branch's ids stay in it (D-LENS-UNION) — and the team's review
35
+ // publication can only lower it (D-PUBLICATION-CEILING) — a branch's
36
+ // `reviewPublication: "full"` raises nothing, since only a personal value asks
37
+ // for more than `auto`. The tracker (provider, site, key) is taken as the
38
+ // branch states it. It WRITES NOTHING (applies ADR-024).
39
+ //
40
+ // stdout is exactly one line plus "\n", or empty (D-SETTINGS-LINE):
41
+ // TRACKER=<github|jira|linear> TRACKER_SOURCE=<project|personal|machine|default>
42
+ // TRACKER_WARN=<none|mismatch|invalid> SITE=<url|none> KEY=<KEY|none>
43
+ // REVIEW_PUBLICATION=<off|auto|full> COMPLIANCE=<off|generic|id,…>
44
+ // MEMORY=<on|off> LEARNING=<on|off> KNOWLEDGE=<on|off>
45
+ // (one line on stdout; wrapped here for reading). Every value is a closed token
46
+ // or a SITE/KEY that passed its shape gate, so no other byte of a config file can
47
+ // reach stdout. Whole-file rule: a project.json or config.json that EXISTS but
48
+ // cannot be read as a JSON object — unparseable, empty, not an object, a BOM,
49
+ // not UTF-8, over MAX_CONFIG_BYTES, a symlink or other non-regular file — fails
50
+ // every field closed except COMPLIANCE, which keeps every lens a readable layer
51
+ // declares (exit 0, the file named on stderr). Only keys inside a readable object
52
+ // are classified one by one.
53
+ //
54
+ // Exit codes (a caller treats EVERY non-zero code as the fail-closed line):
55
+ // 0 resolved — the line above, including the whole-file rule's line
56
+ // 1 usage error — stdout entirely empty, usage on stderr
57
+ // 2 input unusable — <dir> missing or not a directory; prints
58
+ // SETTINGS_FAIL_CLOSED_LINE
59
+ // 3 never emitted — this script writes no file
60
+ // 4 internal error — or git could not say whether <dir> is in a repository;
61
+ // prints SETTINGS_FAIL_CLOSED_LINE
62
+ // 5 output gate refused — the composed line failed the grammar or its
63
+ // coherence checks; prints SETTINGS_FAIL_CLOSED_LINE
64
+ //
65
+ // Design constraints (binding, shared with resolve-evidence-policy.cjs):
66
+ // - main() returns {code, line} and never calls process.exit; the single
67
+ // `require.main === module` boundary is the only stdout write and the only
68
+ // exitCode assignment
69
+ // - every subprocess is spawned with an argv array (never a shell), stdin
70
+ // ignored, a timeout and a maxBuffer
71
+ // - a config file that is not a regular file is never opened, and one over
72
+ // MAX_CONFIG_BYTES is never read
73
+
74
+ 'use strict';
75
+
76
+ const fs = require('fs');
77
+ const path = require('path');
78
+ const childProcess = require('child_process');
79
+ const projectConfig = require('./lib/project-config.cjs');
80
+
81
+ const {
82
+ PUBLICATIONS,
83
+ TRACKER_PROVIDER_IDS,
84
+ COMPLIANCE_IDS,
85
+ FEATURE_SWITCHES,
86
+ TRACKER_SITE_RE,
87
+ TRACKER_KEY_RE,
88
+ MAX_CONFIG_BYTES,
89
+ } = projectConfig;
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Closed vocabularies
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /** @typedef {'github' | 'jira' | 'linear'} TrackerProvider */
96
+ /** @typedef {'project' | 'personal' | 'machine' | 'default'} TrackerSource */
97
+ /** @typedef {'none' | 'mismatch' | 'invalid'} TrackerWarn */
98
+ /** @typedef {'off' | 'auto' | 'full'} Publication */
99
+ /** @typedef {'memory' | 'learning' | 'knowledge'} FeatureSwitch */
100
+ /** @typedef {'machine' | 'project' | 'personal'} SwitchSource */
101
+
102
+ /** Who decided TRACKER. `default` is github with no layer naming a provider. */
103
+ const TRACKER_SOURCES = Object.freeze(/** @type {TrackerSource[]} */ (['project', 'personal', 'machine', 'default']));
104
+
105
+ /**
106
+ * TRACKER_WARN tokens, mildest first.
107
+ * mismatch the personal override names a provider other than github or the
108
+ * resolved one — a personal file may only narrow, so it is ignored
109
+ * invalid a tracker value (project provider, site or key; the personal
110
+ * override) is malformed. The Git agent maps this to its existing
111
+ * `unknown tracker provider` DEGRADED reason
112
+ */
113
+ const TRACKER_WARNS = Object.freeze(/** @type {TrackerWarn[]} */ (['none', 'mismatch', 'invalid']));
114
+
115
+ /** Exit codes by meaning; 3 is deliberately absent (no arm can produce it). */
116
+ const EXIT_CODES = Object.freeze({
117
+ RESOLVED: 0,
118
+ USAGE: 1,
119
+ INPUT_UNUSABLE: 2,
120
+ INTERNAL_ERROR: 4,
121
+ OUTPUT_GATE_REFUSED: 5,
122
+ });
123
+
124
+ /**
125
+ * D-SETTINGS-LINE: the exported output grammar — anchored, fixed field order,
126
+ * closed alternations built from the shared registries, named groups for
127
+ * consumers. SITE and KEY reuse the parser's own shape gates, so a line can only
128
+ * carry a site or key the parser admitted. COMPLIANCE is `off`, `generic` (the
129
+ * lens with no framework reference), or registry ids joined by commas.
130
+ *
131
+ * Whole-file rule: the line is folded only from files that are absent or read as
132
+ * a JSON object. A repository file that exists and does not (the parser's
133
+ * `invalid` file) says nothing that can be trusted — a team file meant to declare
134
+ * `compliance:["hipaa"]` must not read as "no file" — so every field takes its
135
+ * SETTINGS_FAIL_CLOSED_LINE value, for the team project.json and the personal
136
+ * config.json alike, EXCEPT COMPLIANCE. The review lens only adds scrutiny
137
+ * (D-LENS-UNION), and a broken file affects only the keys it owns, so it never
138
+ * lowers a lens another layer declares: COMPLIANCE is the fold of the readable
139
+ * layers — the machine's ids, the default branch's, and the worktree
140
+ * project.json's when only config.json is broken (a personal file declares no
141
+ * compliance) — with an unreadable project.json read as a malformed declaration,
142
+ * `generic`. That line prints with exit 0, the unreadable file named on stderr,
143
+ * so a consumer that accepts only exit 0 acts on it; an unreadable project.json
144
+ * with no other lens anywhere is exactly SETTINGS_FAIL_CLOSED_LINE, and an
145
+ * unreadable machine manifest reads as no lens. Individually malformed keys
146
+ * inside a readable object keep their per-key readings (AC-26).
147
+ */
148
+ const SETTINGS_LINE_RE = new RegExp(
149
+ '^TRACKER=(?<tracker>' + TRACKER_PROVIDER_IDS.join('|') + ')'
150
+ + ' TRACKER_SOURCE=(?<trackerSource>' + TRACKER_SOURCES.join('|') + ')'
151
+ + ' TRACKER_WARN=(?<trackerWarn>' + TRACKER_WARNS.join('|') + ')'
152
+ + ' SITE=(?<site>none|' + TRACKER_SITE_RE.source.slice(1, -1) + ')'
153
+ + ' KEY=(?<key>none|' + TRACKER_KEY_RE.source.slice(1, -1) + ')'
154
+ + ' REVIEW_PUBLICATION=(?<publication>' + PUBLICATIONS.join('|') + ')'
155
+ + ' COMPLIANCE=(?<compliance>off|generic|(?:' + COMPLIANCE_IDS.join('|') + ')(?:,(?:'
156
+ + COMPLIANCE_IDS.join('|') + ')){0,' + String(COMPLIANCE_IDS.length - 1) + '})'
157
+ + ' MEMORY=(?<memory>on|off) LEARNING=(?<learning>on|off) KNOWLEDGE=(?<knowledge>on|off)$',
158
+ );
159
+
160
+ /**
161
+ * The line every refusal prints (exits 2, 4 and 5), what a consumer uses in place
162
+ * of any line it cannot accept, and the whole-file rule's line on a machine with
163
+ * no compliance lens. A constant, so the boundary can never fail
164
+ * to compose it. Each field is the conservative reading for its consumer:
165
+ * TRACKER_WARN=invalid the Git agent degrades rather than guessing a provider
166
+ * REVIEW_PUBLICATION=off nothing is published (raised to a stub under required)
167
+ * COMPLIANCE=generic the review lens still runs
168
+ * KNOWLEDGE=off no knowledge write-back commits into the repository
169
+ * MEMORY/LEARNING=on the machine switch is the one that turns them off;
170
+ * hooks never consume this line (their gate folds an
171
+ * unreadable file as narrowing nothing)
172
+ */
173
+ const SETTINGS_FAIL_CLOSED_LINE =
174
+ 'TRACKER=github TRACKER_SOURCE=default TRACKER_WARN=invalid SITE=none KEY=none '
175
+ + 'REVIEW_PUBLICATION=off COMPLIANCE=generic MEMORY=on LEARNING=on KNOWLEDGE=off';
176
+
177
+ /** Bound on each local git call. */
178
+ const GIT_TIMEOUT_MS = 5000;
179
+ /** rev-parse, ls-files and symbolic-ref print one short line. */
180
+ const LINE_MAX_BUFFER = 4096;
181
+ /**
182
+ * The tracking-copy read: one byte past the parser's bound, so an oversize blob
183
+ * either arrives whole and is refused by the parser, or overflows (ENOBUFS) and
184
+ * reads as unanswered — never truncated into something that parses.
185
+ */
186
+ const BLOB_MAX_BUFFER = MAX_CONFIG_BYTES + 1;
187
+
188
+ /** The personal file, repository-relative, in git's spelling. */
189
+ const PERSONAL_REL = '.devflow/config.json';
190
+ /** The team file, repository-relative, in git's spelling. */
191
+ const PROJECT_REL = '.devflow/project.json';
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Shapes
195
+ // ---------------------------------------------------------------------------
196
+
197
+ /**
198
+ * @typedef {{ on: boolean, source: SwitchSource }} SwitchState
199
+ * `source` is the layer that decided: `machine` when the machine switch is off
200
+ * or nothing narrowed it, else the repo layer whose literal `false` narrowed it.
201
+ *
202
+ * @typedef {{
203
+ * ok: boolean,
204
+ * tracker: TrackerProvider,
205
+ * trackerSource: TrackerSource,
206
+ * trackerWarn: TrackerWarn,
207
+ * site: string | null,
208
+ * key: string | null,
209
+ * reviewPublication: Publication,
210
+ * compliance: { enabled: boolean, frameworks: readonly string[] },
211
+ * switches: { memory: SwitchState, learning: SwitchState, knowledge: SwitchState },
212
+ * repoCompliance: readonly string[] | null,
213
+ * defaultBranchCompliance: readonly string[] | null,
214
+ * personalTracked: boolean,
215
+ * retiredPolicyFile: boolean,
216
+ * unreadable: 'project' | 'personal' | null,
217
+ * }} Settings
218
+ * ok false only for a fail-closed resolution — the whole-file
219
+ * rule's included, which still carries every readable lens
220
+ * compliance enabled with no frameworks is `generic`
221
+ * repoCompliance the worktree project.json's ids, or null when it declares
222
+ * none (a malformed declaration reads as [] — generic)
223
+ * defaultBranchCompliance the default branch's tracking copy's ids, the same
224
+ * way — null when it declares none or there is no tracking copy
225
+ * personalTracked .devflow/config.json is tracked by git and was ignored
226
+ * (D-PERSONAL-UNTRACKED); main() tells the user to untrack it
227
+ * retiredPolicyFile the worktree holds the retired .devflow/policy.json (the
228
+ * CLI prints a migration hint)
229
+ * unreadable the first repository layer whose file exists but is
230
+ * unreadable, which failed every field but the compliance
231
+ * lens closed (the whole-file rule; project before personal);
232
+ * null otherwise
233
+ *
234
+ * @typedef {{
235
+ * project: object,
236
+ * personal: object,
237
+ * manifest: unknown,
238
+ * retiredPolicyFile: boolean,
239
+ * defaultBranch?: ComplianceField,
240
+ * personalTracked?: boolean,
241
+ * }} SettingsInputs
242
+ * project/personal are lib/project-config.cjs ProjectConfig / PersonalConfig.
243
+ * defaultBranch is the tracking copy's `compliance` field (absent when omitted).
244
+ *
245
+ * @typedef {{ kind: 'absent' } | { kind: 'malformed' } | { kind: 'valid', value: readonly string[] }} ComplianceField
246
+ *
247
+ * @typedef {{ project: object, personal: object, personalTracked: boolean }} RepoLayers
248
+ *
249
+ * @typedef {{ status: number | null, stdout?: Buffer | string, error?: { code?: string } }} ExecResult
250
+ * @typedef {{ answered: boolean, ok: boolean, stdout: Buffer }} GitAnswer
251
+ * answered git ran and exited on its own (no spawn error, timeout or overflow)
252
+ * ok answered with exit 0
253
+ * @typedef {(file: string, args: string[], opts: object) => ExecResult} ExecFn
254
+ * @typedef {{ dir: string, manifest?: unknown }} ResolveSettingsOptions
255
+ * `manifest` is an already-parsed ~/.devflow/manifest.json; omitted
256
+ * (undefined), the script reads it itself.
257
+ */
258
+
259
+ /** @type {Settings} */
260
+ const FAIL_CLOSED_SETTINGS = Object.freeze({
261
+ ok: false,
262
+ tracker: 'github',
263
+ trackerSource: 'default',
264
+ trackerWarn: 'invalid',
265
+ site: null,
266
+ key: null,
267
+ reviewPublication: 'off',
268
+ compliance: Object.freeze({ enabled: true, frameworks: Object.freeze([]) }),
269
+ switches: Object.freeze({
270
+ memory: Object.freeze({ on: true, source: /** @type {SwitchSource} */ ('machine') }),
271
+ learning: Object.freeze({ on: true, source: /** @type {SwitchSource} */ ('machine') }),
272
+ knowledge: Object.freeze({ on: false, source: /** @type {SwitchSource} */ ('machine') }),
273
+ }),
274
+ repoCompliance: null,
275
+ defaultBranchCompliance: null,
276
+ personalTracked: false,
277
+ retiredPolicyFile: false,
278
+ unreadable: null,
279
+ });
280
+
281
+
282
+ const ABSENT_FIELD = Object.freeze({ kind: 'absent' });
283
+ const MALFORMED_FIELD = Object.freeze({ kind: 'malformed' });
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Machine-layer mirrors (pinned to the TypeScript by parity tests)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * @param {unknown} value
291
+ * @returns {value is Record<string, any>}
292
+ */
293
+ function isPlainObject(value) {
294
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
295
+ }
296
+
297
+ /** The pre-rename key each switch was stored under (feature-switch.ts LEGACY_KEYS). */
298
+ const LEGACY_SWITCH_KEYS = Object.freeze({ learning: 'decisions', knowledge: 'kb' });
299
+
300
+ /**
301
+ * src/core/feature-switch.ts isMachineFeatureOn exactly: only an explicit
302
+ * boolean `false` is off, the legacy key is read when the current one is not a
303
+ * boolean, and anything that is not a manifest-shaped object is on.
304
+ *
305
+ * @param {unknown} manifest
306
+ * @param {FeatureSwitch} feature
307
+ * @returns {boolean}
308
+ */
309
+ function machineSwitchOn(manifest, feature) {
310
+ if (!isPlainObject(manifest)) return true;
311
+ const features = manifest.features;
312
+ if (!isPlainObject(features)) return true;
313
+ const legacy = /** @type {Record<string, string>} */ (LEGACY_SWITCH_KEYS)[feature];
314
+ const value = legacy !== undefined && typeof features[feature] !== 'boolean' ? features[legacy] : features[feature];
315
+ return value !== false;
316
+ }
317
+
318
+ /**
319
+ * src/core/tracker.ts normalizeTrackerFeature, plus whether a provider was
320
+ * actually recorded (`machine`) or defaulted (`default`).
321
+ *
322
+ * @param {unknown} manifest
323
+ * @returns {{ provider: TrackerProvider, recorded: boolean }}
324
+ */
325
+ function machineTracker(manifest) {
326
+ const tracker = isPlainObject(manifest) && isPlainObject(manifest.features) ? manifest.features.tracker : undefined;
327
+ if (isPlainObject(tracker) && TRACKER_PROVIDER_IDS.includes(tracker.provider)) {
328
+ return { provider: /** @type {TrackerProvider} */ (tracker.provider), recorded: true };
329
+ }
330
+ return { provider: 'github', recorded: false };
331
+ }
332
+
333
+ /**
334
+ * The machine compliance lens: null when off, else its registry ids —
335
+ * src/core/compliance.ts normalizeComplianceFeature, then normalizeFrameworks.
336
+ *
337
+ * @param {unknown} manifest
338
+ * @returns {string[] | null}
339
+ */
340
+ function machineCompliance(manifest) {
341
+ const raw = isPlainObject(manifest) && isPlainObject(manifest.features) ? manifest.features.compliance : undefined;
342
+ if (!isPlainObject(raw) || typeof raw.enabled !== 'boolean' || !Array.isArray(raw.frameworks)) return null;
343
+ if (!raw.frameworks.every((/** @type {unknown} */ f) => typeof f === 'string')) return null;
344
+ return raw.enabled ? projectConfig.normalizeComplianceIds(raw.frameworks) : null;
345
+ }
346
+
347
+ // ---------------------------------------------------------------------------
348
+ // The fold (the functional core)
349
+ //
350
+ // The fold reads a file-level `invalid` field by field (fieldIn) so that it never
351
+ // throws on any parser output. resolveSettings folds such a file for the
352
+ // compliance lens alone — an unreadable project.json is a malformed declaration,
353
+ // `generic` — and fails every other field closed first (the whole-file rule); the
354
+ // hooks' switch gate folds it whole, where an unreadable file narrows nothing.
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /**
358
+ * A field of a parsed file, or the file-level verdict applied to it: an absent
359
+ * file has the field absent; an invalid file has every field malformed.
360
+ *
361
+ * @param {any} file
362
+ * @param {string} key
363
+ * @param {'malformed' | 'absent'} whenInvalid
364
+ * @returns {any}
365
+ */
366
+ function fieldIn(file, key, whenInvalid) {
367
+ if (file.kind === 'parsed') return file[key];
368
+ if (file.kind === 'invalid') return whenInvalid === 'malformed' ? MALFORMED_FIELD : ABSENT_FIELD;
369
+ return ABSENT_FIELD;
370
+ }
371
+
372
+ /**
373
+ * The tracker (D-SETTINGS-LINE, tracker rules):
374
+ * 1. project.json `tracker.provider`, else the machine's recorded provider,
375
+ * else github (`default`);
376
+ * 2. the personal override may only NARROW — to github, or to the provider
377
+ * step 1 resolved. Anything else is ignored and flagged `mismatch`;
378
+ * 3. a malformed value anywhere (project provider/site/key, the personal
379
+ * override, an unreadable project.json) is flagged `invalid`, and a
380
+ * malformed site or key is never printed.
381
+ * SITE and KEY are the project's, printed only when the resolved provider is not
382
+ * github — a github repository has neither.
383
+ *
384
+ * @param {any} project
385
+ * @param {any} personal
386
+ * @param {unknown} manifest
387
+ * @returns {Pick<Settings, 'tracker' | 'trackerSource' | 'trackerWarn' | 'site' | 'key'>}
388
+ */
389
+ function foldTracker(project, personal, manifest) {
390
+ let invalid = false;
391
+ let mismatch = false;
392
+ /** @type {TrackerProvider | null} */
393
+ let projectProvider = null;
394
+ /** @type {string | null} */
395
+ let site = null;
396
+ /** @type {string | null} */
397
+ let key = null;
398
+
399
+ const projectTracker = fieldIn(project, 'tracker', 'malformed');
400
+ if (projectTracker.kind === 'malformed') invalid = true;
401
+ if (projectTracker.kind === 'valid') {
402
+ const t = projectTracker.value;
403
+ if (t.provider.kind === 'valid') projectProvider = t.provider.value;
404
+ if (t.site.kind === 'valid') site = t.site.value;
405
+ if (t.key.kind === 'valid') key = t.key.value;
406
+ if (t.provider.kind === 'malformed' || t.site.kind === 'malformed' || t.key.kind === 'malformed') invalid = true;
407
+ }
408
+
409
+ /** @type {TrackerProvider} */
410
+ let tracker;
411
+ /** @type {TrackerSource} */
412
+ let trackerSource;
413
+ if (projectProvider !== null) {
414
+ tracker = projectProvider;
415
+ trackerSource = 'project';
416
+ } else {
417
+ const machine = machineTracker(manifest);
418
+ tracker = machine.provider;
419
+ trackerSource = machine.recorded ? 'machine' : 'default';
420
+ }
421
+
422
+ // An unreadable personal file is no override at all — readConfig's reading.
423
+ const override = fieldIn(personal, 'tracker', 'absent');
424
+ if (override.kind === 'malformed') invalid = true;
425
+ if (override.kind === 'valid') {
426
+ if (override.value === 'github' || override.value === tracker) {
427
+ tracker = override.value;
428
+ trackerSource = 'personal';
429
+ } else {
430
+ mismatch = true;
431
+ }
432
+ }
433
+
434
+ return {
435
+ tracker,
436
+ trackerSource,
437
+ trackerWarn: invalid ? 'invalid' : mismatch ? 'mismatch' : 'none',
438
+ site: tracker === 'github' ? null : site,
439
+ key: tracker === 'github' ? null : key,
440
+ };
441
+ }
442
+
443
+ /**
444
+ * D-PUBLICATION-CEILING: review publication is
445
+ * min(team ?? full, personal ?? auto) over off < auto < full.
446
+ * The team value is a CEILING only — never a default. It can lower what the
447
+ * personal value asks for, but it cannot raise a run above `auto`: this script
448
+ * reads the worktree's project.json, so a contributor's branch committing
449
+ * `"reviewPublication":"full"` would otherwise make a maintainer's local review
450
+ * of that branch skip the visibility gate on a public repository. Only the
451
+ * uncommitted personal value asks for `full` — and a config.json git tracks is
452
+ * not personal, so it never reaches this fold (D-PERSONAL-UNTRACKED). A
453
+ * malformed team value (or an unreadable project.json) is `off` — a ceiling
454
+ * that cannot be read is the lowest one. A malformed personal value is
455
+ * ignored, as readConfig ignores it, and so resolves `auto`.
456
+ *
457
+ * @param {any} project
458
+ * @param {any} personal
459
+ * @returns {Publication}
460
+ */
461
+ function foldPublication(project, personal) {
462
+ const teamField = fieldIn(project, 'reviewPublication', 'malformed');
463
+ /** @type {Publication | null} */
464
+ const team = teamField.kind === 'valid' ? teamField.value : teamField.kind === 'malformed' ? 'off' : null;
465
+ const personalField = fieldIn(personal, 'reviewPublication', 'absent');
466
+ /** @type {Publication | null} */
467
+ const own = personalField.kind === 'valid' ? personalField.value : null;
468
+ const ceiling = team === null ? 'full' : team;
469
+ const wanted = own !== null ? own : 'auto';
470
+ return PUBLICATIONS.indexOf(wanted) <= PUBLICATIONS.indexOf(ceiling) ? wanted : ceiling;
471
+ }
472
+
473
+ /**
474
+ * The ids a compliance field declares: its registry ids when valid, [] (generic)
475
+ * when malformed, null when absent.
476
+ *
477
+ * @param {ComplianceField} field
478
+ * @returns {readonly string[] | null}
479
+ */
480
+ function declaredIds(field) {
481
+ if (field.kind === 'valid') return field.value;
482
+ return field.kind === 'malformed' ? Object.freeze([]) : null;
483
+ }
484
+
485
+ /**
486
+ * D-LENS-UNION: the compliance lens is
487
+ * machine ∪ default branch ∪ worktree
488
+ * — the machine's ids, the default branch's project.json ids (its local tracking
489
+ * copy, refs/remotes/origin/<D>), and the worktree project.json's. The lens only
490
+ * adds scrutiny, so no layer can take a framework away from another: a branch can
491
+ * ADD a framework in its own project.json, and can never REMOVE one the default
492
+ * branch declares — a PR that deletes `compliance:["hipaa"]` is still reviewed
493
+ * under hipaa. The default branch's copy is read locally, never over the network
494
+ * (D-SETTINGS-LOCAL-ONLY), so it is as fresh as the last fetch; with no tracking
495
+ * copy the lens is machine ∪ worktree. `off` only when no layer declares
496
+ * anything; a malformed declaration in either repository copy — an unreadable
497
+ * file, or a tracking copy git could not read — is `generic`: the lens runs with
498
+ * no framework reference, never silently not at all. The evidence floor is not
499
+ * this lens's to decide (resolve-evidence-policy.cjs).
500
+ *
501
+ * @param {any} project
502
+ * @param {unknown} manifest
503
+ * @param {ComplianceField} defaultBranch
504
+ * @returns {{ compliance: Settings['compliance'], repoCompliance: readonly string[] | null, defaultBranchCompliance: readonly string[] | null }}
505
+ */
506
+ function foldCompliance(project, manifest, defaultBranch) {
507
+ const machine = machineCompliance(manifest);
508
+ const repo = declaredIds(fieldIn(project, 'compliance', 'malformed'));
509
+ const base = declaredIds(defaultBranch);
510
+ if (machine === null && repo === null && base === null) {
511
+ return {
512
+ compliance: Object.freeze({ enabled: false, frameworks: Object.freeze([]) }),
513
+ repoCompliance: null,
514
+ defaultBranchCompliance: null,
515
+ };
516
+ }
517
+ /** @param {readonly string[] | null} ids @param {string} id */
518
+ const has = (ids, id) => ids !== null && ids.includes(id);
519
+ const union = COMPLIANCE_IDS.filter(id => has(machine, id) || has(base, id) || has(repo, id));
520
+ return {
521
+ compliance: Object.freeze({ enabled: true, frameworks: Object.freeze(union) }),
522
+ repoCompliance: repo,
523
+ defaultBranchCompliance: base,
524
+ };
525
+ }
526
+
527
+ /**
528
+ * D-FEATURES-NARROW-ONLY (src/core/feature-switch.ts): a switch is on iff the
529
+ * machine switch is on AND project.json's `features.<name>` is not `false` AND
530
+ * config.json's `features.<name>` is not `false`. Only a literal `false`
531
+ * narrows: absent, malformed, duplicated and unreadable all leave the switch as
532
+ * the machine set it. No repo layer can turn on what the machine turned off.
533
+ *
534
+ * @param {any} project
535
+ * @param {any} personal
536
+ * @param {unknown} manifest
537
+ * @param {FeatureSwitch} feature
538
+ * @returns {SwitchState}
539
+ */
540
+ function foldSwitch(project, personal, manifest, feature) {
541
+ /** @param {any} file */
542
+ const narrows = file => {
543
+ const features = fieldIn(file, 'features', 'absent');
544
+ return features.kind === 'valid' && features.value[feature].kind === 'valid' && features.value[feature].value === false;
545
+ };
546
+ if (!machineSwitchOn(manifest, feature)) return Object.freeze({ on: false, source: 'machine' });
547
+ if (narrows(project)) return Object.freeze({ on: false, source: 'project' });
548
+ if (narrows(personal)) return Object.freeze({ on: false, source: 'personal' });
549
+ return Object.freeze({ on: true, source: 'machine' });
550
+ }
551
+
552
+ /**
553
+ * The whole-file rule's resolution (D-SETTINGS-LINE): the fail-closed values,
554
+ * naming the layer whose file could not be read, except the compliance lens —
555
+ * a broken file affects only the keys it owns, so the lens stays the fold of
556
+ * every readable layer (D-LENS-UNION). An unreadable project.json declares
557
+ * nothing readable of its own (repoCompliance null) but reads as a malformed
558
+ * declaration, so its lens is at least `generic`; an unreadable config.json
559
+ * owns no compliance at all, so the worktree project.json's ids still count.
560
+ *
561
+ * @param {'project' | 'personal'} layer
562
+ * @param {SettingsInputs} inputs
563
+ * @returns {Settings}
564
+ */
565
+ function unreadableSettings(layer, inputs) {
566
+ const defaultBranch = inputs.defaultBranch === undefined ? ABSENT_FIELD : inputs.defaultBranch;
567
+ const lens = foldCompliance(inputs.project, inputs.manifest, defaultBranch);
568
+ return Object.freeze({
569
+ ...FAIL_CLOSED_SETTINGS,
570
+ compliance: lens.compliance,
571
+ repoCompliance: layer === 'project' ? null : lens.repoCompliance,
572
+ defaultBranchCompliance: lens.defaultBranchCompliance,
573
+ personalTracked: inputs.personalTracked === true,
574
+ unreadable: layer,
575
+ });
576
+ }
577
+
578
+ /**
579
+ * Fold the layers into Settings. Pure; never throws on any parser output.
580
+ *
581
+ * @param {SettingsInputs} inputs
582
+ * @returns {Settings}
583
+ */
584
+ function foldSettings(inputs) {
585
+ const { project, personal, manifest } = inputs;
586
+ const tracker = foldTracker(project, personal, manifest);
587
+ const lens = foldCompliance(project, manifest, inputs.defaultBranch === undefined ? ABSENT_FIELD : inputs.defaultBranch);
588
+ return Object.freeze({
589
+ ok: true,
590
+ ...tracker,
591
+ reviewPublication: foldPublication(project, personal),
592
+ compliance: lens.compliance,
593
+ switches: Object.freeze({
594
+ memory: foldSwitch(project, personal, manifest, 'memory'),
595
+ learning: foldSwitch(project, personal, manifest, 'learning'),
596
+ knowledge: foldSwitch(project, personal, manifest, 'knowledge'),
597
+ }),
598
+ repoCompliance: lens.repoCompliance,
599
+ defaultBranchCompliance: lens.defaultBranchCompliance,
600
+ personalTracked: inputs.personalTracked === true,
601
+ retiredPolicyFile: inputs.retiredPolicyFile === true,
602
+ unreadable: null,
603
+ });
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // Gathering the layers (the imperative shell — D-SETTINGS-LOCAL-ONLY)
608
+ // ---------------------------------------------------------------------------
609
+
610
+ /**
611
+ * The production exec: spawnSync, read off the module object at call time.
612
+ *
613
+ * @type {ExecFn}
614
+ */
615
+ function defaultExec(file, args, opts) {
616
+ return childProcess.spawnSync(file, args, /** @type {any} */ (opts));
617
+ }
618
+
619
+ /**
620
+ * One bounded local git call, normalized. The argv is a fresh copy, so an exec
621
+ * cannot mutate a caller's constant.
622
+ *
623
+ * @param {ExecFn} exec
624
+ * @param {string} cwd
625
+ * @param {readonly string[]} args
626
+ * @param {number} maxBuffer
627
+ * @returns {GitAnswer}
628
+ */
629
+ function runGit(exec, cwd, args, maxBuffer) {
630
+ const res = exec('git', [...args], {
631
+ cwd,
632
+ env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0' }),
633
+ stdio: ['ignore', 'pipe', 'pipe'],
634
+ timeout: GIT_TIMEOUT_MS,
635
+ maxBuffer,
636
+ windowsHide: true,
637
+ shell: false,
638
+ });
639
+ const answered = Boolean(res) && !res.error && typeof res.status === 'number';
640
+ const raw = res ? res.stdout : undefined;
641
+ const stdout = Buffer.isBuffer(raw) ? raw : typeof raw === 'string' ? Buffer.from(raw, 'utf8') : Buffer.alloc(0);
642
+ return { answered, ok: answered && res.status === 0, stdout };
643
+ }
644
+
645
+ /**
646
+ * The repository root of `dir`.
647
+ * root exit 0 with exactly one absolute path plus its newline
648
+ * none git ANSWERED non-zero — not a repository; only the machine layer applies
649
+ * unknown git did not answer, or answered 0 with an unusable path ⇒ fail closed
650
+ *
651
+ * @param {ExecFn} exec
652
+ * @param {string} dir
653
+ * @returns {{ kind: 'root', root: string } | { kind: 'none' } | { kind: 'unknown' }}
654
+ */
655
+ function gitToplevel(exec, dir) {
656
+ const res = runGit(exec, dir, ['rev-parse', '--show-toplevel'], LINE_MAX_BUFFER);
657
+ if (!res.answered) return { kind: 'unknown' };
658
+ if (!res.ok) return { kind: 'none' };
659
+ const text = res.stdout.toString('utf8').replace(/\r?\n$/, '');
660
+ if (text === '' || /[\r\n\0]/.test(text) || !path.isAbsolute(text)) return { kind: 'unknown' };
661
+ return { kind: 'root', root: text };
662
+ }
663
+
664
+ /**
665
+ * D-PERSONAL-UNTRACKED: `.devflow/config.json` holds PERSONAL settings — the one
666
+ * layer that may ask for `reviewPublication: "full"` — so a copy git TRACKS is
667
+ * not personal: a contributor's branch that commits one (`git add -f`) would
668
+ * otherwise lift a maintainer's local run of that branch past the publication
669
+ * ceiling. A tracked copy is ignored entirely, exactly as if absent, and main()
670
+ * tells the user to untrack it. The check is `git ls-files --error-unmatch`,
671
+ * which reads the index without refreshing or writing it. Reading the index
672
+ * runs a configured `core.fsmonitor` hook — an arbitrary command from the
673
+ * repository's config, and on macOS the builtin daemon's start-up — so the
674
+ * call turns it off for itself (`-c core.fsmonitor=false`, D-NO-FSMONITOR):
675
+ * this resolver runs from session hooks and must stay a pure read.
676
+ * tracked exit 0
677
+ * untracked any other answered exit (1: no such index entry; outside a
678
+ * repository git answers 128, and there is nothing to track)
679
+ * unknown git did not answer — whether the file is personal cannot be
680
+ * known, so it is unreadable and its keys fail closed
681
+ *
682
+ * @param {ExecFn} exec
683
+ * @param {string} root
684
+ * @returns {'tracked' | 'untracked' | 'unknown'}
685
+ */
686
+ function personalTracking(exec, root) {
687
+ const res = runGit(exec, root, ['-c', 'core.fsmonitor=false', 'ls-files', '--error-unmatch', '--', PERSONAL_REL], LINE_MAX_BUFFER);
688
+ if (!res.answered) return 'unknown';
689
+ return res.ok ? 'tracked' : 'untracked';
690
+ }
691
+
692
+ /**
693
+ * One repository config file, classified. lstat-refused, never followed: a
694
+ * symlink, directory, FIFO or device — or a file over MAX_CONFIG_BYTES — is
695
+ * invalid unopened.
696
+ *
697
+ * @param {string} filePath
698
+ * @param {(buf: Buffer | null) => object} parse
699
+ * @returns {object}
700
+ */
701
+ function readConfigFile(filePath, parse) {
702
+ const read = projectConfig.readBoundedRegularFile(filePath, MAX_CONFIG_BYTES, false);
703
+ if (read.kind === 'ok') return parse(read.bytes);
704
+ return read.kind === 'absent' ? parse(null) : Object.freeze({ kind: 'invalid' });
705
+ }
706
+
707
+ /**
708
+ * Whether anything exists at `filePath` (never followed, never opened).
709
+ *
710
+ * @param {string} filePath
711
+ * @returns {boolean}
712
+ */
713
+ function existsNoFollow(filePath) {
714
+ try {
715
+ fs.lstatSync(filePath);
716
+ return true;
717
+ } catch (_) {
718
+ return false;
719
+ }
720
+ }
721
+
722
+ /**
723
+ * The two repository layers under `<root>/.devflow`, each read and classified by
724
+ * readConfigFile. Exported for the hooks' one parser fork (queue_read_gates in
725
+ * scripts/hooks/queue-append, D-FEATURES-NARROW-ONLY): a file the shell fast path
726
+ * hands over is read by exactly the code resolveSettings reads it with, so the two
727
+ * cannot disagree about a symlink, a size, a BOM, a duplicated key — or a
728
+ * config.json git tracks (D-PERSONAL-UNTRACKED), which is absent to both. Its only
729
+ * subprocess is that tracking check, made only when config.json exists; never
730
+ * throws on a missing or unreadable file.
731
+ *
732
+ * @param {string} root
733
+ * @param {{ exec?: ExecFn }} [deps]
734
+ * @returns {RepoLayers}
735
+ */
736
+ function readRepoLayers(root, deps) {
737
+ const exec = deps && typeof deps.exec === 'function' ? deps.exec : defaultExec;
738
+ const devflow = path.join(root, '.devflow');
739
+ const project = readConfigFile(path.join(devflow, 'project.json'), projectConfig.parseProjectBytes);
740
+ const personalPath = path.join(devflow, 'config.json');
741
+ const tracking = existsNoFollow(personalPath) ? personalTracking(exec, root) : 'untracked';
742
+ const personal = tracking === 'tracked' ? projectConfig.parsePersonalBytes(null)
743
+ : tracking === 'unknown' ? Object.freeze({ kind: 'invalid' })
744
+ : readConfigFile(personalPath, projectConfig.parsePersonalBytes);
745
+ return Object.freeze({ project, personal, personalTracked: tracking === 'tracked' });
746
+ }
747
+
748
+ /**
749
+ * The default branch's `compliance` declaration, from its LOCAL tracking copy
750
+ * (D-LENS-UNION, D-SETTINGS-LOCAL-ONLY): origin/HEAD names the branch D, and
751
+ * `refs/remotes/origin/<D>:.devflow/project.json` is read through the same
752
+ * parser. The lens only adds, so every state that cannot be read is a malformed
753
+ * declaration (`generic`), never "declares nothing" (avoids PF-075):
754
+ * absent origin/HEAD is not recorded, or D holds no project.json
755
+ * malformed a git call did not answer, origin/HEAD names no safe branch, the
756
+ * blob overflows its bound, the file is unreadable, or its
757
+ * `compliance` value is malformed
758
+ * valid the registry ids it declares
759
+ *
760
+ * @param {ExecFn} exec
761
+ * @param {string} root
762
+ * @returns {ComplianceField}
763
+ */
764
+ function defaultBranchCompliance(exec, root) {
765
+ const head = runGit(exec, root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], LINE_MAX_BUFFER);
766
+ if (!head.answered) return MALFORMED_FIELD;
767
+ if (!head.ok) return ABSENT_FIELD;
768
+ const branch = projectConfig.parseOriginHeadRef(head.stdout.toString('utf8'));
769
+ if (branch === null) return MALFORMED_FIELD;
770
+ const blob = runGit(exec, root,
771
+ ['cat-file', 'blob', projectConfig.ORIGIN_TRACKING_PREFIX + branch + ':' + PROJECT_REL], BLOB_MAX_BUFFER);
772
+ if (!blob.answered) return MALFORMED_FIELD;
773
+ if (!blob.ok) return ABSENT_FIELD;
774
+ return fieldIn(projectConfig.parseProjectBytes(blob.stdout), 'compliance', 'malformed');
775
+ }
776
+
777
+ /**
778
+ * Resolve the settings for `opts.dir`. Never throws: a git that cannot say
779
+ * whether `opts.dir` is in a repository, or any internal failure, is the
780
+ * fail-closed resolution, which main() maps to exit 4. A repository file that
781
+ * exists but is unreadable fails every field closed but the compliance lens,
782
+ * naming its layer in `unreadable` (the whole-file rule, D-SETTINGS-LINE);
783
+ * main() prints that line with exit 0.
784
+ *
785
+ * @param {ResolveSettingsOptions} opts
786
+ * @param {{ exec?: ExecFn }} [deps]
787
+ * @returns {Settings}
788
+ */
789
+ function resolveSettings(opts, deps) {
790
+ try {
791
+ const exec = deps && typeof deps.exec === 'function' ? deps.exec : defaultExec;
792
+ const manifest = opts.manifest !== undefined ? opts.manifest : projectConfig.readMachineManifest();
793
+ const toplevel = gitToplevel(exec, opts.dir);
794
+ if (toplevel.kind === 'unknown') return FAIL_CLOSED_SETTINGS;
795
+ if (toplevel.kind === 'none') {
796
+ const none = projectConfig.parseProjectBytes(null);
797
+ return foldSettings({ project: none, personal: none, manifest, retiredPolicyFile: false });
798
+ }
799
+ const layers = readRepoLayers(toplevel.root, { exec });
800
+ /** @type {SettingsInputs} */
801
+ const inputs = {
802
+ project: layers.project,
803
+ personal: layers.personal,
804
+ personalTracked: layers.personalTracked,
805
+ manifest,
806
+ defaultBranch: defaultBranchCompliance(exec, toplevel.root),
807
+ retiredPolicyFile: existsNoFollow(path.join(toplevel.root, '.devflow', 'policy.json')),
808
+ };
809
+ if (layers.project.kind === 'invalid') return unreadableSettings('project', inputs);
810
+ if (layers.personal.kind === 'invalid') return unreadableSettings('personal', inputs);
811
+ return foldSettings(inputs);
812
+ } catch (_) {
813
+ return FAIL_CLOSED_SETTINGS;
814
+ }
815
+ }
816
+
817
+ // ---------------------------------------------------------------------------
818
+ // The line, and the gate on it
819
+ // ---------------------------------------------------------------------------
820
+
821
+ /**
822
+ * The COMPLIANCE token for a lens.
823
+ *
824
+ * @param {Settings['compliance']} c
825
+ * @returns {string}
826
+ */
827
+ function complianceToken(c) {
828
+ if (!c.enabled) return 'off';
829
+ return c.frameworks.length === 0 ? 'generic' : c.frameworks.join(',');
830
+ }
831
+
832
+ /**
833
+ * Compose the stdout line (D-SETTINGS-LINE).
834
+ *
835
+ * @param {Settings} s
836
+ * @returns {string}
837
+ */
838
+ function formatSettingsLine(s) {
839
+ /** @param {SwitchState} sw */
840
+ const onOff = sw => (sw.on ? 'on' : 'off');
841
+ return 'TRACKER=' + s.tracker
842
+ + ' TRACKER_SOURCE=' + s.trackerSource
843
+ + ' TRACKER_WARN=' + s.trackerWarn
844
+ + ' SITE=' + (s.site === null ? 'none' : s.site)
845
+ + ' KEY=' + (s.key === null ? 'none' : s.key)
846
+ + ' REVIEW_PUBLICATION=' + s.reviewPublication
847
+ + ' COMPLIANCE=' + complianceToken(s.compliance)
848
+ + ' MEMORY=' + onOff(s.switches.memory)
849
+ + ' LEARNING=' + onOff(s.switches.learning)
850
+ + ' KNOWLEDGE=' + onOff(s.switches.knowledge);
851
+ }
852
+
853
+ /**
854
+ * Whether a line is well-formed AND coherent: it matches SETTINGS_LINE_RE, its
855
+ * compliance ids are unique and in registry order, a github tracker carries no
856
+ * SITE or KEY, and a `default` tracker source is github.
857
+ *
858
+ * @param {unknown} line
859
+ * @returns {boolean}
860
+ */
861
+ function isCoherentSettingsLine(line) {
862
+ if (typeof line !== 'string') return false;
863
+ const m = SETTINGS_LINE_RE.exec(line);
864
+ if (m === null || m.groups === undefined) return false;
865
+ const g = m.groups;
866
+ if (g.tracker === 'github' && (g.site !== 'none' || g.key !== 'none')) return false;
867
+ if (g.trackerSource === 'default' && g.tracker !== 'github') return false;
868
+ if (g.compliance !== 'off' && g.compliance !== 'generic') {
869
+ const order = g.compliance.split(',').map(id => COMPLIANCE_IDS.indexOf(id));
870
+ for (let i = 1; i < order.length; i++) {
871
+ if (order[i] <= order[i - 1]) return false;
872
+ }
873
+ }
874
+ return true;
875
+ }
876
+
877
+ /**
878
+ * Settle whatever main() returned into what the boundary may print.
879
+ *
880
+ * @param {unknown} outcome
881
+ * @returns {{ code: number, line: string }}
882
+ */
883
+ function settleOutcome(outcome) {
884
+ const o = /** @type {any} */ (outcome);
885
+ if (o === null || typeof o !== 'object' || typeof o.line !== 'string') {
886
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
887
+ }
888
+ if (o.code === EXIT_CODES.USAGE) return { code: EXIT_CODES.USAGE, line: '' };
889
+ if (o.code === EXIT_CODES.RESOLVED) {
890
+ return isCoherentSettingsLine(o.line)
891
+ ? { code: o.code, line: o.line }
892
+ : { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: SETTINGS_FAIL_CLOSED_LINE };
893
+ }
894
+ if (o.code === EXIT_CODES.INPUT_UNUSABLE || o.code === EXIT_CODES.OUTPUT_GATE_REFUSED) {
895
+ return { code: o.code, line: SETTINGS_FAIL_CLOSED_LINE };
896
+ }
897
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
898
+ }
899
+
900
+ // ---------------------------------------------------------------------------
901
+ // The suggestion the CLI prints (never writes — ADR-024)
902
+ // ---------------------------------------------------------------------------
903
+
904
+ /**
905
+ * The canonical `.devflow/project.json` bytes the CLI suggests a team commit:
906
+ * `{"version":1[,"evidence":…][,"compliance":[…]]}\n`, in that key order.
907
+ * `evidence` must be a policy value; `compliance` a list of strings, normalized
908
+ * to registry ids as the parser would read them. Null for anything else, and
909
+ * null unless the bytes read back through parseProjectBytes as exactly what was
910
+ * asked for — nothing half-formed is ever printed.
911
+ *
912
+ * @param {unknown} input `{ evidence?, compliance? }`
913
+ * @returns {string | null}
914
+ */
915
+ function serializeProjectSuggestion(input) {
916
+ if (!isPlainObject(input)) return null;
917
+ /** @type {Record<string, unknown>} */
918
+ const out = { version: 1 };
919
+ if (Object.prototype.hasOwnProperty.call(input, 'evidence')) {
920
+ if (!projectConfig.POLICIES.includes(input.evidence)) return null;
921
+ out.evidence = input.evidence;
922
+ }
923
+ if (Object.prototype.hasOwnProperty.call(input, 'compliance')) {
924
+ const list = input.compliance;
925
+ if (!Array.isArray(list) || !list.every(v => typeof v === 'string')) return null;
926
+ out.compliance = projectConfig.normalizeComplianceIds(list);
927
+ }
928
+ const text = JSON.stringify(out) + '\n';
929
+ const back = /** @type {any} */ (projectConfig.parseProjectBytes(Buffer.from(text, 'utf8')));
930
+ if (back.kind !== 'parsed' || back.version.kind !== 'valid') return null;
931
+ if (out.evidence !== undefined && (back.evidence.kind !== 'valid' || back.evidence.value !== out.evidence)) return null;
932
+ if (out.compliance !== undefined && back.compliance.kind !== 'valid') return null;
933
+ return text;
934
+ }
935
+
936
+ // ---------------------------------------------------------------------------
937
+ // main — returns {code, line}; never calls process.exit
938
+ // ---------------------------------------------------------------------------
939
+
940
+ /**
941
+ * @param {readonly string[]} argv process.argv
942
+ * @returns {{ kind: 'resolve', dir: string } | { kind: 'usage', usage: string }}
943
+ */
944
+ function parseArgs(argv) {
945
+ const USAGE = 'Usage: node resolve-settings.cjs [<dir>]';
946
+ const rest = Array.isArray(argv) ? argv.slice(2) : [];
947
+ /** @type {string[]} */
948
+ const positionals = [];
949
+ for (const arg of rest) {
950
+ if (typeof arg !== 'string' || arg.startsWith('-')) {
951
+ return {
952
+ kind: 'usage',
953
+ usage: 'resolve-settings: unrecognised argument ' + JSON.stringify(String(arg)).slice(0, 80) + '\n' + USAGE,
954
+ };
955
+ }
956
+ positionals.push(arg);
957
+ }
958
+ if (positionals.length > 1) return { kind: 'usage', usage: USAGE };
959
+ return { kind: 'resolve', dir: positionals.length === 1 ? positionals[0] : process.cwd() };
960
+ }
961
+
962
+ /**
963
+ * @param {string} dir
964
+ * @returns {boolean}
965
+ */
966
+ function isUsableDirectory(dir) {
967
+ try {
968
+ return fs.statSync(dir).isDirectory();
969
+ } catch (_) {
970
+ return false;
971
+ }
972
+ }
973
+
974
+ /**
975
+ * @param {readonly string[]} argv process.argv
976
+ * @param {{ exec?: ExecFn, formatLine?: (s: Settings) => unknown }} [deps] tests only
977
+ * @returns {{ code: number, line: string }}
978
+ */
979
+ function main(argv, deps) {
980
+ const d = deps || {};
981
+ const args = parseArgs(argv);
982
+ if (args.kind === 'usage') {
983
+ process.stderr.write(args.usage + '\n');
984
+ return { code: EXIT_CODES.USAGE, line: '' };
985
+ }
986
+ if (!isUsableDirectory(args.dir)) {
987
+ process.stderr.write('resolve-settings: not a usable directory: '
988
+ + JSON.stringify(args.dir).slice(0, 300) + ' — failing closed\n');
989
+ return { code: EXIT_CODES.INPUT_UNUSABLE, line: SETTINGS_FAIL_CLOSED_LINE };
990
+ }
991
+ const settings = resolveSettings({ dir: args.dir }, { exec: d.exec });
992
+ if (settings.personalTracked) {
993
+ process.stderr.write('resolve-settings: warning: ' + PERSONAL_REL + ' is tracked by git, so it is ignored'
994
+ + ' — it holds personal settings; untrack it with: git rm --cached ' + PERSONAL_REL + '\n');
995
+ }
996
+ if (settings.unreadable !== null) {
997
+ process.stderr.write('resolve-settings: ' + (settings.unreadable === 'project' ? PROJECT_REL : PERSONAL_REL)
998
+ + ' exists but could not be read (not a JSON object, or git could not say whether it is tracked)'
999
+ + ' — failing its fields closed, keeping every readable compliance lens\n');
1000
+ } else if (!settings.ok) {
1001
+ process.stderr.write('resolve-settings: could not resolve (git did not answer, or an internal error) — failing closed\n');
1002
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
1003
+ }
1004
+ const format = typeof d.formatLine === 'function' ? d.formatLine : formatSettingsLine;
1005
+ let line;
1006
+ try {
1007
+ line = format(settings);
1008
+ } catch (_) {
1009
+ process.stderr.write('resolve-settings: internal error composing the line — failing closed\n');
1010
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
1011
+ }
1012
+ if (!isCoherentSettingsLine(line)) {
1013
+ process.stderr.write('resolve-settings: output gate refused the composed line — failing closed\n');
1014
+ return { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: SETTINGS_FAIL_CLOSED_LINE };
1015
+ }
1016
+ return { code: EXIT_CODES.RESOLVED, line: /** @type {string} */ (line) };
1017
+ }
1018
+
1019
+ // ---------------------------------------------------------------------------
1020
+ // Top-level boundary — the ONLY stdout write and the ONLY exitCode assignment
1021
+ // ---------------------------------------------------------------------------
1022
+
1023
+ if (require.main === module) {
1024
+ let outcome;
1025
+ try {
1026
+ outcome = main(process.argv);
1027
+ } catch (_) {
1028
+ process.stderr.write('resolve-settings: internal error — failing closed\n');
1029
+ outcome = { code: EXIT_CODES.INTERNAL_ERROR, line: SETTINGS_FAIL_CLOSED_LINE };
1030
+ }
1031
+ const settled = settleOutcome(outcome);
1032
+ if (settled.line !== '') process.stdout.write(settled.line + '\n');
1033
+ process.exitCode = settled.code;
1034
+ }
1035
+
1036
+ // ---------------------------------------------------------------------------
1037
+ // Exports — the CLI seam (src/core/evidence-policy.ts) and the unit tests
1038
+ // ---------------------------------------------------------------------------
1039
+
1040
+ module.exports = Object.freeze({
1041
+ TRACKER_SOURCES,
1042
+ TRACKER_WARNS,
1043
+ EXIT_CODES,
1044
+ SETTINGS_LINE_RE,
1045
+ SETTINGS_FAIL_CLOSED_LINE,
1046
+ parseArgs,
1047
+ foldSettings,
1048
+ readRepoLayers,
1049
+ resolveSettings,
1050
+ formatSettingsLine,
1051
+ isCoherentSettingsLine,
1052
+ serializeProjectSuggestion,
1053
+ main,
1054
+ });