@bookedsolid/rea 0.43.0 → 0.44.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.
@@ -59,6 +59,29 @@ export interface ResolvedConfig {
59
59
  reagentPolicyPath: string | null;
60
60
  reagentNotices: string[];
61
61
  }
62
+ /**
63
+ * 0.44.0 charter item 1: derive the canonical hook filename set the
64
+ * installer will lay down. Union of:
65
+ *
66
+ * - `EXPECTED_HOOKS` (the doctor's required-on-disk list — source of
67
+ * truth for "what `.claude/hooks/` must contain after install").
68
+ * - The `command` paths of every entry in `defaultDesiredHooks()`
69
+ * (the source of truth for "what `.claude/settings.json` registers
70
+ * with Claude Code"). Each command path ends in
71
+ * `.claude/hooks/<name>.sh`; we extract `<name>.sh` so the result
72
+ * joins cleanly with `EXPECTED_HOOKS`.
73
+ *
74
+ * Pre-0.44.0 `buildInstallSummary` hard-coded a hook count / list. If
75
+ * a new hook was added to `EXPECTED_HOOKS` (e.g. `delegation-advisory`
76
+ * was promoted in 0.36.0) or registered in `defaultDesiredHooks()`
77
+ * without anyone touching the summary, the operator's confirm screen
78
+ * silently lied about what was about to be installed. This helper
79
+ * means the summary now tracks the real installer surface — adding
80
+ * a hook to either canonical source automatically updates the screen.
81
+ *
82
+ * Sorted + deduped so the screen is stable across orderings.
83
+ */
84
+ export declare function canonicalInstalledHooks(): string[];
62
85
  /**
63
86
  * 0.43.0 UX polish: build the human-readable install summary shown
64
87
  * BEFORE any files are written. Lists, in order: the policy file
@@ -67,6 +90,11 @@ export interface ResolvedConfig {
67
90
  * installer will ACTUALLY do given the target tree's shape), and
68
91
  * whether re-run preservation is active.
69
92
  *
93
+ * 0.44.0 charter item 1: hook count + listing is derived from the
94
+ * canonical hook resolvers via {@link canonicalInstalledHooks}, NOT
95
+ * hard-coded. Adding a hook to `EXPECTED_HOOKS` or
96
+ * `defaultDesiredHooks()` automatically reflects in this screen.
97
+ *
70
98
  * Rendered via clack's `note` primitive so it sits in a bordered block
71
99
  * adjacent to the final `confirm` gate. The string is also returned
72
100
  * verbatim so the test suite can assert content without mocking clack.
@@ -99,12 +127,52 @@ export interface TargetState {
99
127
  * and the summary was only slightly stale.
100
128
  */
101
129
  export declare function detectTargetState(targetDir: string): TargetState;
130
+ /**
131
+ * 0.44.0 charter item 2: detect filesystems where Unix mode bits are
132
+ * unreliable (Windows-class FSes, WSL/native crossings, some network
133
+ * mounts). On these, `stat.mode` for a freshly-installed `.sh` either
134
+ * reads back without the `0o111` exec bit set, or is zeroed entirely.
135
+ *
136
+ * Pre-fix `postInstallVerify` hard-failed the install when zero `.sh`
137
+ * files had the exec bit — every Windows install thus produced a
138
+ * false-positive "0 executable .sh files" warning even on a perfectly
139
+ * healthy install. We now treat exec-bit checks as advisory on these
140
+ * filesystems and still verify the more meaningful invariant: the
141
+ * files exist and have non-empty bytes.
142
+ *
143
+ * Detection strategy — two layers, either sufficient:
144
+ *
145
+ * 1. Platform — `process.platform === 'win32'` always skips the
146
+ * exec-bit check (native Windows has no POSIX mode bit; node's
147
+ * `stat.mode` is a translation that may or may not preserve the
148
+ * 0o111 bit depending on the source).
149
+ * 2. Sample — even on Linux/macOS, when crossing into a Windows-
150
+ * backed filesystem (WSL bind-mount onto `/mnt/c/`, an SMB
151
+ * share, etc.), `stat.mode` returns a value whose `0o777`
152
+ * portion is zero. We detect this by sampling the FIRST `.sh`
153
+ * file in the hooks directory and checking whether ANY of the
154
+ * `0o777` bits are set; if none are, treat as mode-less.
155
+ *
156
+ * Returns true when the exec-bit check should be SKIPPED.
157
+ *
158
+ * Exported for testability — callers can stub the filesystem and
159
+ * exercise both shapes (mode-aware vs mode-less) without spinning
160
+ * up an actual Windows VM.
161
+ */
162
+ export declare function isModeLessFilesystem(hooksDir: string): boolean;
102
163
  /**
103
164
  * 0.43.0 UX polish: post-install sanity check. Runs synchronously
104
165
  * after the file-write phase to catch installs that completed
105
166
  * "successfully" but are missing a critical artifact (write
106
167
  * permissions issue, partial copy, etc.).
107
168
  *
169
+ * 0.44.0 charter item 2: exec-bit check is skipped on mode-less
170
+ * filesystems (Windows / WSL-crossing / SMB mounts). When skipped, we
171
+ * still verify the files exist + are non-empty — that's the invariant
172
+ * a partial-copy or zero-byte write would actually violate. The skip
173
+ * is annotated in the returned advisory so the operator knows why a
174
+ * check they expected to run didn't.
175
+ *
108
176
  * Strictly read-only — no probes that touch python3 / jq / codex.
109
177
  * Pattern modelled on the synthetic round-trip checks established by
110
178
  * `checkDelegationRoundTrip` in 0.29.0/0.31.0: cheap, in-process,
@@ -112,7 +180,9 @@ export declare function detectTargetState(targetDir: string): TargetState;
112
180
  * that bites first-time consumers hardest. For deep diagnostics
113
181
  * point the operator at `rea doctor`.
114
182
  *
115
- * Returns the list of issues found (empty = healthy). The caller
183
+ * Returns the list of issues found (empty = healthy). Advisory
184
+ * (skipped-check) lines are prefixed with `advisory:` so the caller
185
+ * can distinguish them from real issues if desired. The caller
116
186
  * surfaces them via clack's `log.warn` and points the operator at
117
187
  * `rea doctor` for follow-up.
118
188
  */
package/dist/cli/init.js CHANGED
@@ -8,6 +8,7 @@ import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js
8
8
  import { copyArtifacts } from './install/copy.js';
9
9
  import { ensureReaGitignore } from './install/gitignore.js';
10
10
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
11
+ import { EXPECTED_HOOKS } from './doctor.js';
11
12
  import { installCommitMsgHook } from './install/commit-msg.js';
12
13
  import { installPrepareCommitMsgHook } from './install/prepare-commit-msg.js';
13
14
  import { installPrePushFallback } from './install/pre-push.js';
@@ -813,6 +814,44 @@ function readExistingManifestInstalledAt(manifestPath) {
813
814
  }
814
815
  return undefined;
815
816
  }
817
+ /**
818
+ * 0.44.0 charter item 1: derive the canonical hook filename set the
819
+ * installer will lay down. Union of:
820
+ *
821
+ * - `EXPECTED_HOOKS` (the doctor's required-on-disk list — source of
822
+ * truth for "what `.claude/hooks/` must contain after install").
823
+ * - The `command` paths of every entry in `defaultDesiredHooks()`
824
+ * (the source of truth for "what `.claude/settings.json` registers
825
+ * with Claude Code"). Each command path ends in
826
+ * `.claude/hooks/<name>.sh`; we extract `<name>.sh` so the result
827
+ * joins cleanly with `EXPECTED_HOOKS`.
828
+ *
829
+ * Pre-0.44.0 `buildInstallSummary` hard-coded a hook count / list. If
830
+ * a new hook was added to `EXPECTED_HOOKS` (e.g. `delegation-advisory`
831
+ * was promoted in 0.36.0) or registered in `defaultDesiredHooks()`
832
+ * without anyone touching the summary, the operator's confirm screen
833
+ * silently lied about what was about to be installed. This helper
834
+ * means the summary now tracks the real installer surface — adding
835
+ * a hook to either canonical source automatically updates the screen.
836
+ *
837
+ * Sorted + deduped so the screen is stable across orderings.
838
+ */
839
+ export function canonicalInstalledHooks() {
840
+ const fromExpected = new Set(EXPECTED_HOOKS);
841
+ for (const group of defaultDesiredHooks()) {
842
+ for (const h of group.hooks) {
843
+ const cmd = h.command;
844
+ // Commands have shape `"$CLAUDE_PROJECT_DIR"/.claude/hooks/<name>.sh`.
845
+ // Take the basename (everything after the last `/`). Robust against
846
+ // future path changes — only the filename matters here.
847
+ const slashIdx = cmd.lastIndexOf('/');
848
+ const basename = slashIdx >= 0 ? cmd.slice(slashIdx + 1) : cmd;
849
+ if (basename.endsWith('.sh'))
850
+ fromExpected.add(basename);
851
+ }
852
+ }
853
+ return Array.from(fromExpected).sort();
854
+ }
816
855
  /**
817
856
  * 0.43.0 UX polish: build the human-readable install summary shown
818
857
  * BEFORE any files are written. Lists, in order: the policy file
@@ -821,6 +860,11 @@ function readExistingManifestInstalledAt(manifestPath) {
821
860
  * installer will ACTUALLY do given the target tree's shape), and
822
861
  * whether re-run preservation is active.
823
862
  *
863
+ * 0.44.0 charter item 1: hook count + listing is derived from the
864
+ * canonical hook resolvers via {@link canonicalInstalledHooks}, NOT
865
+ * hard-coded. Adding a hook to `EXPECTED_HOOKS` or
866
+ * `defaultDesiredHooks()` automatically reflects in this screen.
867
+ *
824
868
  * Rendered via clack's `note` primitive so it sits in a bordered block
825
869
  * adjacent to the final `confirm` gate. The string is also returned
826
870
  * verbatim so the test suite can assert content without mocking clack.
@@ -843,7 +887,16 @@ export function buildInstallSummary(targetDir, config, reRunMode, targetState) {
843
887
  lines.push(` .rea/registry.yaml — empty MCP-server registry`);
844
888
  lines.push(` .rea/install-manifest.json — hash record for drift detection`);
845
889
  lines.push(` .claude/agents/ — curated specialist agents`);
846
- lines.push(` .claude/hooks/ — hook scripts (executable)`);
890
+ // 0.44.0 charter item 1: hook count derived from the canonical
891
+ // resolvers (EXPECTED_HOOKS + defaultDesiredHooks). Pre-fix this
892
+ // line read `.claude/hooks/ — hook scripts (executable)`
893
+ // with no count, so adding a new hook silently changed the install
894
+ // surface without surfacing in the operator's confirm screen.
895
+ const hookNames = canonicalInstalledHooks();
896
+ lines.push(` .claude/hooks/ — ${hookNames.length} hook scripts (executable):`);
897
+ for (const name of hookNames) {
898
+ lines.push(` ${name}`);
899
+ }
847
900
  lines.push(` .claude/commands/ — slash commands`);
848
901
  lines.push(` .claude/settings.json — hook registration entries`);
849
902
  // 0.43.0 codex round-1 P3: the installer writes to `.git/hooks/*`
@@ -908,12 +961,81 @@ export function detectTargetState(targetDir) {
908
961
  huskyDirPresent: fs.existsSync(path.join(targetDir, '.husky')),
909
962
  };
910
963
  }
964
+ /**
965
+ * 0.44.0 charter item 2: detect filesystems where Unix mode bits are
966
+ * unreliable (Windows-class FSes, WSL/native crossings, some network
967
+ * mounts). On these, `stat.mode` for a freshly-installed `.sh` either
968
+ * reads back without the `0o111` exec bit set, or is zeroed entirely.
969
+ *
970
+ * Pre-fix `postInstallVerify` hard-failed the install when zero `.sh`
971
+ * files had the exec bit — every Windows install thus produced a
972
+ * false-positive "0 executable .sh files" warning even on a perfectly
973
+ * healthy install. We now treat exec-bit checks as advisory on these
974
+ * filesystems and still verify the more meaningful invariant: the
975
+ * files exist and have non-empty bytes.
976
+ *
977
+ * Detection strategy — two layers, either sufficient:
978
+ *
979
+ * 1. Platform — `process.platform === 'win32'` always skips the
980
+ * exec-bit check (native Windows has no POSIX mode bit; node's
981
+ * `stat.mode` is a translation that may or may not preserve the
982
+ * 0o111 bit depending on the source).
983
+ * 2. Sample — even on Linux/macOS, when crossing into a Windows-
984
+ * backed filesystem (WSL bind-mount onto `/mnt/c/`, an SMB
985
+ * share, etc.), `stat.mode` returns a value whose `0o777`
986
+ * portion is zero. We detect this by sampling the FIRST `.sh`
987
+ * file in the hooks directory and checking whether ANY of the
988
+ * `0o777` bits are set; if none are, treat as mode-less.
989
+ *
990
+ * Returns true when the exec-bit check should be SKIPPED.
991
+ *
992
+ * Exported for testability — callers can stub the filesystem and
993
+ * exercise both shapes (mode-aware vs mode-less) without spinning
994
+ * up an actual Windows VM.
995
+ */
996
+ export function isModeLessFilesystem(hooksDir) {
997
+ if (process.platform === 'win32')
998
+ return true;
999
+ // Sample any single .sh file to probe whether the FS preserves
1000
+ // exec bits at all. We don't need every file — just one signal.
1001
+ try {
1002
+ const entries = fs.readdirSync(hooksDir);
1003
+ const firstSh = entries.find((e) => e.endsWith('.sh'));
1004
+ if (firstSh === undefined) {
1005
+ // No .sh files at all — let the caller's existence check fire.
1006
+ // Treat as mode-aware (skip = false) so we don't hide the
1007
+ // genuinely-missing-files case behind the WSL advisory.
1008
+ return false;
1009
+ }
1010
+ const stat = fs.statSync(path.join(hooksDir, firstSh));
1011
+ // If ALL 0o777 bits are clear, the FS is not preserving Unix
1012
+ // mode bits. Genuine Unix installs always have at least the
1013
+ // owner-read bit (0o400) set, so an entirely-zero perms triple
1014
+ // means we're on a mode-less mount.
1015
+ if ((stat.mode & 0o777) === 0)
1016
+ return true;
1017
+ return false;
1018
+ }
1019
+ catch {
1020
+ // Stat failed — let the caller's enumeration handle the error.
1021
+ // Returning false here means "don't skip" so a genuine ENOENT
1022
+ // surfaces through the normal exec-bit branch.
1023
+ return false;
1024
+ }
1025
+ }
911
1026
  /**
912
1027
  * 0.43.0 UX polish: post-install sanity check. Runs synchronously
913
1028
  * after the file-write phase to catch installs that completed
914
1029
  * "successfully" but are missing a critical artifact (write
915
1030
  * permissions issue, partial copy, etc.).
916
1031
  *
1032
+ * 0.44.0 charter item 2: exec-bit check is skipped on mode-less
1033
+ * filesystems (Windows / WSL-crossing / SMB mounts). When skipped, we
1034
+ * still verify the files exist + are non-empty — that's the invariant
1035
+ * a partial-copy or zero-byte write would actually violate. The skip
1036
+ * is annotated in the returned advisory so the operator knows why a
1037
+ * check they expected to run didn't.
1038
+ *
917
1039
  * Strictly read-only — no probes that touch python3 / jq / codex.
918
1040
  * Pattern modelled on the synthetic round-trip checks established by
919
1041
  * `checkDelegationRoundTrip` in 0.29.0/0.31.0: cheap, in-process,
@@ -921,7 +1043,9 @@ export function detectTargetState(targetDir) {
921
1043
  * that bites first-time consumers hardest. For deep diagnostics
922
1044
  * point the operator at `rea doctor`.
923
1045
  *
924
- * Returns the list of issues found (empty = healthy). The caller
1046
+ * Returns the list of issues found (empty = healthy). Advisory
1047
+ * (skipped-check) lines are prefixed with `advisory:` so the caller
1048
+ * can distinguish them from real issues if desired. The caller
925
1049
  * surfaces them via clack's `log.warn` and points the operator at
926
1050
  * `rea doctor` for follow-up.
927
1051
  */
@@ -945,17 +1069,21 @@ export function postInstallVerify(targetDir) {
945
1069
  'run `rea doctor` for details');
946
1070
  }
947
1071
  }
948
- // 2. .claude/hooks directory present with executable scripts.
1072
+ // 2. .claude/hooks directory present with non-empty scripts (and,
1073
+ // on mode-aware filesystems, executable).
949
1074
  const hooksDir = path.join(targetDir, '.claude', 'hooks');
950
1075
  if (!fs.existsSync(hooksDir)) {
951
1076
  issues.push(`.claude/hooks/ directory missing after install (expected at ${hooksDir})`);
952
1077
  }
953
1078
  else {
1079
+ const modeLess = isModeLessFilesystem(hooksDir);
954
1080
  let executableCount = 0;
1081
+ let shCount = 0;
955
1082
  try {
956
1083
  for (const entry of fs.readdirSync(hooksDir)) {
957
1084
  if (!entry.endsWith('.sh'))
958
1085
  continue;
1086
+ shCount += 1;
959
1087
  const stat = fs.statSync(path.join(hooksDir, entry));
960
1088
  if ((stat.mode & 0o111) !== 0)
961
1089
  executableCount += 1;
@@ -964,7 +1092,58 @@ export function postInstallVerify(targetDir) {
964
1092
  catch (e) {
965
1093
  issues.push(`failed to enumerate .claude/hooks/: ${e instanceof Error ? e.message : String(e)}`);
966
1094
  }
967
- if (executableCount === 0) {
1095
+ if (modeLess) {
1096
+ // 0.44.0 charter item 2: emit a one-liner advisory so the
1097
+ // operator understands why the exec-bit check didn't run. Still
1098
+ // verify the files exist + have content — that's the partial-
1099
+ // copy failure shape we genuinely want to catch on these FSes.
1100
+ //
1101
+ // 0.44.0 codex round-1 P2 fix: validate the FULL canonical hook
1102
+ // set, not just `shCount > 0 && nonEmptyCount > 0`. Pre-fix a
1103
+ // partial copy that left ONE non-empty .sh and dropped the rest
1104
+ // would still report "install looks healthy" because the
1105
+ // substitute invariant only required at least one survivor.
1106
+ // Now we per-file check every entry in canonicalInstalledHooks()
1107
+ // for existence + non-empty bytes — equivalent rigor to the
1108
+ // mode-aware path's per-file exec-bit check.
1109
+ issues.push('advisory: skipping exec-bit check on this filesystem ' +
1110
+ '(Windows/WSL/SMB-class; mode bits not reliable). ' +
1111
+ 'Verifying per-file presence and non-empty content instead.');
1112
+ const expected = canonicalInstalledHooks();
1113
+ const missing = [];
1114
+ const empty = [];
1115
+ for (const name of expected) {
1116
+ const hookPath = path.join(hooksDir, name);
1117
+ if (!fs.existsSync(hookPath)) {
1118
+ missing.push(name);
1119
+ continue;
1120
+ }
1121
+ try {
1122
+ const stat = fs.statSync(hookPath);
1123
+ if (stat.size === 0)
1124
+ empty.push(name);
1125
+ }
1126
+ catch {
1127
+ // Treat unstattable as missing — the partial-copy failure
1128
+ // shape we are trying to detect.
1129
+ missing.push(name);
1130
+ }
1131
+ }
1132
+ if (missing.length > 0) {
1133
+ issues.push(`.claude/hooks/ is missing ${missing.length} expected hook file(s): ${missing.join(', ')}`);
1134
+ }
1135
+ if (empty.length > 0) {
1136
+ issues.push(`.claude/hooks/ has ${empty.length} empty hook file(s): ${empty.join(', ')}`);
1137
+ }
1138
+ // Fallback for the no-canonical-list-known case (defensive — the
1139
+ // helper always returns >=1 in practice, but if a future
1140
+ // refactor empties the resolvers we still want to catch a
1141
+ // completely-empty hooks dir).
1142
+ if (expected.length === 0 && shCount === 0) {
1143
+ issues.push('.claude/hooks/ contains zero .sh files — run `rea doctor`');
1144
+ }
1145
+ }
1146
+ else if (executableCount === 0) {
968
1147
  issues.push('.claude/hooks/ contains zero executable .sh files — run `rea doctor`');
969
1148
  }
970
1149
  }
@@ -1281,13 +1460,34 @@ export async function runInit(options) {
1281
1460
  // operator at `rea doctor` for the deep dive. Modelled on the
1282
1461
  // 0.29.0/0.31.0 synthetic round-trip pattern.
1283
1462
  const verifyIssues = postInstallVerify(targetDir);
1284
- if (verifyIssues.length > 0) {
1463
+ // 0.44.0 charter item 2: split advisory (`advisory:`-prefixed) from
1464
+ // real issues. Advisories explain skipped checks (Windows/WSL exec-
1465
+ // bit skip) and don't merit the loud "verification flagged" header
1466
+ // when no real issue is present.
1467
+ const realIssues = verifyIssues.filter((i) => !i.startsWith('advisory:'));
1468
+ const advisories = verifyIssues.filter((i) => i.startsWith('advisory:'));
1469
+ if (realIssues.length > 0) {
1285
1470
  console.log('');
1286
1471
  warn('post-install verification flagged the following:');
1287
- for (const issue of verifyIssues)
1472
+ for (const issue of realIssues)
1288
1473
  warn(` • ${issue}`);
1474
+ for (const adv of advisories)
1475
+ warn(` • ${adv}`);
1289
1476
  warn('Run `rea doctor` for a full diagnostic.');
1290
1477
  }
1478
+ else if (advisories.length > 0) {
1479
+ if (interactive) {
1480
+ p.log.success('Post-install check: install looks healthy.');
1481
+ for (const adv of advisories)
1482
+ p.log.info(adv);
1483
+ }
1484
+ else {
1485
+ console.log('');
1486
+ console.log('Post-install check: install looks healthy.');
1487
+ for (const adv of advisories)
1488
+ console.log(` ${adv}`);
1489
+ }
1490
+ }
1291
1491
  else if (interactive) {
1292
1492
  // Quiet success — confirm we checked, but don't shout about it.
1293
1493
  p.log.success('Post-install check: install looks healthy.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",