devflow-kit 2.5.0 → 3.0.1

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 +82 -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 +246 -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
@@ -3,14 +3,17 @@
3
3
  //
4
4
  // Resolves EVIDENCE_POLICY for a repository and prints it on ONE line together
5
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/.
6
+ // top-level sibling of hud.sh and redact-secrets.cjs under ~/.devflow/scripts/,
7
+ // with its parser beside it in lib/project-config.cjs.
7
8
  //
8
9
  // Usage: node resolve-evidence-policy.cjs [<dir>] (<dir> defaults to cwd)
9
10
  //
10
11
  // The policy is plumbing, decided once by the caller: this script prints it plus
11
12
  // 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.
13
+ // — no file, no git ref, no remote state — so `.devflow/project.json` stays a
14
+ // team-owned file that only the team commits (applies ADR-024). A committed
15
+ // `.devflow/policy.json` is detected by presence and never parsed
16
+ // (D-POLICY-JSON-RETIRED).
14
17
  //
15
18
  // stdout is exactly one line plus "\n", or empty (D-POLICY-LINE):
16
19
  // EVIDENCE_POLICY=<required|standard> SOURCE=<file|worktree|default|invalid|error>
@@ -38,8 +41,8 @@
38
41
  // exitCode assignment, so nothing is truncated and no cleanup is skipped
39
42
  // - every subprocess is spawned with an argv array (never a shell), stdin
40
43
  // 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
44
+ // - a project.json that is not a regular file is never opened, and one over
45
+ // MAX_CONFIG_BYTES is never read; a working-tree policy.json is never opened
43
46
  // - no index-refreshing git command runs (no `status`, no `diff`), so a
44
47
  // repository's configured fsmonitor hook never fires; no fetch, no set-head
45
48
 
@@ -47,8 +50,11 @@
47
50
 
48
51
  const fs = require('fs');
49
52
  const path = require('path');
50
- const os = require('os');
51
53
  const childProcess = require('child_process');
54
+ const projectConfig = require('./lib/project-config.cjs');
55
+
56
+ /** Largest project.json read, in bytes — the shared parser's bound. */
57
+ const { MAX_CONFIG_BYTES } = projectConfig;
52
58
 
53
59
  // ---------------------------------------------------------------------------
54
60
  // Closed vocabularies
@@ -67,7 +73,8 @@ const SOURCES = Object.freeze(/** @type {Source[]} */ (['file', 'worktree', 'def
67
73
  /**
68
74
  * Every WARN token, in the order the line prints them.
69
75
  * 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
76
+ * invalid-file a folded source (remote, worktree or tracking copy) was invalid —
77
+ * its project.json evidence, or the retired policy.json's presence
71
78
  * raised-by-compliance compliance raised a `standard` file or worktree policy
72
79
  * pr-changes-policy HEAD or the worktree differs from the default branch (advisory)
73
80
  */
@@ -116,19 +123,11 @@ const MECHANISM_INPUTS = Object.freeze({
116
123
  /** The mechanism-input keys, in line order. */
117
124
  const INPUT_KEYS = Object.freeze(['ISSUE_REQUIRED', 'APPLY_CONVENTIONS', 'REQUIRE_NON_AUTHOR_APPROVAL']);
118
125
 
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
  /**
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 `.*`).
127
+ * A branch name this script will put into argv or onto stdout — the shared gate
128
+ * (lib/project-config.cjs), so the settings resolver reads the same branch names.
130
129
  */
131
- const SAFE_REF_RE = /^(?![A-Za-z0-9._/-]{0,254}\.\.)[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/;
130
+ const { SAFE_REF_RE } = projectConfig;
132
131
 
133
132
  /**
134
133
  * D-POLICY-LINE: the exported output grammar — anchored, closed alternations
@@ -153,14 +152,17 @@ const FAIL_CLOSED_LINE =
153
152
  const GH_TIMEOUT_MS = 10000;
154
153
  const GIT_REMOTE_TIMEOUT_MS = 10000;
155
154
  const GIT_LOCAL_TIMEOUT_MS = 5000;
156
- /** gh answers and blob reads: generous for a 4 KiB policy, and an overflow is ENOBUFS ⇒ invalid. */
155
+ /** gh answers and blob reads: generous for a 4 KiB project.json, and an overflow is ENOBUFS ⇒ invalid. */
157
156
  const BLOB_MAX_BUFFER = 65536;
158
157
  /** rev-parse and ls-remote print one short line each. */
159
158
  const LINE_MAX_BUFFER = 4096;
160
159
 
161
- /** The repository-relative path of the policy file, in git and API spelling. */
160
+ /** The repository-relative path of the retired policy file, in git and API spelling (D-POLICY-JSON-RETIRED). */
162
161
  const POLICY_REL = '.devflow/policy.json';
163
162
 
163
+ /** The repository-relative path of the team config file, in git and API spelling. */
164
+ const PROJECT_REL = '.devflow/project.json';
165
+
164
166
  /** `git ls-remote --symref origin HEAD`, first line. Bounded class, no backtracking. */
165
167
  const LS_REMOTE_SYMREF_RE = /^ref: refs\/heads\/([^\t\n]{1,255})\tHEAD$/;
166
168
 
@@ -241,76 +243,6 @@ function parseArgs(argv) {
241
243
  return { kind: 'resolve', dir: positionals.length === 1 ? positionals[0] : process.cwd() };
242
244
  }
243
245
 
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
246
  // ---------------------------------------------------------------------------
315
247
  // Compliance default
316
248
  // ---------------------------------------------------------------------------
@@ -340,117 +272,148 @@ function complianceDefault(rawFeatureValue) {
340
272
  // Bounded file reads
341
273
  // ---------------------------------------------------------------------------
342
274
 
275
+ /** Bounded, never-writing file read — the shared one (lib/project-config.cjs). */
276
+ const readBoundedRegularFile = projectConfig.readBoundedRegularFile;
277
+
343
278
  /**
344
- * Read a regular file of at most `maxBytes`, or say why not.
279
+ * D-POLICY-JSON-RETIRED: `.devflow/policy.json` is never parsed. At a source whose
280
+ * project.json does not decide the evidence (absent, or present without an
281
+ * `evidence` key), an existing policy.json makes that source `invalid` — whatever
282
+ * its bytes say, `standard` and unreadable alike — so the repository resolves
283
+ * `required` with `invalid-file` until the team states its value as `evidence` in
284
+ * project.json. With neither file the source is absent. Presence is the whole
285
+ * signal, and each source probes it with the call it already makes:
286
+ * R the default branch's contents call — exit 0 or an overflow is present, a
287
+ * 404 absent, anything else the remote unavailable (remoteSource)
288
+ * T/H `git cat-file blob` — exit 0 present, an answered non-zero exit absent,
289
+ * an unanswered call invalid (catFilePolicyPresence)
290
+ * W an lstat that never opens the file — ENOENT or ENOTDIR absent, anything
291
+ * else present, a symlink or FIFO included (worktreePolicyPresence)
292
+ * The bytes a call returns are discarded unread, so no value in the file can
293
+ * lower the policy or reach stdout.
345
294
  *
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.
295
+ * @param {boolean} present
296
+ * @returns {ParsedPolicy}
297
+ */
298
+ function retiredPolicyReading(present) {
299
+ return present ? INVALID : ABSENT;
300
+ }
301
+
302
+ /**
303
+ * W — whether the working tree holds a policy.json. lstat only: the file is never
304
+ * opened, so a FIFO cannot block and a symlink is never followed. An lstat that
305
+ * fails for any reason but "no such path" cannot prove absence, so it reads as
306
+ * present (fail closed).
352
307
  *
353
- * @param {string} filePath
354
- * @param {number} maxBytes
355
- * @param {boolean} followSymlinks
356
- * @returns {{ kind: 'absent' } | { kind: 'refused' } | { kind: 'ok', bytes: Buffer }}
308
+ * @param {string} root
309
+ * @returns {ParsedPolicy}
357
310
  */
358
- function readBoundedRegularFile(filePath, maxBytes, followSymlinks) {
359
- let st;
311
+ function worktreePolicyPresence(root) {
360
312
  try {
361
- st = followSymlinks ? fs.statSync(filePath) : fs.lstatSync(filePath);
313
+ fs.lstatSync(path.join(root, '.devflow', 'policy.json'));
314
+ return retiredPolicyReading(true);
362
315
  } catch (/** @type {any} */ err) {
363
- return err && (err.code === 'ENOENT' || err.code === 'ENOTDIR') ? { kind: 'absent' } : { kind: 'refused' };
316
+ return retiredPolicyReading(!(err && (err.code === 'ENOENT' || err.code === 'ENOTDIR')));
364
317
  }
365
- if (!st.isFile() || st.size > maxBytes) return { kind: 'refused' };
318
+ }
366
319
 
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 */ }
320
+ // ---------------------------------------------------------------------------
321
+ // Source readings (D-POLICY-SOURCE-PRECEDENCE, D-COMPLIANCE-REPO-FLOOR)
322
+ // ---------------------------------------------------------------------------
323
+
324
+ /**
325
+ * @typedef {{ evidence: ParsedPolicy, compliance: boolean }} SourceReading
326
+ * What one source (R, T, H or W) says: its evidence policy, and whether its
327
+ * project.json declares repository compliance.
328
+ *
329
+ * @typedef {{ evidence: ParsedPolicy | null, compliance: boolean }} ProjectReading
330
+ * `evidence` null ⇒ project.json does not decide it, so the presence of the
331
+ * retired policy file at the same source does (D-POLICY-JSON-RETIRED).
332
+ *
333
+ * @typedef {{ kind: 'absent' } | { kind: 'invalid' } | { kind: 'parsed', evidence: { kind: string, value?: unknown }, compliance: { kind: string } }} ProjectConfigView
334
+ * The part of lib/project-config.cjs ProjectConfig this script reads.
335
+ */
336
+
337
+ /** @type {ProjectConfigView} */
338
+ const PROJECT_ABSENT = Object.freeze({ kind: 'absent' });
339
+ /** @type {ProjectConfigView} */
340
+ const PROJECT_INVALID = Object.freeze({ kind: 'invalid' });
341
+
342
+ /**
343
+ * D-POLICY-SOURCE-PRECEDENCE: at every source, the `evidence` key of
344
+ * `project.json` is the one authority on the evidence policy. A valid value
345
+ * decides it, and a present-but-malformed or duplicated `evidence` decides
346
+ * `invalid` (⇒ required); a project.json that is not a JSON object at all is
347
+ * invalid outright. Only where project.json is absent, or present without an
348
+ * `evidence` key, does the same source's retired `policy.json` count — by
349
+ * presence alone (D-POLICY-JSON-RETIRED). The precedence is per source, so each
350
+ * source says ONE thing and foldPolicy folds them unchanged.
351
+ *
352
+ * D-COMPLIANCE-REPO-FLOOR: a `compliance` key in project.json at R, T or W — any
353
+ * value, an empty list and a malformed one included — makes the repository's
354
+ * compliance default `required`, folded with the machine's as
355
+ * stricter(C_machine, C_repo). A machine with compliance on at zero frameworks is
356
+ * already `required`, and a repository that declares compliance is held to the
357
+ * same floor. A project.json that cannot be read counts as declaring it (fail
358
+ * closed).
359
+ *
360
+ * @param {ProjectConfigView} project
361
+ * @returns {ProjectReading}
362
+ */
363
+ function projectReading(project) {
364
+ if (project.kind === 'absent') return { evidence: null, compliance: false };
365
+ if (project.kind === 'invalid') return { evidence: INVALID, compliance: true };
366
+ const compliance = project.compliance.kind !== 'absent';
367
+ const evidence = project.evidence;
368
+ if (evidence.kind === 'valid' && POLICIES.includes(/** @type {Policy} */ (evidence.value))) {
369
+ return { evidence: Object.freeze({ kind: 'valid', policy: /** @type {Policy} */ (evidence.value) }), compliance };
395
370
  }
371
+ if (evidence.kind === 'absent') return { evidence: null, compliance };
372
+ return { evidence: INVALID, compliance };
396
373
  }
397
374
 
398
375
  /**
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.
376
+ * Complete a project reading with the retired policy file's presence when
377
+ * project.json did not decide the evidence (D-POLICY-JSON-RETIRED).
402
378
  *
403
- * @param {string} root
404
- * @returns {ParsedPolicy}
379
+ * @param {ProjectReading} reading
380
+ * @param {() => ParsedPolicy} probePolicy called only when needed
381
+ * @returns {SourceReading}
405
382
  */
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);
383
+ function withRetiredPolicy(reading, probePolicy) {
384
+ return { evidence: reading.evidence === null ? probePolicy() : reading.evidence, compliance: reading.compliance };
411
385
  }
412
386
 
413
387
  /**
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).
388
+ * W — the working tree's project.json, then (only when it has no `evidence`)
389
+ * whether a policy.json is there. project.json is lstat-refused, never followed:
390
+ * a symlink, directory, FIFO or device is invalid unopened, and an oversize file
391
+ * invalid unread. policy.json is never opened at all.
416
392
  *
417
- * @returns {string | null}
393
+ * @param {string} root
394
+ * @returns {SourceReading}
418
395
  */
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;
396
+ function readWorktreeSource(root) {
397
+ const read = readBoundedRegularFile(path.join(root, '.devflow', 'project.json'), MAX_CONFIG_BYTES, false);
398
+ const project = read.kind === 'ok' ? projectConfig.parseProjectBytes(read.bytes)
399
+ : read.kind === 'absent' ? PROJECT_ABSENT : PROJECT_INVALID;
400
+ return withRetiredPolicy(projectReading(project), () => worktreePolicyPresence(root));
430
401
  }
431
402
 
432
403
  /**
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.
404
+ * The raw `features.compliance` value from the machine manifest, or undefined.
405
+ * The manifest is read by the shared reader (lib/project-config.cjs
406
+ * readMachineManifest) — never through the CLI's manifest reader, which
407
+ * heal-writes. Anything unreadable or malformed is "no state", which
408
+ * complianceDefault maps to disabled; since C only ever raises, a missing C can
409
+ * never lower the result below the governing file.
438
410
  *
439
411
  * @returns {unknown}
440
412
  */
441
413
  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
- }
414
+ const parsed = projectConfig.readMachineManifest();
452
415
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
453
- const features = parsed.features;
416
+ const features = /** @type {any} */ (parsed).features;
454
417
  if (features === null || typeof features !== 'object' || Array.isArray(features)) return undefined;
455
418
  return Object.prototype.hasOwnProperty.call(features, 'compliance') ? features.compliance : undefined;
456
419
  }
@@ -565,15 +528,20 @@ function probeDefaultBranch(ctx, root) {
565
528
  }
566
529
 
567
530
  /**
568
- * Step 3: R, the default branch's policy file — or null when the remote turned
569
- * out to be unavailable after all.
531
+ * @typedef {{ kind: 'bytes', bytes: Buffer } | { kind: 'absent' } | { kind: 'overflow' }} RemoteFile
532
+ */
533
+
534
+ /**
535
+ * Step 3: one file on the default branch — or null when the remote turned out to
536
+ * be unavailable after all.
570
537
  *
571
538
  * `--method GET` is mandatory: gh sends POST whenever a field is added. stdout is
572
539
  * file content ONLY on exit 0 — a 404 prints gh's JSON error body on stdout, so
573
540
  * 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
541
+ * ENOBUFS ⇒ overflow — invalid, never unavailable,
542
+ * or a huge file would hand control to
543
+ * the worktree
544
+ * exit 0 ⇒ the bytes
577
545
  * exit ≠ 0 and stderr has `(HTTP 404)` ⇒ absent — the probe already succeeded,
578
546
  * so 404 means "no file", not "no access"
579
547
  * anything else (403/429, 5xx, timeout, spawn error) ⇒ unavailable
@@ -581,19 +549,42 @@ function probeDefaultBranch(ctx, root) {
581
549
  * @param {CallContext} ctx
582
550
  * @param {string} root
583
551
  * @param {string} ref
584
- * @returns {ParsedPolicy | null}
552
+ * @param {string} rel PROJECT_REL or POLICY_REL — a constant, never input
553
+ * @returns {RemoteFile | null}
585
554
  */
586
- function fetchRemotePolicy(ctx, root, ref) {
555
+ function fetchRemoteFile(ctx, root, ref, rel) {
587
556
  const r = runCall(ctx, 'gh', [
588
- 'api', '--method', 'GET', 'repos/{owner}/{repo}/contents/' + POLICY_REL,
557
+ 'api', '--method', 'GET', 'repos/{owner}/{repo}/contents/' + rel,
589
558
  '-f', 'ref=' + ref, '-H', 'Accept: application/vnd.github.raw+json',
590
559
  ], 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;
560
+ if (r.errorCode === 'ENOBUFS') return { kind: 'overflow' };
561
+ if (r.ok) return { kind: 'bytes', bytes: r.stdout };
562
+ if (r.errorCode === null && r.status !== null && r.status !== 0 && r.stderr.includes('(HTTP 404)')) return { kind: 'absent' };
594
563
  return null;
595
564
  }
596
565
 
566
+ /**
567
+ * R — the default branch's project.json, then (only when it is a 404 or has no
568
+ * `evidence`) whether its policy.json exists — the contents call's bytes are
569
+ * discarded unread (D-POLICY-JSON-RETIRED). Null when either call finds the
570
+ * remote unavailable: a half-read default branch is not a default branch.
571
+ *
572
+ * @param {CallContext} ctx
573
+ * @param {string} root
574
+ * @param {string} ref
575
+ * @returns {SourceReading | null}
576
+ */
577
+ function remoteSource(ctx, root, ref) {
578
+ const projectFile = fetchRemoteFile(ctx, root, ref, PROJECT_REL);
579
+ if (projectFile === null) return null;
580
+ const reading = projectReading(projectFile.kind === 'bytes' ? projectConfig.parseProjectBytes(projectFile.bytes)
581
+ : projectFile.kind === 'absent' ? PROJECT_ABSENT : PROJECT_INVALID);
582
+ if (reading.evidence !== null) return { evidence: reading.evidence, compliance: reading.compliance };
583
+ const policyFile = fetchRemoteFile(ctx, root, ref, POLICY_REL);
584
+ if (policyFile === null) return null;
585
+ return withRetiredPolicy(reading, () => retiredPolicyReading(policyFile.kind !== 'absent'));
586
+ }
587
+
597
588
  /**
598
589
  * Step 5 (offline, D unknown): the default branch from `ls-remote --symref`.
599
590
  * Only line 1 is read, and its capture must still pass SAFE_REF_RE.
@@ -612,27 +603,94 @@ function lsRemoteDefaultBranch(ctx, root) {
612
603
  }
613
604
 
614
605
  /**
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.
606
+ * @typedef {{ kind: 'ref', ref: string } | { kind: 'none' } | { kind: 'unreadable' }} OriginHead
607
+ */
608
+
609
+ /**
610
+ * Step 5b (offline, D still unknown after ls-remote) — D-OFFLINE-ORIGIN-HEAD:
611
+ * the default branch this clone last recorded for origin, read LOCALLY with
612
+ * `git symbolic-ref --quiet refs/remotes/origin/HEAD` (no network, no index
613
+ * refresh). With D unnamed an unreachable origin leaves the worktree's own file
614
+ * governing, so a branch that sets `evidence:"standard"` would resolve standard
615
+ * while main says required; naming D here lets trackingSource fold main's
616
+ * tracking copy in, so the branch cannot lower it.
617
+ * ref exit 0 with one `refs/remotes/origin/<SAFE_REF_RE>` line
618
+ * none an answered non-zero exit: origin/HEAD is not recorded — the
619
+ * residual case, where the worktree file governs with
620
+ * WARN=remote-unavailable
621
+ * unreadable git did not answer, or origin/HEAD exists but its answer names
622
+ * no safe branch (another remote, a hostile or unparseable name).
623
+ * Either is a failure, never the residual case: gatherFacts reads
624
+ * it as an invalid base, which resolves required (avoids PF-075).
625
+ * resolve-settings' defaultBranchCompliance fails the same answer
626
+ * closed, to `generic`.
627
+ *
628
+ * @param {CallContext} ctx
629
+ * @param {string} root
630
+ * @returns {OriginHead}
631
+ */
632
+ function localOriginHead(ctx, root) {
633
+ const r = runCall(ctx, 'git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], root,
634
+ GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
635
+ if (!answered(r)) return { kind: 'unreadable' };
636
+ if (!r.ok) return { kind: 'none' };
637
+ const ref = projectConfig.parseOriginHeadRef(r.stdout.toString('utf8'));
638
+ return ref === null ? { kind: 'unreadable' } : { kind: 'ref', ref };
639
+ }
640
+
641
+ /**
642
+ * Steps 4 and 7: one blob at a revision (HEAD, or the tracking ref).
643
+ * Exit 0 ⇒ the bytes; an answered non-zero exit (the path does not exist there,
644
+ * an unborn HEAD) ⇒ absent; an unanswered call (ENOBUFS included) ⇒ unanswered,
645
+ * which every caller reads as invalid — never absent, or a git that timed out
646
+ * would silently drop the default branch's copy from the fold.
620
647
  *
621
648
  * @param {CallContext} ctx
622
649
  * @param {string} root
623
650
  * @param {string} revision
624
- * @returns {ParsedPolicy}
651
+ * @param {string} rel PROJECT_REL or POLICY_REL — a constant, never input
652
+ * @returns {{ kind: 'bytes', bytes: Buffer } | { kind: 'absent' } | { kind: 'unanswered' }}
625
653
  */
626
- function catFilePolicy(ctx, root, revision) {
627
- const r = runCall(ctx, 'git', ['cat-file', 'blob', revision + ':' + POLICY_REL], root,
654
+ function catFileBlob(ctx, root, revision, rel) {
655
+ const r = runCall(ctx, 'git', ['cat-file', 'blob', revision + ':' + rel], root,
628
656
  GIT_LOCAL_TIMEOUT_MS, BLOB_MAX_BUFFER);
629
- if (r.ok) return parsePolicyBytes(r.stdout);
630
- return answered(r) ? ABSENT : INVALID;
657
+ if (r.ok) return { kind: 'bytes', bytes: r.stdout };
658
+ return answered(r) ? { kind: 'absent' } : { kind: 'unanswered' };
659
+ }
660
+
661
+ /**
662
+ * Whether a revision holds the retired policy blob (D-POLICY-JSON-RETIRED): its
663
+ * bytes are discarded unread, and an unanswered read cannot prove absence, so it
664
+ * reads as present.
665
+ *
666
+ * @param {CallContext} ctx
667
+ * @param {string} root
668
+ * @param {string} revision
669
+ * @returns {ParsedPolicy}
670
+ */
671
+ function catFilePolicyPresence(ctx, root, revision) {
672
+ return retiredPolicyReading(catFileBlob(ctx, root, revision, POLICY_REL).kind !== 'absent');
673
+ }
674
+
675
+ /**
676
+ * T or H: a revision's project.json, then (only when it is absent or has no
677
+ * `evidence`) whether its policy.json exists.
678
+ *
679
+ * @param {CallContext} ctx
680
+ * @param {string} root
681
+ * @param {string} revision
682
+ * @returns {SourceReading}
683
+ */
684
+ function revisionSource(ctx, root, revision) {
685
+ const blob = catFileBlob(ctx, root, revision, PROJECT_REL);
686
+ const project = blob.kind === 'bytes' ? projectConfig.parseProjectBytes(blob.bytes)
687
+ : blob.kind === 'absent' ? PROJECT_ABSENT : PROJECT_INVALID;
688
+ return withRetiredPolicy(projectReading(project), () => catFilePolicyPresence(ctx, root, revision));
631
689
  }
632
690
 
633
691
  /**
634
692
  * 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
693
+ * branch's files. The ref check decides whether T can stand for the default branch
636
694
  * at all — a ref git ANSWERS is missing is "B unknown" (null), which a failed blob
637
695
  * read could not tell apart from "file absent". A ref check git does not answer is
638
696
  * not knowing, so T is invalid (it raises) rather than unknown (it would not).
@@ -640,14 +698,14 @@ function catFilePolicy(ctx, root, revision) {
640
698
  * @param {CallContext} ctx
641
699
  * @param {string} root
642
700
  * @param {string} ref
643
- * @returns {ParsedPolicy | null}
701
+ * @returns {SourceReading | null}
644
702
  */
645
- function trackingPolicy(ctx, root, ref) {
703
+ function trackingSource(ctx, root, ref) {
646
704
  const trackingRef = 'refs/remotes/origin/' + ref;
647
705
  const r = runCall(ctx, 'git', ['rev-parse', '--verify', '--quiet', trackingRef], root,
648
706
  GIT_LOCAL_TIMEOUT_MS, LINE_MAX_BUFFER);
649
- if (r.ok) return catFilePolicy(ctx, root, trackingRef);
650
- return answered(r) ? null : INVALID;
707
+ if (r.ok) return revisionSource(ctx, root, trackingRef);
708
+ return answered(r) ? null : { evidence: INVALID, compliance: false };
651
709
  }
652
710
 
653
711
  // ---------------------------------------------------------------------------
@@ -664,27 +722,35 @@ function trackingPolicy(ctx, root, ref) {
664
722
  * head: ParsedPolicy | null,
665
723
  * compliance: Policy,
666
724
  * }} 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
725
+ * remote R — set iff reachable
726
+ * tracking T — set iff offline and refs/remotes/origin/<D> exists (or git
727
+ * could not answer whether it does, or origin/HEAD is unreadable —
728
+ * then invalid)
729
+ * head H — set iff B (R online, T offline) is known
730
+ * compliance stricter(C_machine, C_repo) — D-COMPLIANCE-REPO-FLOOR
671
731
  */
672
732
 
673
733
  /**
674
- * Run the fixed call sequence. The only calls ever made, in order:
675
- * git rev-parse --show-toplevel (cwd = <dir>)
734
+ * Run the fixed call sequence (D-POLICY-PROBE). The only calls ever made, in order:
735
+ * git rev-parse --show-toplevel (cwd = <dir>)
676
736
  * 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)
737
+ * gh api --method GET …/contents/.devflow/project.json (the probe named D)
738
+ * gh api --method GET …/contents/.devflow/policy.json (… and project.json is
739
+ * a 404 or has no evidence;
740
+ * presence only)
741
+ * git ls-remote --symref origin HEAD (offline, D unknown)
742
+ * git symbolic-ref --quiet refs/remotes/origin/HEAD (… and ls-remote named none)
743
+ * git rev-parse --verify --quiet refs/remotes/origin/D (offline, D known)
744
+ * git cat-file blob refs/remotes/origin/D:…/project.json (that ref exists)
745
+ * git cat-file blob refs/remotes/origin/D:…/policy.json (… no evidence there; presence)
746
+ * git cat-file blob HEAD:…/project.json (B known)
747
+ * git cat-file blob HEAD:…/policy.json (… no evidence there; presence)
682
748
  *
683
749
  * Returns null when git cannot say whether <dir> is in a repository at all (see
684
750
  * gitToplevel): nothing below can be trusted then, and resolve() fails closed.
685
751
  *
686
752
  * @param {string} dir
687
- * @param {Policy} compliance
753
+ * @param {Policy} compliance C_machine
688
754
  * @param {ExecFn} exec
689
755
  * @returns {Facts | null}
690
756
  */
@@ -702,20 +768,37 @@ function gatherFacts(dir, compliance, exec) {
702
768
  }
703
769
  const root = toplevel.root;
704
770
 
705
- const worktree = readWorktreePolicy(root);
771
+ const worktree = readWorktreeSource(root);
706
772
  let ref = probeDefaultBranch(ctx, root);
707
- const remote = ref === null ? null : fetchRemotePolicy(ctx, root, ref);
773
+ const remote = ref === null ? null : remoteSource(ctx, root, ref);
708
774
  const reachable = remote !== null;
709
775
 
776
+ /** @type {SourceReading | null} */
710
777
  let tracking = null;
711
778
  if (!reachable) {
712
779
  if (ref === null) ref = lsRemoteDefaultBranch(ctx, root);
713
- if (ref !== null) tracking = trackingPolicy(ctx, root, ref);
780
+ if (ref === null) {
781
+ const originHead = localOriginHead(ctx, root);
782
+ if (originHead.kind === 'ref') ref = originHead.ref;
783
+ if (originHead.kind === 'unreadable') tracking = { evidence: INVALID, compliance: false };
784
+ }
785
+ if (ref !== null) tracking = trackingSource(ctx, root, ref);
714
786
  }
715
787
 
716
788
  const baseKnown = reachable || tracking !== null;
717
- const head = baseKnown ? catFilePolicy(ctx, root, 'HEAD') : null;
718
- return { reachable, ref, remote, worktree, tracking, head, compliance };
789
+ const head = baseKnown ? revisionSource(ctx, root, 'HEAD') : null;
790
+ const repoCompliance = worktree.compliance
791
+ || (remote !== null && remote.compliance)
792
+ || (tracking !== null && tracking.compliance);
793
+ return {
794
+ reachable,
795
+ ref,
796
+ remote: remote === null ? null : remote.evidence,
797
+ worktree: worktree.evidence,
798
+ tracking: tracking === null ? null : tracking.evidence,
799
+ head: head === null ? null : head.evidence,
800
+ compliance: stricter(compliance, repoCompliance ? 'required' : 'standard'),
801
+ };
719
802
  }
720
803
 
721
804
  // ---------------------------------------------------------------------------
@@ -1051,15 +1134,12 @@ module.exports = Object.freeze({
1051
1134
  WARNINGS,
1052
1135
  EXIT_CODES,
1053
1136
  MECHANISM_INPUTS,
1054
- MAX_POLICY_BYTES,
1055
1137
  SAFE_REF_RE,
1056
1138
  OUTPUT_LINE_RE,
1057
1139
  FAIL_CLOSED_LINE,
1058
1140
  parseArgs,
1059
- parsePolicyBytes,
1060
1141
  complianceDefault,
1061
1142
  resolve,
1062
1143
  formatLine,
1063
- serializePolicy,
1064
1144
  main,
1065
1145
  });