devflow-kit 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +86 -18
  3. package/dist/agents/git.md +824 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/attribution-prompts.js +1 -1
  6. package/dist/cli/commands/compliance-prompts.js +1 -1
  7. package/dist/cli/commands/compliance.js +23 -1
  8. package/dist/cli/commands/init-seed.js +24 -26
  9. package/dist/cli/commands/init.js +502 -71
  10. package/dist/cli/commands/install-report.js +205 -0
  11. package/dist/cli/commands/knowledge/index.js +2 -2
  12. package/dist/cli/commands/knowledge/toggle.js +27 -37
  13. package/dist/cli/commands/learning.js +37 -30
  14. package/dist/cli/commands/memory.js +79 -69
  15. package/dist/cli/commands/prompt-io.js +4 -4
  16. package/dist/cli/commands/security.js +76 -16
  17. package/dist/cli/commands/skills.js +53 -7
  18. package/dist/cli/commands/tracker-prompts.js +145 -0
  19. package/dist/cli/commands/tracker.js +405 -0
  20. package/dist/cli/commands/uninstall.js +211 -65
  21. package/dist/cli.js +2 -0
  22. package/dist/commands/bug-analysis.md +22 -4
  23. package/dist/commands/code-review.md +44 -15
  24. package/dist/commands/debug.md +20 -6
  25. package/dist/commands/dynamic-build.md +289 -67
  26. package/dist/commands/dynamic-plan.md +60 -21
  27. package/dist/commands/dynamic-profile.md +1 -1
  28. package/dist/commands/dynamic-tickets.md +58 -8
  29. package/dist/commands/explore.md +2 -2
  30. package/dist/commands/implement.md +241 -53
  31. package/dist/commands/plan.md +88 -17
  32. package/dist/commands/release.md +64 -17
  33. package/dist/commands/resolve.md +138 -58
  34. package/dist/commands/self-review.md +2 -2
  35. package/dist/core/agent-models.js +55 -12
  36. package/dist/core/assets.js +58 -2
  37. package/dist/core/evidence-policy.js +147 -0
  38. package/dist/core/feature-config.js +130 -64
  39. package/dist/core/feature-switch.js +112 -0
  40. package/dist/core/flags.js +4 -4
  41. package/dist/core/manifest.js +33 -7
  42. package/dist/core/mds-variants.js +861 -0
  43. package/dist/core/model-discovery.js +12 -1
  44. package/dist/core/plugins.js +357 -9
  45. package/dist/core/project-paths.js +1 -1
  46. package/dist/core/proxy-log.js +8 -6
  47. package/dist/core/proxy-state.js +11 -8
  48. package/dist/core/reference-sweep.js +136 -0
  49. package/dist/core/tracker.js +407 -0
  50. package/dist/skills/git/references/decision-markers.md +19 -0
  51. package/dist/skills/git/references/learn-conventions.md +56 -0
  52. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  53. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  54. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  55. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  56. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  57. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  58. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  59. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  60. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  61. package/dist/skills/git/references/publication-gate.md +13 -0
  62. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  63. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  65. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  66. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  67. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  68. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  69. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  70. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  71. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  72. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  73. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  74. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  75. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  76. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  77. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  78. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  79. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  80. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  81. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  82. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  83. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  84. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  85. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  87. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  88. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  89. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  90. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  91. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  92. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  93. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  94. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  95. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  96. package/dist/skills/git/references/trust-rule.md +7 -0
  97. package/dist/targets/claude-code/installer.js +1213 -31
  98. package/dist/targets/claude-code/legacy.js +5 -0
  99. package/dist/targets/claude-code/post-install.js +196 -74
  100. package/dist/targets/claude-code/tracker-install.js +161 -0
  101. package/package.json +4 -3
  102. package/src/assets/agents/code.md +42 -4
  103. package/src/assets/agents/design.md +1 -1
  104. package/src/assets/agents/git.mds +827 -0
  105. package/src/assets/agents/knowledge.md +1 -1
  106. package/src/assets/agents/learning.md +11 -0
  107. package/src/assets/agents/synthesize.md +1 -1
  108. package/src/assets/agents/test.md +16 -5
  109. package/src/assets/agents/tracker.md +467 -0
  110. package/src/assets/agents/validate.md +7 -5
  111. package/src/assets/commands/_partials/_engine.mds +11 -9
  112. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  113. package/src/assets/commands/_partials/_knowledge.mds +2 -2
  114. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  115. package/src/assets/commands/_partials/_preamble.mds +1 -1
  116. package/src/assets/commands/_partials/_publication.mds +3 -1
  117. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  118. package/src/assets/commands/_partials/_tracker.mds +18 -0
  119. package/src/assets/commands/_partials/_wave.mds +16 -10
  120. package/src/assets/commands/bug-analysis.mds +15 -5
  121. package/src/assets/commands/code-review.mds +34 -14
  122. package/src/assets/commands/debug.mds +11 -4
  123. package/src/assets/commands/dynamic-build.mds +227 -41
  124. package/src/assets/commands/dynamic-plan.mds +35 -13
  125. package/src/assets/commands/dynamic-tickets.mds +47 -5
  126. package/src/assets/commands/implement.mds +206 -52
  127. package/src/assets/commands/plan.mds +70 -17
  128. package/src/assets/commands/release.md +64 -17
  129. package/src/assets/commands/resolve.mds +126 -56
  130. package/src/assets/mds/git/_pr.mds +331 -0
  131. package/src/assets/mds/git/_references.mds +135 -0
  132. package/src/assets/mds/tracker/_common.mds +156 -0
  133. package/src/assets/mds/tracker/_github.mds +472 -0
  134. package/src/assets/mds/tracker/_jira.mds +407 -0
  135. package/src/assets/mds/tracker/_linear.mds +449 -0
  136. package/src/assets/mds/tracker/_mcp.mds +299 -0
  137. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  138. package/src/assets/scripts/hooks/background-memory-update +14 -9
  139. package/src/assets/scripts/hooks/capture-prompt +6 -2
  140. package/src/assets/scripts/hooks/capture-question +6 -2
  141. package/src/assets/scripts/hooks/capture-turn +6 -2
  142. package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
  143. package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
  144. package/src/assets/scripts/hooks/hook-log-init +3 -1
  145. package/src/assets/scripts/hooks/json-helper.cjs +223 -5
  146. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
  147. package/src/assets/scripts/hooks/memory-worker +15 -8
  148. package/src/assets/scripts/hooks/pre-compact-memory +12 -8
  149. package/src/assets/scripts/hooks/preamble +1 -4
  150. package/src/assets/scripts/hooks/queue-append +68 -24
  151. package/src/assets/scripts/hooks/session-start-context +355 -8
  152. package/src/assets/scripts/hooks/session-start-memory +12 -8
  153. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  154. package/src/assets/scripts/redact-secrets.cjs +490 -62
  155. package/src/assets/scripts/release-trace.cjs +1143 -0
  156. package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
  157. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  158. package/src/assets/skills/compliance/SKILL.md +2 -0
  159. package/src/assets/skills/docs-framework/SKILL.md +5 -3
  160. package/src/assets/skills/git/SKILL.md +8 -78
  161. package/src/assets/skills/git/references/github-api.md +179 -141
  162. package/src/assets/skills/git/references/patterns.md +11 -6
  163. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  164. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  165. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  166. package/src/assets/agents/git.md +0 -938
@@ -0,0 +1,1065 @@
1
+ #!/usr/bin/env node
2
+ // src/assets/scripts/resolve-evidence-policy.cjs
3
+ //
4
+ // Resolves EVIDENCE_POLICY for a repository and prints it on ONE line together
5
+ // with the three mechanism inputs that operations receive. Installed as a
6
+ // top-level sibling of hud.sh and redact-secrets.cjs under ~/.devflow/scripts/.
7
+ //
8
+ // Usage: node resolve-evidence-policy.cjs [<dir>] (<dir> defaults to cwd)
9
+ //
10
+ // The policy is plumbing, decided once by the caller: this script prints it plus
11
+ // the mechanism inputs, and operations only ever see the inputs. It WRITES NOTHING
12
+ // — no file, no git ref, no remote state — so `.devflow/policy.json` stays a
13
+ // team-owned file that only the team commits.
14
+ //
15
+ // stdout is exactly one line plus "\n", or empty (D-POLICY-LINE):
16
+ // EVIDENCE_POLICY=<required|standard> SOURCE=<file|worktree|default|invalid|error>
17
+ // REF=<branch|none>[ WARN=<w>[,<w>…]] ISSUE_REQUIRED=<bool> APPLY_CONVENTIONS=<bool>
18
+ // REQUIRE_NON_AUTHOR_APPROVAL=<bool>
19
+ // (one line on stdout; wrapped here for reading). Every value is a token from a
20
+ // closed vocabulary or a SAFE_REF_RE-checked branch name, so no byte of a policy
21
+ // file, a gh answer or a git answer can reach stdout.
22
+ //
23
+ // Exit codes (a caller treats EVERY non-zero code as `required`):
24
+ // 0 resolved — the line above
25
+ // 1 usage error — stdout entirely empty, usage on stderr
26
+ // 2 input unusable — <dir> missing or not a directory; prints FAIL_CLOSED_LINE
27
+ // 3 never emitted — this script writes no file, so the write-failure code of
28
+ // redact-secrets.cjs has no arm here and is absent from EXIT_CODES
29
+ // 4 internal error — or git could not say whether <dir> is in a repository
30
+ // (missing, timed out, killed); prints FAIL_CLOSED_LINE
31
+ // 5 output gate refused — the composed line failed the grammar or was not
32
+ // consistent with the resolved policy; prints FAIL_CLOSED_LINE. Final: a
33
+ // re-run resolves the same inputs to the same refusal
34
+ //
35
+ // Design constraints (binding):
36
+ // - main() returns {code, line} and never calls process.exit; the single
37
+ // `require.main === module` boundary is the only stdout write and the only
38
+ // exitCode assignment, so nothing is truncated and no cleanup is skipped
39
+ // - every subprocess is spawned with an argv array (never a shell), stdin
40
+ // ignored, a timeout and a maxBuffer; every loop has a fixed bound
41
+ // - a policy file that is not a regular file is never opened, and one over
42
+ // MAX_POLICY_BYTES is never read
43
+ // - no index-refreshing git command runs (no `status`, no `diff`), so a
44
+ // repository's configured fsmonitor hook never fires; no fetch, no set-head
45
+
46
+ 'use strict';
47
+
48
+ const fs = require('fs');
49
+ const path = require('path');
50
+ const os = require('os');
51
+ const childProcess = require('child_process');
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Closed vocabularies
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /** @typedef {'required' | 'standard'} Policy */
58
+ /** @typedef {'file' | 'worktree' | 'default' | 'invalid' | 'error'} Source */
59
+ /** @typedef {'remote-unavailable' | 'invalid-file' | 'raised-by-compliance' | 'pr-changes-policy'} Warning */
60
+
61
+ /** Every policy value, strictest first. */
62
+ const POLICIES = Object.freeze(/** @type {Policy[]} */ (['required', 'standard']));
63
+
64
+ /** Every SOURCE value — the governing input, or `error` for a fail-closed exit. */
65
+ const SOURCES = Object.freeze(/** @type {Source[]} */ (['file', 'worktree', 'default', 'invalid', 'error']));
66
+
67
+ /**
68
+ * Every WARN token, in the order the line prints them.
69
+ * remote-unavailable the default branch's file could not be consulted
70
+ * invalid-file a folded policy file (remote, worktree or tracking copy) was invalid
71
+ * raised-by-compliance compliance raised a `standard` file or worktree policy
72
+ * pr-changes-policy HEAD or the worktree differs from the default branch (advisory)
73
+ */
74
+ const WARNINGS = Object.freeze(/** @type {Warning[]} */ ([
75
+ 'remote-unavailable',
76
+ 'invalid-file',
77
+ 'raised-by-compliance',
78
+ 'pr-changes-policy',
79
+ ]));
80
+
81
+ /**
82
+ * Exit codes by meaning. 3 is deliberately absent: no arm of this script can
83
+ * produce it, and a value in a closed vocabulary that no arm reaches reads as a
84
+ * live outcome to anyone auditing the set.
85
+ */
86
+ const EXIT_CODES = Object.freeze({
87
+ RESOLVED: 0,
88
+ USAGE: 1,
89
+ INPUT_UNUSABLE: 2,
90
+ INTERNAL_ERROR: 4,
91
+ OUTPUT_GATE_REFUSED: 5,
92
+ });
93
+
94
+ /**
95
+ * @typedef {{ ISSUE_REQUIRED: boolean, APPLY_CONVENTIONS: boolean, REQUIRE_NON_AUTHOR_APPROVAL: boolean }} MechanismInputs
96
+ */
97
+
98
+ /**
99
+ * D-POLICY-PLUMBING: the mechanism inputs are a pure function of EVIDENCE_POLICY,
100
+ * and this table is their single authority. Operations receive these three
101
+ * booleans and never the policy itself; a prompt that restated the mapping would
102
+ * be a second authority free to drift from this one.
103
+ * ISSUE_REQUIRED a tracked issue is mandatory for the task
104
+ * APPLY_CONVENTIONS learned naming conventions are applied (branch, PR title)
105
+ * REQUIRE_NON_AUTHOR_APPROVAL merge-readiness demands a non-author approval
106
+ * Per-PR content (a PR's test-plan block, its exceptions) is rendered by callers
107
+ * and is not a function of the policy, so it is not emitted here.
108
+ *
109
+ * @type {Readonly<Record<Policy, Readonly<MechanismInputs>>>}
110
+ */
111
+ const MECHANISM_INPUTS = Object.freeze({
112
+ required: Object.freeze({ ISSUE_REQUIRED: true, APPLY_CONVENTIONS: true, REQUIRE_NON_AUTHOR_APPROVAL: true }),
113
+ standard: Object.freeze({ ISSUE_REQUIRED: false, APPLY_CONVENTIONS: false, REQUIRE_NON_AUTHOR_APPROVAL: false }),
114
+ });
115
+
116
+ /** The mechanism-input keys, in line order. */
117
+ const INPUT_KEYS = Object.freeze(['ISSUE_REQUIRED', 'APPLY_CONVENTIONS', 'REQUIRE_NON_AUTHOR_APPROVAL']);
118
+
119
+ /** Largest policy file read, in bytes. A policy is two keys; nothing valid approaches this. */
120
+ const MAX_POLICY_BYTES = 4096;
121
+
122
+ /** Largest manifest read, in bytes. */
123
+ const MAX_MANIFEST_BYTES = 1048576;
124
+
125
+ /**
126
+ * A branch name this script will put into argv or onto stdout: an alphanumeric
127
+ * first character (so never an option and never a hidden path), then at most 254
128
+ * of `[A-Za-z0-9._/-]`, and never `..`. The lookahead is bounded by the same
129
+ * class, so the scan is linear (no unbounded `.*`).
130
+ */
131
+ const SAFE_REF_RE = /^(?![A-Za-z0-9._/-]{0,254}\.\.)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
132
+
133
+ /**
134
+ * D-POLICY-LINE: the exported output grammar — anchored, closed alternations
135
+ * only, named groups for consumers. Consumers' parses are pinned to this
136
+ * expression, so it is the one definition of a well-formed line. REF admits
137
+ * `none` or a SAFE_REF_RE branch (a branch literally named `none` is ambiguous;
138
+ * REF is display-only and no consumer branches on it).
139
+ */
140
+ const OUTPUT_LINE_RE = /^EVIDENCE_POLICY=(?<policy>required|standard) SOURCE=(?<source>file|worktree|default|invalid|error) REF=(?<ref>none|(?![A-Za-z0-9._/-]{0,254}\.\.)[A-Za-z0-9][A-Za-z0-9._/-]{0,254})(?: WARN=(?<warn>(?:remote-unavailable|invalid-file|raised-by-compliance|pr-changes-policy)(?:,(?:remote-unavailable|invalid-file|raised-by-compliance|pr-changes-policy)){0,3}))? ISSUE_REQUIRED=(?<issue>true|false) APPLY_CONVENTIONS=(?<conventions>true|false) REQUIRE_NON_AUTHOR_APPROVAL=(?<approval>true|false)$/;
141
+
142
+ /**
143
+ * The line every refusal prints (exits 2, 4 and 5). A constant, so the boundary
144
+ * can never fail to compose it, and `required` in every field a consumer reads.
145
+ */
146
+ const FAIL_CLOSED_LINE =
147
+ 'EVIDENCE_POLICY=required SOURCE=error REF=none ISSUE_REQUIRED=true APPLY_CONVENTIONS=true REQUIRE_NON_AUTHOR_APPROVAL=true';
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Subprocess bounds (every spawn carries a timeout and a maxBuffer)
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const GH_TIMEOUT_MS = 10000;
154
+ const GIT_REMOTE_TIMEOUT_MS = 10000;
155
+ const GIT_LOCAL_TIMEOUT_MS = 5000;
156
+ /** gh answers and blob reads: generous for a 4 KiB policy, and an overflow is ENOBUFS ⇒ invalid. */
157
+ const BLOB_MAX_BUFFER = 65536;
158
+ /** rev-parse and ls-remote print one short line each. */
159
+ const LINE_MAX_BUFFER = 4096;
160
+
161
+ /** The repository-relative path of the policy file, in git and API spelling. */
162
+ const POLICY_REL = '.devflow/policy.json';
163
+
164
+ /** `git ls-remote --symref origin HEAD`, first line. Bounded class, no backtracking. */
165
+ const LS_REMOTE_SYMREF_RE = /^ref: refs\/heads\/([^\t\n]{1,255})\tHEAD$/;
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Types shared by the helpers
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * @typedef {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'valid', policy: Policy }} ParsedPolicy
173
+ *
174
+ * @typedef {{ policy: Policy, source: Source, ref: string, warnings: Warning[], inputs: MechanismInputs }} Resolution
175
+ * `ref` is the default branch as resolved, else `none`. Frozen, arrays included.
176
+ *
177
+ * @typedef {{ status: number | null, stdout?: Buffer | string, stderr?: Buffer | string, error?: { code?: string } }} ExecResult
178
+ * The spawnSync subset this script reads.
179
+ *
180
+ * @typedef {(file: string, args: string[], opts: object) => ExecResult} ExecFn
181
+ * Called exactly like child_process.spawnSync(file, args, opts).
182
+ *
183
+ * @typedef {{ dir: string, compliance?: unknown }} ResolveOptions
184
+ * `compliance` is the caller's already-read `manifest.features.compliance` value
185
+ * (raw or normalized — complianceDefault accepts either). Omitted (undefined), the
186
+ * script reads the manifest itself; pass `null` for "no compliance state".
187
+ *
188
+ * @typedef {{ exec?: ExecFn }} ResolveDeps
189
+ * `exec` is the only injected I/O; it defaults to child_process.spawnSync.
190
+ *
191
+ * @typedef {{ kind: 'resolve', dir: string } | { kind: 'usage', usage: string }} ParsedArgs
192
+ *
193
+ * @typedef {{ exec?: ExecFn, formatLine?: (r: Resolution) => unknown }} MainDeps
194
+ * Injected by tests only, to reach the output gate.
195
+ *
196
+ * @typedef {{ code: number, line: string }} MainOutcome
197
+ * `line` is '' only for code 1, FAIL_CLOSED_LINE for codes 2, 4 and 5.
198
+ */
199
+
200
+ /** @type {ParsedPolicy} */
201
+ const ABSENT = Object.freeze({ kind: 'absent' });
202
+ /** @type {ParsedPolicy} */
203
+ const INVALID = Object.freeze({ kind: 'invalid' });
204
+
205
+ /** @type {Resolution} */
206
+ const ERROR_RESOLUTION = Object.freeze({
207
+ policy: 'required',
208
+ source: 'error',
209
+ ref: 'none',
210
+ warnings: Object.freeze([]),
211
+ inputs: MECHANISM_INPUTS.required,
212
+ });
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // parseArgs
216
+ // ---------------------------------------------------------------------------
217
+
218
+ /**
219
+ * Parse argv into a directory or a usage error. `kind` is the discriminant, and
220
+ * main() dispatches on it alone. Any `-`-prefixed argument (including `-` and
221
+ * `--`) or a second positional is a usage error, never an ignored argument.
222
+ *
223
+ * @param {readonly string[]} argv process.argv
224
+ * @returns {ParsedArgs}
225
+ */
226
+ function parseArgs(argv) {
227
+ const USAGE = 'Usage: node resolve-evidence-policy.cjs [<dir>]';
228
+ const rest = Array.isArray(argv) ? argv.slice(2) : [];
229
+ /** @type {string[]} */
230
+ const positionals = [];
231
+ for (const arg of rest) {
232
+ if (typeof arg !== 'string' || arg.startsWith('-')) {
233
+ return {
234
+ kind: 'usage',
235
+ usage: 'resolve-evidence-policy: unrecognised argument ' + JSON.stringify(String(arg)).slice(0, 80) + '\n' + USAGE,
236
+ };
237
+ }
238
+ positionals.push(arg);
239
+ }
240
+ if (positionals.length > 1) return { kind: 'usage', usage: USAGE };
241
+ return { kind: 'resolve', dir: positionals.length === 1 ? positionals[0] : process.cwd() };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // parsePolicyBytes (D-POLICY-STRICT-SCHEMA)
246
+ // ---------------------------------------------------------------------------
247
+
248
+ /**
249
+ * D-POLICY-STRICT-SCHEMA: the grammar gate a policy file must pass BEFORE
250
+ * JSON.parse sees it. Exactly two members, each key `version` or
251
+ * `evidencePolicy`, each value `1`, `"required"` or `"standard"`, and JSON
252
+ * whitespace only — `[ \t\r\n]`, never `\s`, which admits U+FEFF and U+00A0.
253
+ * Each `*` run is separated by a literal, so matching is linear. What it keeps
254
+ * out never reaches the parser: `__proto__`/`constructor` and every other key,
255
+ * `\u`-escaped keys or values, `1.0`, `"1"`, arrays, `null`, trailing bytes.
256
+ */
257
+ const POLICY_GRAMMAR_RE =
258
+ /^[ \t\r\n]*\{[ \t\r\n]*"(?:version|evidencePolicy)"[ \t\r\n]*:[ \t\r\n]*(?:1|"required"|"standard")[ \t\r\n]*,[ \t\r\n]*"(?:version|evidencePolicy)"[ \t\r\n]*:[ \t\r\n]*(?:1|"required"|"standard")[ \t\r\n]*\}[ \t\r\n]*$/;
259
+
260
+ /**
261
+ * Classify policy-file bytes. `null`/`undefined` is "no file" (absent); everything
262
+ * else is valid or invalid, never absent — an empty committed file is invalid.
263
+ *
264
+ * The byte checks run before decoding (size, then a UTF-8 BOM, which is invalid
265
+ * rather than skipped), decoding is fatal on a malformed sequence, and after the
266
+ * grammar gate the parsed object must hold exactly the own keys
267
+ * {version, evidencePolicy} with version === 1. A duplicate key passes the
268
+ * grammar but collapses in the parse, so the key-set check refuses it.
269
+ *
270
+ * @param {Uint8Array | null | undefined} buf
271
+ * @returns {ParsedPolicy}
272
+ */
273
+ function parsePolicyBytes(buf) {
274
+ if (buf === null || buf === undefined) return ABSENT;
275
+ if (!(buf instanceof Uint8Array)) return INVALID;
276
+ if (buf.length > MAX_POLICY_BYTES) return INVALID;
277
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) return INVALID;
278
+
279
+ let text;
280
+ try {
281
+ // TextDecoder would silently strip a leading BOM; the byte check above is
282
+ // what makes one invalid.
283
+ text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
284
+ } catch (_) {
285
+ return INVALID;
286
+ }
287
+ if (!POLICY_GRAMMAR_RE.test(text)) return INVALID;
288
+
289
+ let parsed;
290
+ try {
291
+ parsed = JSON.parse(text);
292
+ } catch (_) {
293
+ return INVALID;
294
+ }
295
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return INVALID;
296
+ const keys = Object.keys(parsed);
297
+ if (keys.length !== 2 || !keys.includes('version') || !keys.includes('evidencePolicy')) return INVALID;
298
+ if (parsed.version !== 1) return INVALID;
299
+ if (!POLICIES.includes(parsed.evidencePolicy)) return INVALID;
300
+ return Object.freeze({ kind: 'valid', policy: parsed.evidencePolicy });
301
+ }
302
+
303
+ /**
304
+ * The canonical bytes of a policy file — what the CLI suggests a team commit.
305
+ *
306
+ * @param {unknown} policy
307
+ * @returns {string | null} `{"version":1,"evidencePolicy":"<p>"}\n`, or null outside POLICIES
308
+ */
309
+ function serializePolicy(policy) {
310
+ if (!POLICIES.includes(/** @type {Policy} */ (policy))) return null;
311
+ return JSON.stringify({ version: 1, evidencePolicy: policy }) + '\n';
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Compliance default
316
+ // ---------------------------------------------------------------------------
317
+
318
+ /**
319
+ * The compliance default C. Mirrors the CLI's `normalizeComplianceFeature`
320
+ * (src/core/compliance.ts) exactly — absent or malformed is disabled — behind a
321
+ * parity test. Enabled is `required` WHATEVER the framework count: an install
322
+ * with zero frameworks ("generic controls only") still installs the compliance
323
+ * skill and is gated today, and no compliance-on user may lose that. Which
324
+ * frameworks are listed therefore never matters, only that the value is
325
+ * well-formed.
326
+ *
327
+ * @param {unknown} rawFeatureValue `manifest.features.compliance`, raw or normalized
328
+ * @returns {Policy}
329
+ */
330
+ function complianceDefault(rawFeatureValue) {
331
+ const raw = /** @type {any} */ (rawFeatureValue);
332
+ if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return 'standard';
333
+ if (typeof raw.enabled !== 'boolean') return 'standard';
334
+ if (!Array.isArray(raw.frameworks)) return 'standard';
335
+ if (!raw.frameworks.every((/** @type {unknown} */ f) => typeof f === 'string')) return 'standard';
336
+ return raw.enabled ? 'required' : 'standard';
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Bounded file reads
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * Read a regular file of at most `maxBytes`, or say why not.
345
+ *
346
+ * The stat runs first and decides without opening: a non-regular file (with
347
+ * `followSymlinks` false this includes a symlink; always a directory, FIFO,
348
+ * socket or device) is refused unopened, and an oversize one unread. The open
349
+ * then adds O_NONBLOCK (a FIFO swapped in after the stat cannot block) and, when
350
+ * not following, O_NOFOLLOW (a symlink swapped in fails the open); the fstat
351
+ * re-checks the opened object. The read loop is bounded by the byte count.
352
+ *
353
+ * @param {string} filePath
354
+ * @param {number} maxBytes
355
+ * @param {boolean} followSymlinks
356
+ * @returns {{ kind: 'absent' } | { kind: 'refused' } | { kind: 'ok', bytes: Buffer }}
357
+ */
358
+ function readBoundedRegularFile(filePath, maxBytes, followSymlinks) {
359
+ let st;
360
+ try {
361
+ st = followSymlinks ? fs.statSync(filePath) : fs.lstatSync(filePath);
362
+ } catch (/** @type {any} */ err) {
363
+ return err && (err.code === 'ENOENT' || err.code === 'ENOTDIR') ? { kind: 'absent' } : { kind: 'refused' };
364
+ }
365
+ if (!st.isFile() || st.size > maxBytes) return { kind: 'refused' };
366
+
367
+ const flags = fs.constants.O_RDONLY
368
+ | (fs.constants.O_NONBLOCK || 0)
369
+ | (followSymlinks ? 0 : (fs.constants.O_NOFOLLOW || 0));
370
+ let fd;
371
+ try {
372
+ fd = fs.openSync(filePath, flags);
373
+ } catch (_) {
374
+ return { kind: 'refused' };
375
+ }
376
+ try {
377
+ const fst = fs.fstatSync(fd);
378
+ if (!fst.isFile() || fst.size > maxBytes) return { kind: 'refused' };
379
+ // One byte past the stat'd size: a file that grew between the stat and the
380
+ // read is refused rather than truncated into something that parses.
381
+ const buf = Buffer.alloc(fst.size + 1);
382
+ let total = 0;
383
+ for (let i = 0; i < buf.length; i++) {
384
+ const n = fs.readSync(fd, buf, total, buf.length - total, null);
385
+ if (n === 0) break;
386
+ total += n;
387
+ if (total === buf.length) break;
388
+ }
389
+ if (total > fst.size) return { kind: 'refused' };
390
+ return { kind: 'ok', bytes: buf.subarray(0, total) };
391
+ } catch (_) {
392
+ return { kind: 'refused' };
393
+ } finally {
394
+ try { fs.closeSync(fd); } catch (_) { /* the read already decided; a close error changes nothing */ }
395
+ }
396
+ }
397
+
398
+ /**
399
+ * W — the working tree's policy file. lstat-refused, never followed: a symlink,
400
+ * directory, FIFO or device is invalid unopened, and an oversize file invalid
401
+ * unread.
402
+ *
403
+ * @param {string} root
404
+ * @returns {ParsedPolicy}
405
+ */
406
+ function readWorktreePolicy(root) {
407
+ const read = readBoundedRegularFile(path.join(root, '.devflow', 'policy.json'), MAX_POLICY_BYTES, false);
408
+ if (read.kind === 'absent') return ABSENT;
409
+ if (read.kind === 'refused') return INVALID;
410
+ return parsePolicyBytes(read.bytes);
411
+ }
412
+
413
+ /**
414
+ * The devflow directory: DEVFLOW_DIR when it is absolute, else ~/.devflow, else
415
+ * null (a home directory that is not absolute would resolve against cwd).
416
+ *
417
+ * @returns {string | null}
418
+ */
419
+ function devflowDir() {
420
+ const fromEnv = process.env.DEVFLOW_DIR;
421
+ if (typeof fromEnv === 'string' && fromEnv !== '' && path.isAbsolute(fromEnv)) return fromEnv;
422
+ let home;
423
+ try {
424
+ home = os.homedir();
425
+ } catch (_) {
426
+ // No HOME and no passwd entry: there is no manifest to read, which is not an error.
427
+ return null;
428
+ }
429
+ return typeof home === 'string' && path.isAbsolute(home) ? path.join(home, '.devflow') : null;
430
+ }
431
+
432
+ /**
433
+ * The raw `features.compliance` value from the manifest, or undefined. Read
434
+ * directly — never through the CLI's manifest reader, which heal-writes — as a
435
+ * regular file of at most 1 MiB. Anything unreadable or malformed is "no state",
436
+ * which complianceDefault maps to disabled; since C only ever raises, a missing C
437
+ * can never lower the result below the governing file.
438
+ *
439
+ * @returns {unknown}
440
+ */
441
+ function readManifestCompliance() {
442
+ const dir = devflowDir();
443
+ if (dir === null) return undefined;
444
+ const read = readBoundedRegularFile(path.join(dir, 'manifest.json'), MAX_MANIFEST_BYTES, true);
445
+ if (read.kind !== 'ok') return undefined;
446
+ let parsed;
447
+ try {
448
+ parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(read.bytes));
449
+ } catch (_) {
450
+ return undefined;
451
+ }
452
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
453
+ const features = parsed.features;
454
+ if (features === null || typeof features !== 'object' || Array.isArray(features)) return undefined;
455
+ return Object.prototype.hasOwnProperty.call(features, 'compliance') ? features.compliance : undefined;
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // Subprocess calls (D-POLICY-PROBE)
460
+ // ---------------------------------------------------------------------------
461
+
462
+ /**
463
+ * @typedef {{ ok: boolean, status: number | null, errorCode: string | null, stdout: Buffer, stderr: string }} CallResult
464
+ * @typedef {{ exec: ExecFn, env: NodeJS.ProcessEnv }} CallContext
465
+ */
466
+
467
+ /**
468
+ * Whether a call ran to completion and exited on its own — as opposed to never
469
+ * starting (ENOENT), timing out, overflowing its buffer or being killed. Only an
470
+ * answered non-zero exit is a real "no" ("not a repository", "no such path",
471
+ * "no such ref"); an unanswered call is NOT KNOWING, and a local-git step that
472
+ * does not know must not read as the permissive answer (avoids PF-075).
473
+ *
474
+ * @param {CallResult} r
475
+ * @returns {boolean}
476
+ */
477
+ function answered(r) {
478
+ return r.errorCode === null && r.status !== null;
479
+ }
480
+
481
+ /** @param {unknown} value @returns {Buffer} */
482
+ function asBuffer(value) {
483
+ if (Buffer.isBuffer(value)) return value;
484
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
485
+ return Buffer.alloc(0);
486
+ }
487
+
488
+ /**
489
+ * One bounded subprocess call, normalized. `ok` is exit 0 with no spawn error.
490
+ * The argv array is a fresh copy, so an exec cannot mutate a caller's constant.
491
+ *
492
+ * @param {CallContext} ctx
493
+ * @param {string} file
494
+ * @param {readonly string[]} args
495
+ * @param {string} cwd
496
+ * @param {number} timeout
497
+ * @param {number} maxBuffer
498
+ * @returns {CallResult}
499
+ */
500
+ function runCall(ctx, file, args, cwd, timeout, maxBuffer) {
501
+ const res = ctx.exec(file, [...args], {
502
+ cwd,
503
+ env: ctx.env,
504
+ stdio: ['ignore', 'pipe', 'pipe'],
505
+ timeout,
506
+ maxBuffer,
507
+ windowsHide: true,
508
+ shell: false,
509
+ });
510
+ const errorCode = res && res.error ? String(res.error.code || 'EUNKNOWN') : null;
511
+ const status = res && typeof res.status === 'number' ? res.status : null;
512
+ return {
513
+ ok: errorCode === null && status === 0,
514
+ status,
515
+ errorCode,
516
+ stdout: asBuffer(res && res.stdout),
517
+ stderr: asBuffer(res && res.stderr).toString('utf8'),
518
+ };
519
+ }
520
+
521
+ /**
522
+ * @typedef {{ kind: 'root', root: string } | { kind: 'none' } | { kind: 'unknown' }} Toplevel
523
+ */
524
+
525
+ /**
526
+ * Step 1: the repository root.
527
+ * root exit 0 with exactly one absolute path plus its newline
528
+ * none git ANSWERED non-zero — not a repository (or not a work tree); the
529
+ * resolver then treats the remote as unavailable and the worktree file
530
+ * as absent
531
+ * unknown git did not answer (missing, timed out, overflowed, killed), or
532
+ * answered exit 0 — a repository — with an unusable path. Either way a
533
+ * repository's files may exist and cannot be read, so resolve() fails
534
+ * closed rather than resolving from compliance alone
535
+ *
536
+ * @param {CallContext} ctx
537
+ * @param {string} dir
538
+ * @returns {Toplevel}
539
+ */
540
+ function gitToplevel(ctx, dir) {
541
+ const r = runCall(ctx, 'git', ['rev-parse', '--show-toplevel'], dir, GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
542
+ if (!answered(r)) return { kind: 'unknown' };
543
+ if (r.status !== 0) return { kind: 'none' };
544
+ const text = r.stdout.toString('utf8').replace(/\r?\n$/, '');
545
+ if (text === '' || /[\r\n\0]/.test(text) || !path.isAbsolute(text)) return { kind: 'unknown' };
546
+ return { kind: 'root', root: text };
547
+ }
548
+
549
+ /**
550
+ * Step 2 — D-POLICY-PROBE: ONE gh call is both the reachability probe and the
551
+ * default-branch lookup. `{owner}/{repo}` are gh's own placeholders, filled from
552
+ * the repository's remotes (host inference is gh's). Reachable iff exit 0 with a
553
+ * SAFE_REF_RE value that is not jq's `null`; anything else — no gh, no auth, no
554
+ * GitHub remote, a hostile answer — is unavailable.
555
+ *
556
+ * @param {CallContext} ctx
557
+ * @param {string} root
558
+ * @returns {string | null} the default branch D, or null (unavailable)
559
+ */
560
+ function probeDefaultBranch(ctx, root) {
561
+ const r = runCall(ctx, 'gh', ['api', 'repos/{owner}/{repo}', '--jq', '.default_branch'], root, GH_TIMEOUT_MS, BLOB_MAX_BUFFER);
562
+ if (!r.ok) return null;
563
+ const value = r.stdout.toString('utf8').replace(/\n$/, '');
564
+ return value !== 'null' && SAFE_REF_RE.test(value) ? value : null;
565
+ }
566
+
567
+ /**
568
+ * Step 3: R, the default branch's policy file — or null when the remote turned
569
+ * out to be unavailable after all.
570
+ *
571
+ * `--method GET` is mandatory: gh sends POST whenever a field is added. stdout is
572
+ * file content ONLY on exit 0 — a 404 prints gh's JSON error body on stdout, so
573
+ * it is classified from stderr and the exit code and its stdout is never parsed.
574
+ * ENOBUFS ⇒ invalid (never unavailable, or a huge
575
+ * file would hand control to the worktree)
576
+ * exit 0 ⇒ parse the bytes
577
+ * exit ≠ 0 and stderr has `(HTTP 404)` ⇒ absent — the probe already succeeded,
578
+ * so 404 means "no file", not "no access"
579
+ * anything else (403/429, 5xx, timeout, spawn error) ⇒ unavailable
580
+ *
581
+ * @param {CallContext} ctx
582
+ * @param {string} root
583
+ * @param {string} ref
584
+ * @returns {ParsedPolicy | null}
585
+ */
586
+ function fetchRemotePolicy(ctx, root, ref) {
587
+ const r = runCall(ctx, 'gh', [
588
+ 'api', '--method', 'GET', 'repos/{owner}/{repo}/contents/' + POLICY_REL,
589
+ '-f', 'ref=' + ref, '-H', 'Accept: application/vnd.github.raw+json',
590
+ ], root, GH_TIMEOUT_MS, BLOB_MAX_BUFFER);
591
+ if (r.errorCode === 'ENOBUFS') return INVALID;
592
+ if (r.ok) return parsePolicyBytes(r.stdout);
593
+ if (r.errorCode === null && r.status !== null && r.status !== 0 && r.stderr.includes('(HTTP 404)')) return ABSENT;
594
+ return null;
595
+ }
596
+
597
+ /**
598
+ * Step 5 (offline, D unknown): the default branch from `ls-remote --symref`.
599
+ * Only line 1 is read, and its capture must still pass SAFE_REF_RE.
600
+ *
601
+ * @param {CallContext} ctx
602
+ * @param {string} root
603
+ * @returns {string | null}
604
+ */
605
+ function lsRemoteDefaultBranch(ctx, root) {
606
+ const r = runCall(ctx, 'git', ['ls-remote', '--symref', 'origin', 'HEAD'], root, GIT_REMOTE_TIMEOUT_MS, LINE_MAX_BUFFER);
607
+ if (!r.ok) return null;
608
+ const text = r.stdout.toString('utf8');
609
+ const newline = text.indexOf('\n');
610
+ const m = LS_REMOTE_SYMREF_RE.exec(newline === -1 ? text : text.slice(0, newline));
611
+ return m !== null && SAFE_REF_RE.test(m[1]) ? m[1] : null;
612
+ }
613
+
614
+ /**
615
+ * Steps 4 and 7: a policy blob at a revision (HEAD, or the tracking ref).
616
+ * Exit 0 ⇒ parse; an answered non-zero exit (the path does not exist there, an
617
+ * unborn HEAD) ⇒ absent; an unanswered call (ENOBUFS included) ⇒ invalid — never
618
+ * absent, or a git that timed out would silently drop the default branch's copy
619
+ * from the fold.
620
+ *
621
+ * @param {CallContext} ctx
622
+ * @param {string} root
623
+ * @param {string} revision
624
+ * @returns {ParsedPolicy}
625
+ */
626
+ function catFilePolicy(ctx, root, revision) {
627
+ const r = runCall(ctx, 'git', ['cat-file', 'blob', revision + ':' + POLICY_REL], root,
628
+ GIT_LOCAL_TIMEOUT_MS, BLOB_MAX_BUFFER);
629
+ if (r.ok) return parsePolicyBytes(r.stdout);
630
+ return answered(r) ? ABSENT : INVALID;
631
+ }
632
+
633
+ /**
634
+ * Steps 6 and 7 (offline, D known): T, the local tracking copy of the default
635
+ * branch's file. The ref check decides whether T can stand for the default branch
636
+ * at all — a ref git ANSWERS is missing is "B unknown" (null), which a failed blob
637
+ * read could not tell apart from "file absent". A ref check git does not answer is
638
+ * not knowing, so T is invalid (it raises) rather than unknown (it would not).
639
+ *
640
+ * @param {CallContext} ctx
641
+ * @param {string} root
642
+ * @param {string} ref
643
+ * @returns {ParsedPolicy | null}
644
+ */
645
+ function trackingPolicy(ctx, root, ref) {
646
+ const trackingRef = 'refs/remotes/origin/' + ref;
647
+ const r = runCall(ctx, 'git', ['rev-parse', '--verify', '--quiet', trackingRef], root,
648
+ GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
649
+ if (r.ok) return catFilePolicy(ctx, root, trackingRef);
650
+ return answered(r) ? null : INVALID;
651
+ }
652
+
653
+ // ---------------------------------------------------------------------------
654
+ // Gathering the facts (the imperative shell)
655
+ // ---------------------------------------------------------------------------
656
+
657
+ /**
658
+ * @typedef {{
659
+ * reachable: boolean,
660
+ * ref: string | null,
661
+ * remote: ParsedPolicy | null,
662
+ * worktree: ParsedPolicy,
663
+ * tracking: ParsedPolicy | null,
664
+ * head: ParsedPolicy | null,
665
+ * compliance: Policy,
666
+ * }} Facts
667
+ * remote R — set iff reachable
668
+ * tracking T — set iff offline and refs/remotes/origin/<D> exists (or git could
669
+ * not answer whether it does — then invalid)
670
+ * head H — set iff B (R online, T offline) is known
671
+ */
672
+
673
+ /**
674
+ * Run the fixed call sequence. The only calls ever made, in order:
675
+ * git rev-parse --show-toplevel (cwd = <dir>)
676
+ * gh api repos/{owner}/{repo} --jq .default_branch
677
+ * gh api --method GET …/contents/.devflow/policy.json (only if reachable)
678
+ * git ls-remote --symref origin HEAD (offline, D unknown)
679
+ * git rev-parse --verify --quiet refs/remotes/origin/D (offline, D known)
680
+ * git cat-file blob refs/remotes/origin/D:… (that ref exists)
681
+ * git cat-file blob HEAD:… (B known)
682
+ *
683
+ * Returns null when git cannot say whether <dir> is in a repository at all (see
684
+ * gitToplevel): nothing below can be trusted then, and resolve() fails closed.
685
+ *
686
+ * @param {string} dir
687
+ * @param {Policy} compliance
688
+ * @param {ExecFn} exec
689
+ * @returns {Facts | null}
690
+ */
691
+ function gatherFacts(dir, compliance, exec) {
692
+ /** @type {CallContext} */
693
+ const ctx = {
694
+ exec,
695
+ env: Object.assign({}, process.env, { GH_PROMPT_DISABLED: '1', GIT_TERMINAL_PROMPT: '0' }),
696
+ };
697
+
698
+ const toplevel = gitToplevel(ctx, dir);
699
+ if (toplevel.kind === 'unknown') return null;
700
+ if (toplevel.kind === 'none') {
701
+ return { reachable: false, ref: null, remote: null, worktree: ABSENT, tracking: null, head: null, compliance };
702
+ }
703
+ const root = toplevel.root;
704
+
705
+ const worktree = readWorktreePolicy(root);
706
+ let ref = probeDefaultBranch(ctx, root);
707
+ const remote = ref === null ? null : fetchRemotePolicy(ctx, root, ref);
708
+ const reachable = remote !== null;
709
+
710
+ let tracking = null;
711
+ if (!reachable) {
712
+ if (ref === null) ref = lsRemoteDefaultBranch(ctx, root);
713
+ if (ref !== null) tracking = trackingPolicy(ctx, root, ref);
714
+ }
715
+
716
+ const baseKnown = reachable || tracking !== null;
717
+ const head = baseKnown ? catFilePolicy(ctx, root, 'HEAD') : null;
718
+ return { reachable, ref, remote, worktree, tracking, head, compliance };
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // The fold (the functional core)
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /**
726
+ * @param {Policy} a
727
+ * @param {Policy} b
728
+ * @returns {Policy}
729
+ */
730
+ function stricter(a, b) {
731
+ return a === 'required' || b === 'required' ? 'required' : 'standard';
732
+ }
733
+
734
+ /**
735
+ * The comparable state of a parsed file: absent, invalid, or its policy.
736
+ *
737
+ * @param {ParsedPolicy} p
738
+ * @returns {string}
739
+ */
740
+ function stateOf(p) {
741
+ return p.kind === 'valid' ? p.policy : p.kind;
742
+ }
743
+
744
+ /**
745
+ * D-POLICY-FOLD: the stricter value always wins, and local sources only raise.
746
+ *
747
+ * The governing file decides SOURCE and the base — R when the remote is
748
+ * reachable, W when it is not:
749
+ * valid v ⇒ file|worktree, base v invalid ⇒ invalid, base required
750
+ * absent ⇒ default, base C
751
+ * The classifier is total by construction: the terminal arm is the conservative
752
+ * one (invalid, required), so an unforeseen state can only tighten.
753
+ *
754
+ * Then, raise-only: online W folds in (a valid v contributes v, invalid
755
+ * contributes required); offline T folds in when pr-changes-policy fires; C
756
+ * always folds in. `raised-by-compliance` fires only for SOURCE file|worktree —
757
+ * under `default`, C IS the default rather than a raise.
758
+ *
759
+ * D-POLICY-CHANGE-DETECT: `pr-changes-policy` fires iff B (the default branch's
760
+ * state — R online, T offline) is known and state(H) ≠ state(B) or state(W) ≠
761
+ * state(B). The comparison is SEMANTIC (absent/invalid/required/standard), so a
762
+ * CRLF checkout or reformatted JSON does not fire it. It means "the branch or
763
+ * working tree differs from the default branch": a branch cut before a later
764
+ * default-branch edit fires it too, and rebasing clears it — consumers must
765
+ * treat it as advisory. It never lowers: online B is already the base and W can
766
+ * only raise; offline T is folded in, so a branch that edits or deletes the file
767
+ * cannot drop below the default-branch copy.
768
+ *
769
+ * @param {Facts} facts
770
+ * @returns {Resolution}
771
+ */
772
+ function foldPolicy(facts) {
773
+ /** @type {Set<Warning>} */
774
+ const warnings = new Set();
775
+ if (!facts.reachable) warnings.add('remote-unavailable');
776
+
777
+ const governing = facts.reachable && facts.remote !== null ? facts.remote : facts.worktree;
778
+ /** @type {Source} */
779
+ let source;
780
+ /** @type {Policy} */
781
+ let policy;
782
+ if (governing.kind === 'valid') {
783
+ source = facts.reachable ? 'file' : 'worktree';
784
+ policy = governing.policy;
785
+ } else if (governing.kind === 'absent') {
786
+ source = 'default';
787
+ policy = facts.compliance;
788
+ } else {
789
+ source = 'invalid';
790
+ policy = 'required';
791
+ warnings.add('invalid-file');
792
+ }
793
+
794
+ const base = facts.reachable ? facts.remote : facts.tracking;
795
+ if (base !== null) {
796
+ const baseState = stateOf(base);
797
+ const headDiffers = facts.head !== null && stateOf(facts.head) !== baseState;
798
+ if (headDiffers || stateOf(facts.worktree) !== baseState) warnings.add('pr-changes-policy');
799
+ }
800
+
801
+ /** @type {ParsedPolicy[]} */
802
+ const folded = [];
803
+ if (facts.reachable) folded.push(facts.worktree);
804
+ if (!facts.reachable && facts.tracking !== null && warnings.has('pr-changes-policy')) folded.push(facts.tracking);
805
+ for (const file of folded) {
806
+ if (file.kind === 'valid') {
807
+ policy = stricter(policy, file.policy);
808
+ } else if (file.kind === 'invalid') {
809
+ policy = 'required';
810
+ warnings.add('invalid-file');
811
+ }
812
+ }
813
+
814
+ const withoutCompliance = policy;
815
+ policy = stricter(policy, facts.compliance);
816
+ if (facts.compliance === 'required' && withoutCompliance === 'standard'
817
+ && (source === 'file' || source === 'worktree')) {
818
+ warnings.add('raised-by-compliance');
819
+ }
820
+
821
+ return Object.freeze({
822
+ policy,
823
+ source,
824
+ ref: facts.ref === null ? 'none' : facts.ref,
825
+ warnings: Object.freeze(WARNINGS.filter(w => warnings.has(w))),
826
+ inputs: MECHANISM_INPUTS[policy],
827
+ });
828
+ }
829
+
830
+ // ---------------------------------------------------------------------------
831
+ // resolve
832
+ // ---------------------------------------------------------------------------
833
+
834
+ /**
835
+ * The production exec: spawnSync, read off the module object at call time.
836
+ *
837
+ * @type {ExecFn}
838
+ */
839
+ function defaultExec(file, args, opts) {
840
+ return childProcess.spawnSync(file, args, /** @type {any} */ (opts));
841
+ }
842
+
843
+ /**
844
+ * Resolve the policy for `opts.dir`. Never throws: an unexpected internal
845
+ * failure (the exec itself throwing, say), or a git that cannot say whether
846
+ * `opts.dir` is in a repository, is the fail-closed resolution — `required`,
847
+ * SOURCE `error`, REF `none` — which main() maps to exit 4.
848
+ *
849
+ * @param {ResolveOptions} opts
850
+ * @param {ResolveDeps} [deps]
851
+ * @returns {Resolution}
852
+ */
853
+ function resolve(opts, deps) {
854
+ try {
855
+ const exec = deps && typeof deps.exec === 'function' ? deps.exec : defaultExec;
856
+ const compliance = complianceDefault(opts.compliance !== undefined ? opts.compliance : readManifestCompliance());
857
+ const facts = gatherFacts(opts.dir, compliance, exec);
858
+ return facts === null ? ERROR_RESOLUTION : foldPolicy(facts);
859
+ } catch (_) {
860
+ return ERROR_RESOLUTION;
861
+ }
862
+ }
863
+
864
+ // ---------------------------------------------------------------------------
865
+ // The line, and the gate on it
866
+ // ---------------------------------------------------------------------------
867
+
868
+ /**
869
+ * Compose the stdout line for a resolution (D-POLICY-LINE). WARN is omitted when
870
+ * empty; the mechanism inputs follow in their fixed order.
871
+ *
872
+ * @param {Resolution} r
873
+ * @returns {string}
874
+ */
875
+ function formatLine(r) {
876
+ const warn = r.warnings.length > 0 ? ' WARN=' + r.warnings.join(',') : '';
877
+ const inputs = INPUT_KEYS.map(k => k + '=' + String(r.inputs[/** @type {keyof MechanismInputs} */ (k)])).join(' ');
878
+ return 'EVIDENCE_POLICY=' + r.policy + ' SOURCE=' + r.source + ' REF=' + r.ref + warn + ' ' + inputs;
879
+ }
880
+
881
+ /**
882
+ * Whether a line is well-formed AND internally coherent: it matches
883
+ * OUTPUT_LINE_RE, its inputs are exactly MECHANISM_INPUTS for its policy, its
884
+ * WARN tokens are unique and in registry order, and SOURCE invalid|error carries
885
+ * `required`.
886
+ *
887
+ * @param {unknown} line
888
+ * @returns {boolean}
889
+ */
890
+ function isCoherentLine(line) {
891
+ if (typeof line !== 'string') return false;
892
+ const m = OUTPUT_LINE_RE.exec(line);
893
+ if (m === null || m.groups === undefined) return false;
894
+ const g = m.groups;
895
+ const expected = MECHANISM_INPUTS[/** @type {Policy} */ (g.policy)];
896
+ if (g.issue !== String(expected.ISSUE_REQUIRED)
897
+ || g.conventions !== String(expected.APPLY_CONVENTIONS)
898
+ || g.approval !== String(expected.REQUIRE_NON_AUTHOR_APPROVAL)) {
899
+ return false;
900
+ }
901
+ if ((g.source === 'invalid' || g.source === 'error') && g.policy !== 'required') return false;
902
+ if (g.warn !== undefined) {
903
+ const order = g.warn.split(',').map(t => WARNINGS.indexOf(/** @type {Warning} */ (t)));
904
+ for (let i = 1; i < order.length; i++) {
905
+ if (order[i] <= order[i - 1]) return false;
906
+ }
907
+ }
908
+ return true;
909
+ }
910
+
911
+ /**
912
+ * THE GATE main() applies before returning a line: coherent, and carrying
913
+ * exactly the resolved policy — a line that would LOWER the policy is refused
914
+ * even when it is otherwise perfect.
915
+ *
916
+ * @param {unknown} line
917
+ * @param {Resolution} resolution
918
+ * @returns {boolean}
919
+ */
920
+ function passesOutputGate(line, resolution) {
921
+ if (!isCoherentLine(line)) return false;
922
+ const m = OUTPUT_LINE_RE.exec(/** @type {string} */ (line));
923
+ return m !== null && m.groups !== undefined && m.groups.policy === resolution.policy;
924
+ }
925
+
926
+ /**
927
+ * Settle whatever main() returned into what the boundary may print. The code
928
+ * must be a known exit code and the line must fit it: '' for usage, a coherent
929
+ * line for 0, FAIL_CLOSED_LINE for the refusals. Anything else is refused.
930
+ *
931
+ * @param {unknown} outcome
932
+ * @returns {MainOutcome}
933
+ */
934
+ function settleOutcome(outcome) {
935
+ const o = /** @type {any} */ (outcome);
936
+ if (o === null || typeof o !== 'object' || typeof o.line !== 'string') {
937
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
938
+ }
939
+ if (o.code === EXIT_CODES.USAGE) return { code: EXIT_CODES.USAGE, line: '' };
940
+ if (o.code === EXIT_CODES.RESOLVED) {
941
+ return isCoherentLine(o.line) ? { code: o.code, line: o.line } : { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: FAIL_CLOSED_LINE };
942
+ }
943
+ if (o.code === EXIT_CODES.INPUT_UNUSABLE || o.code === EXIT_CODES.OUTPUT_GATE_REFUSED) {
944
+ return { code: o.code, line: FAIL_CLOSED_LINE };
945
+ }
946
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
947
+ }
948
+
949
+ /**
950
+ * A short, safe label for a thrown value — its class name and system error code,
951
+ * never its message (a message can quote input bytes).
952
+ *
953
+ * @param {unknown} err
954
+ * @returns {string}
955
+ */
956
+ function errorLabel(err) {
957
+ const e = /** @type {any} */ (err);
958
+ const name = e && typeof e.name === 'string' ? e.name : 'unknown';
959
+ const code = e && typeof e.code === 'string' ? '/' + e.code : '';
960
+ return (name + code).replace(/[^A-Za-z0-9_/]/g, '').slice(0, 60);
961
+ }
962
+
963
+ // ---------------------------------------------------------------------------
964
+ // main — returns {code, line}; never calls process.exit
965
+ // ---------------------------------------------------------------------------
966
+
967
+ /**
968
+ * @param {string} dir
969
+ * @returns {boolean}
970
+ */
971
+ function isUsableDirectory(dir) {
972
+ try {
973
+ return fs.statSync(dir).isDirectory();
974
+ } catch (_) {
975
+ return false;
976
+ }
977
+ }
978
+
979
+ /**
980
+ * @param {readonly string[]} argv process.argv
981
+ * @param {MainDeps} [deps]
982
+ * @returns {MainOutcome}
983
+ */
984
+ function main(argv, deps) {
985
+ const d = deps || {};
986
+ const args = parseArgs(argv);
987
+ if (args.kind === 'usage') {
988
+ process.stderr.write(args.usage + '\n');
989
+ return { code: EXIT_CODES.USAGE, line: '' };
990
+ }
991
+
992
+ if (!isUsableDirectory(args.dir)) {
993
+ process.stderr.write('resolve-evidence-policy: not a usable directory: '
994
+ + JSON.stringify(args.dir).slice(0, 300) + ' — failing closed to required\n');
995
+ return { code: EXIT_CODES.INPUT_UNUSABLE, line: FAIL_CLOSED_LINE };
996
+ }
997
+
998
+ const resolution = resolve({ dir: args.dir }, { exec: d.exec });
999
+ if (resolution.source === 'error') {
1000
+ process.stderr.write('resolve-evidence-policy: could not resolve (git did not answer, or an internal error)'
1001
+ + ' — failing closed to required\n');
1002
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
1003
+ }
1004
+
1005
+ const format = typeof d.formatLine === 'function' ? d.formatLine : formatLine;
1006
+ let line;
1007
+ try {
1008
+ line = format(resolution);
1009
+ } catch (err) {
1010
+ process.stderr.write('resolve-evidence-policy: internal error (' + errorLabel(err) + ') — failing closed to required\n');
1011
+ return { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
1012
+ }
1013
+
1014
+ if (!passesOutputGate(line, resolution)) {
1015
+ process.stderr.write('resolve-evidence-policy: output gate refused the composed line — failing closed to required\n');
1016
+ return { code: EXIT_CODES.OUTPUT_GATE_REFUSED, line: FAIL_CLOSED_LINE };
1017
+ }
1018
+ return { code: EXIT_CODES.RESOLVED, line: /** @type {string} */ (line) };
1019
+ }
1020
+
1021
+ // ---------------------------------------------------------------------------
1022
+ // Top-level boundary
1023
+ //
1024
+ // This is the ONLY place that writes to stdout and sets process.exitCode.
1025
+ // It re-checks what main() returned (settleOutcome) before writing, so even a
1026
+ // main() that returned something malformed prints either nothing (usage) or a
1027
+ // line that passes the gate. Its catch is the fail-closed arm for anything
1028
+ // main() did not anticipate.
1029
+ // ---------------------------------------------------------------------------
1030
+
1031
+ if (require.main === module) {
1032
+ let outcome;
1033
+ try {
1034
+ outcome = main(process.argv);
1035
+ } catch (err) {
1036
+ process.stderr.write('resolve-evidence-policy: internal error (' + errorLabel(err) + ') — failing closed to required\n');
1037
+ outcome = { code: EXIT_CODES.INTERNAL_ERROR, line: FAIL_CLOSED_LINE };
1038
+ }
1039
+ const settled = settleOutcome(outcome);
1040
+ if (settled.line !== '') process.stdout.write(settled.line + '\n');
1041
+ process.exitCode = settled.code;
1042
+ }
1043
+
1044
+ // ---------------------------------------------------------------------------
1045
+ // Exports — the CLI's `compliance --status` seam and the unit tests
1046
+ // ---------------------------------------------------------------------------
1047
+
1048
+ module.exports = Object.freeze({
1049
+ POLICIES,
1050
+ SOURCES,
1051
+ WARNINGS,
1052
+ EXIT_CODES,
1053
+ MECHANISM_INPUTS,
1054
+ MAX_POLICY_BYTES,
1055
+ SAFE_REF_RE,
1056
+ OUTPUT_LINE_RE,
1057
+ FAIL_CLOSED_LINE,
1058
+ parseArgs,
1059
+ parsePolicyBytes,
1060
+ complianceDefault,
1061
+ resolve,
1062
+ formatLine,
1063
+ serializePolicy,
1064
+ main,
1065
+ });