javi-forge 1.28.1 → 1.29.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.
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Pure settings-entry ownership recognition for the SkillGuard Claude
3
+ * PreToolUse guard (Slice 2). Every function takes already-parsed JS values and
4
+ * returns values — ZERO filesystem access (all bytes-on-disk access lives in
5
+ * `claude-hook-manager.ts`). It covers protocol-shape validation, deterministic
6
+ * canonical identity (Decision ②: the live asset SHA token is normalized out
7
+ * before hashing), exact v0 legacy recognition by deep structural equality, and
8
+ * removal/merge PLANNING only. Identity is always recomputed from observed
9
+ * structure; a marker only claims ownership, it never proves it.
10
+ */
11
+ import { ASSET_SHA_PLACEHOLDER, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX } from "./__fixtures__/claude-hook-ownership.js";
12
+ export { ASSET_SHA_PLACEHOLDER, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, };
13
+ /** The nine independent component states (identical for asset and settings). */
14
+ export type ClaudeHookComponentState = "absent" | "managed-current" | "released-outdated" | "exact-legacy" | "edited-managed" | "foreign" | "symlink" | "non-regular" | "malformed";
15
+ /** A released settings-entry identity: version plus placeholder-normalized hash. */
16
+ export interface CanonicalSettingsEntry {
17
+ version: number;
18
+ canonicalSha256: string;
19
+ }
20
+ /** The manifest's settings-entry identity binding. */
21
+ export interface SettingsIdentityManifest {
22
+ current: CanonicalSettingsEntry | null;
23
+ historical: CanonicalSettingsEntry[];
24
+ }
25
+ /** Result of classifying a settings container against the manifest identity. */
26
+ export interface SettingsClassification {
27
+ state: ClaudeHookComponentState;
28
+ version?: number;
29
+ canonicalSha256?: string;
30
+ detail?: string;
31
+ /** Index of the marked group inside `hooks.PreToolUse`. */
32
+ groupIndex?: number;
33
+ /** Index of the marked handler inside the group's `hooks`. */
34
+ handlerIndex?: number;
35
+ }
36
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
37
+ /**
38
+ * A valid settings container is an object; when `hooks` is present it MUST be
39
+ * an object; when `hooks.PreToolUse` is present it MUST be an array. Anything
40
+ * else is malformed.
41
+ */
42
+ export declare function validateSettingsShape(parsed: unknown): boolean;
43
+ /**
44
+ * Replace the trailing 64-hex asset SHA token in a managed `statusMessage` with
45
+ * the fixed placeholder so settings identity is invariant under asset rotation.
46
+ * A non-managed or malformed token is returned unchanged so it hashes to a
47
+ * distinct (non-current) value.
48
+ */
49
+ export declare function normalizeStatusMessage(statusMessage: unknown): string;
50
+ /** Parse the marker version (`…:v1:…` → 1) for diagnostics only. */
51
+ export declare function parseVersionFromStatus(statusMessage: unknown): number | undefined;
52
+ /**
53
+ * Canonicalize the marker-proven handler group into deterministic bytes and its
54
+ * SHA-256. Fixed key order (`type,command,args,timeout,statusMessage` inside a
55
+ * `matcher,hooks` group), the asset-SHA token normalized out, two-space indent
56
+ * plus a trailing newline. Only the single handler participates.
57
+ */
58
+ export declare function canonicalizeSettingsEntry(group: unknown, handler: unknown): {
59
+ serialization: string;
60
+ canonicalSha256: string;
61
+ };
62
+ /**
63
+ * Order-sensitive for arrays, key-set-exact (order-insensitive) for objects,
64
+ * strict for scalars. No substring, normalization, or tolerance.
65
+ */
66
+ export declare function deepStructuralEqual(a: unknown, b: unknown): boolean;
67
+ /**
68
+ * In-object legacy recognition: exactly one deep-equal match for each of the
69
+ * four cohort objects (L1–L3 Pre, L4 Post) is `exact-legacy`; a partial,
70
+ * duplicate, or edited cohort is `foreign` (partial-legacy); unrecognized
71
+ * PreToolUse handler content is `foreign` per spec R2; only a container with no
72
+ * PreToolUse handler content is `absent` (installable). Whole-file SHA legacy is
73
+ * decided by the manager, which holds the raw bytes. (Spec R2 overrides
74
+ * design.md Algorithm C, which returns `absent` for the no-cohort fallthrough
75
+ * and so cannot flag a resembling unmarked handler.)
76
+ */
77
+ export declare function classifyLegacy(parsed: unknown): SettingsClassification;
78
+ /**
79
+ * Reduce a parsed settings container to one component state, from parsed
80
+ * structure only. Shape validation first, then marker-driven identity (always
81
+ * recomputed), then the legacy fallback when no marker is present.
82
+ */
83
+ export declare function classifySettingsEntry(parsed: unknown, _currentAssetSha: string, identities: SettingsIdentityManifest): SettingsClassification;
84
+ export interface ManagedRemovalPlan {
85
+ refused: boolean;
86
+ reason?: string;
87
+ state: ClaudeHookComponentState;
88
+ groupIndex?: number;
89
+ handlerIndex?: number;
90
+ /** True when removing the managed handler empties the group. */
91
+ removeGroup?: boolean;
92
+ /** Sibling handlers in the group that removal must preserve. */
93
+ preservedSiblings?: number;
94
+ }
95
+ /**
96
+ * Plan removal of the marker-proven managed handler. Only a recognized managed
97
+ * identity (`managed-current` or `released-outdated`) is eligible; foreign,
98
+ * partial-legacy, edited-managed, and every non-regular state refuse.
99
+ */
100
+ export declare function planManagedClaudeHookRemoval(parsed: unknown, currentAssetSha: string, identities: SettingsIdentityManifest): ManagedRemovalPlan;
101
+ export interface ManagedMergePlan {
102
+ refused: boolean;
103
+ reason?: string;
104
+ state: ClaudeHookComponentState;
105
+ action: "install" | "replace" | "noop" | "refuse";
106
+ groupIndex?: number;
107
+ handlerIndex?: number;
108
+ preservedSiblings?: number;
109
+ }
110
+ /**
111
+ * Plan the managed merge. `absent` installs a new group, `managed-current` is a
112
+ * no-op, `released-outdated` replaces the marked handler in place (siblings
113
+ * preserved), and every other state refuses (edited/force is a Slice-3 concern).
114
+ */
115
+ export declare function planManagedClaudeHookMerge(parsed: unknown, currentAssetSha: string, identities: SettingsIdentityManifest): ManagedMergePlan;
116
+ //# sourceMappingURL=claude-hook-settings.d.ts.map
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Pure settings-entry ownership recognition for the SkillGuard Claude
3
+ * PreToolUse guard (Slice 2). Every function takes already-parsed JS values and
4
+ * returns values — ZERO filesystem access (all bytes-on-disk access lives in
5
+ * `claude-hook-manager.ts`). It covers protocol-shape validation, deterministic
6
+ * canonical identity (Decision ②: the live asset SHA token is normalized out
7
+ * before hashing), exact v0 legacy recognition by deep structural equality, and
8
+ * removal/merge PLANNING only. Identity is always recomputed from observed
9
+ * structure; a marker only claims ownership, it never proves it.
10
+ */
11
+ import { createHash } from "node:crypto";
12
+ import { ASSET_SHA_PLACEHOLDER, LEGACY_COHORT, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, } from "./__fixtures__/claude-hook-ownership.js";
13
+ export { ASSET_SHA_PLACEHOLDER, LEGACY_FILE_SHA256, MANAGED_ASSET_ARG, MANAGED_MATCHER, MANAGED_STATUS_PREFIX, };
14
+ // Shape helpers
15
+ export function isPlainObject(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ /**
19
+ * A valid settings container is an object; when `hooks` is present it MUST be
20
+ * an object; when `hooks.PreToolUse` is present it MUST be an array. Anything
21
+ * else is malformed.
22
+ */
23
+ export function validateSettingsShape(parsed) {
24
+ if (!isPlainObject(parsed))
25
+ return false;
26
+ const { hooks } = parsed;
27
+ if (hooks !== undefined && !isPlainObject(hooks))
28
+ return false;
29
+ const pre = isPlainObject(hooks) ? hooks.PreToolUse : undefined;
30
+ if (pre !== undefined && !Array.isArray(pre))
31
+ return false;
32
+ return true;
33
+ }
34
+ // Canonical identity (Decision ②)
35
+ const ASSET_SHA_TOKEN = /^[0-9a-f]{64}$/;
36
+ const VERSION_PATTERN = /^javi-forge-global-pretooluse:v(\d+):sha256:/;
37
+ /**
38
+ * Replace the trailing 64-hex asset SHA token in a managed `statusMessage` with
39
+ * the fixed placeholder so settings identity is invariant under asset rotation.
40
+ * A non-managed or malformed token is returned unchanged so it hashes to a
41
+ * distinct (non-current) value.
42
+ */
43
+ export function normalizeStatusMessage(statusMessage) {
44
+ if (typeof statusMessage !== "string")
45
+ return "";
46
+ if (!statusMessage.startsWith(MANAGED_STATUS_PREFIX))
47
+ return statusMessage;
48
+ const token = statusMessage.slice(MANAGED_STATUS_PREFIX.length);
49
+ return ASSET_SHA_TOKEN.test(token)
50
+ ? `${MANAGED_STATUS_PREFIX}${ASSET_SHA_PLACEHOLDER}`
51
+ : statusMessage;
52
+ }
53
+ /** Parse the marker version (`…:v1:…` → 1) for diagnostics only. */
54
+ export function parseVersionFromStatus(statusMessage) {
55
+ if (typeof statusMessage !== "string")
56
+ return undefined;
57
+ const match = VERSION_PATTERN.exec(statusMessage);
58
+ return match ? Number(match[1]) : undefined;
59
+ }
60
+ /**
61
+ * Canonicalize the marker-proven handler group into deterministic bytes and its
62
+ * SHA-256. Fixed key order (`type,command,args,timeout,statusMessage` inside a
63
+ * `matcher,hooks` group), the asset-SHA token normalized out, two-space indent
64
+ * plus a trailing newline. Only the single handler participates.
65
+ */
66
+ export function canonicalizeSettingsEntry(group, handler) {
67
+ const h = isPlainObject(handler) ? handler : {};
68
+ const g = isPlainObject(group) ? group : {};
69
+ const canonicalHandler = {
70
+ type: h.type,
71
+ command: h.command,
72
+ args: h.args,
73
+ timeout: h.timeout,
74
+ statusMessage: normalizeStatusMessage(h.statusMessage),
75
+ };
76
+ const canonicalGroup = { matcher: g.matcher, hooks: [canonicalHandler] };
77
+ const serialization = `${JSON.stringify(canonicalGroup, null, 2)}\n`;
78
+ const canonicalSha256 = createHash("sha256")
79
+ .update(serialization, "utf8")
80
+ .digest("hex");
81
+ return { serialization, canonicalSha256 };
82
+ }
83
+ // Legacy recognition (deep structural equality only)
84
+ /**
85
+ * Order-sensitive for arrays, key-set-exact (order-insensitive) for objects,
86
+ * strict for scalars. No substring, normalization, or tolerance.
87
+ */
88
+ export function deepStructuralEqual(a, b) {
89
+ if (a === b)
90
+ return true;
91
+ if (Array.isArray(a) || Array.isArray(b)) {
92
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
93
+ return false;
94
+ }
95
+ return a.every((item, index) => deepStructuralEqual(item, b[index]));
96
+ }
97
+ if (isPlainObject(a) && isPlainObject(b)) {
98
+ const keysA = Object.keys(a);
99
+ const keysB = Object.keys(b);
100
+ if (keysA.length !== keysB.length)
101
+ return false;
102
+ return keysA.every((key) => Object.hasOwn(b, key) && deepStructuralEqual(a[key], b[key]));
103
+ }
104
+ return false;
105
+ }
106
+ function countDeepMatches(target, list) {
107
+ let count = 0;
108
+ for (const item of list)
109
+ if (deepStructuralEqual(target, item))
110
+ count++;
111
+ return count;
112
+ }
113
+ /**
114
+ * In-object legacy recognition: exactly one deep-equal match for each of the
115
+ * four cohort objects (L1–L3 Pre, L4 Post) is `exact-legacy`; a partial,
116
+ * duplicate, or edited cohort is `foreign` (partial-legacy); unrecognized
117
+ * PreToolUse handler content is `foreign` per spec R2; only a container with no
118
+ * PreToolUse handler content is `absent` (installable). Whole-file SHA legacy is
119
+ * decided by the manager, which holds the raw bytes. (Spec R2 overrides
120
+ * design.md Algorithm C, which returns `absent` for the no-cohort fallthrough
121
+ * and so cannot flag a resembling unmarked handler.)
122
+ */
123
+ export function classifyLegacy(parsed) {
124
+ const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
125
+ const pre = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
126
+ ? hooks.PreToolUse
127
+ : [];
128
+ const post = isPlainObject(hooks) && Array.isArray(hooks.PostToolUse)
129
+ ? hooks.PostToolUse
130
+ : [];
131
+ const counts = [
132
+ countDeepMatches(LEGACY_COHORT.L1, pre),
133
+ countDeepMatches(LEGACY_COHORT.L2, pre),
134
+ countDeepMatches(LEGACY_COHORT.L3, pre),
135
+ countDeepMatches(LEGACY_COHORT.L4, post),
136
+ ];
137
+ if (counts.every((count) => count === 1)) {
138
+ return { state: "exact-legacy", detail: "cohort" };
139
+ }
140
+ if (counts.some((count) => count >= 1)) {
141
+ return { state: "foreign", detail: "partial-legacy" };
142
+ }
143
+ const preHandlers = pre.reduce((total, group) => total +
144
+ (isPlainObject(group) && Array.isArray(group.hooks)
145
+ ? group.hooks.length
146
+ : 0), 0);
147
+ if (preHandlers > 0)
148
+ return { state: "foreign", detail: "no-marker" };
149
+ return { state: "absent" };
150
+ }
151
+ function findManagedMarkers(groups) {
152
+ const markers = [];
153
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
154
+ const group = groups[groupIndex];
155
+ const handlers = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
156
+ for (let handlerIndex = 0; handlerIndex < handlers.length; handlerIndex++) {
157
+ const handler = handlers[handlerIndex];
158
+ if (isPlainObject(handler) &&
159
+ typeof handler.statusMessage === "string" &&
160
+ handler.statusMessage.startsWith(MANAGED_STATUS_PREFIX)) {
161
+ markers.push({ groupIndex, handlerIndex, group, handler });
162
+ }
163
+ }
164
+ }
165
+ return markers;
166
+ }
167
+ function isValidMatcherGroup(group) {
168
+ return (isPlainObject(group) &&
169
+ typeof group.matcher === "string" &&
170
+ Array.isArray(group.hooks));
171
+ }
172
+ /**
173
+ * Reduce a parsed settings container to one component state, from parsed
174
+ * structure only. Shape validation first, then marker-driven identity (always
175
+ * recomputed), then the legacy fallback when no marker is present.
176
+ */
177
+ export function classifySettingsEntry(parsed, _currentAssetSha, identities) {
178
+ if (!validateSettingsShape(parsed))
179
+ return { state: "malformed" };
180
+ const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
181
+ const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
182
+ ? hooks.PreToolUse
183
+ : [];
184
+ const markers = findManagedMarkers(groups);
185
+ if (markers.length > 1) {
186
+ return { state: "edited-managed", detail: "multiple markers" };
187
+ }
188
+ if (markers.length === 0)
189
+ return classifyLegacy(parsed);
190
+ const marker = markers[0];
191
+ if (!isValidMatcherGroup(marker.group)) {
192
+ return { state: "edited-managed", detail: "marker in invalid container" };
193
+ }
194
+ const { canonicalSha256 } = canonicalizeSettingsEntry(marker.group, marker.handler);
195
+ const version = parseVersionFromStatus(marker.handler.statusMessage);
196
+ const base = {
197
+ version,
198
+ canonicalSha256,
199
+ groupIndex: marker.groupIndex,
200
+ handlerIndex: marker.handlerIndex,
201
+ };
202
+ if (identities.current &&
203
+ canonicalSha256 === identities.current.canonicalSha256) {
204
+ return { state: "managed-current", ...base };
205
+ }
206
+ if (identities.historical.some((entry) => entry.canonicalSha256 === canonicalSha256)) {
207
+ return { state: "released-outdated", ...base };
208
+ }
209
+ return { state: "edited-managed", ...base };
210
+ }
211
+ function groupHandlerCount(parsed, groupIndex) {
212
+ const hooks = isPlainObject(parsed) ? parsed.hooks : undefined;
213
+ const groups = isPlainObject(hooks) && Array.isArray(hooks.PreToolUse)
214
+ ? hooks.PreToolUse
215
+ : [];
216
+ const group = groups[groupIndex];
217
+ return isPlainObject(group) && Array.isArray(group.hooks)
218
+ ? group.hooks.length
219
+ : 0;
220
+ }
221
+ /**
222
+ * Plan removal of the marker-proven managed handler. Only a recognized managed
223
+ * identity (`managed-current` or `released-outdated`) is eligible; foreign,
224
+ * partial-legacy, edited-managed, and every non-regular state refuse.
225
+ */
226
+ export function planManagedClaudeHookRemoval(parsed, currentAssetSha, identities) {
227
+ const cls = classifySettingsEntry(parsed, currentAssetSha, identities);
228
+ if (cls.state === "managed-current" || cls.state === "released-outdated") {
229
+ const handlerCount = groupHandlerCount(parsed, cls.groupIndex ?? -1);
230
+ const preservedSiblings = Math.max(handlerCount - 1, 0);
231
+ return {
232
+ refused: false,
233
+ state: cls.state,
234
+ groupIndex: cls.groupIndex,
235
+ handlerIndex: cls.handlerIndex,
236
+ removeGroup: preservedSiblings === 0,
237
+ preservedSiblings,
238
+ };
239
+ }
240
+ return {
241
+ refused: true,
242
+ reason: `refuse removal for state ${cls.state}`,
243
+ state: cls.state,
244
+ };
245
+ }
246
+ /**
247
+ * Plan the managed merge. `absent` installs a new group, `managed-current` is a
248
+ * no-op, `released-outdated` replaces the marked handler in place (siblings
249
+ * preserved), and every other state refuses (edited/force is a Slice-3 concern).
250
+ */
251
+ export function planManagedClaudeHookMerge(parsed, currentAssetSha, identities) {
252
+ const cls = classifySettingsEntry(parsed, currentAssetSha, identities);
253
+ if (cls.state === "absent") {
254
+ return { refused: false, state: cls.state, action: "install" };
255
+ }
256
+ if (cls.state === "managed-current") {
257
+ return {
258
+ refused: false,
259
+ state: cls.state,
260
+ action: "noop",
261
+ groupIndex: cls.groupIndex,
262
+ handlerIndex: cls.handlerIndex,
263
+ };
264
+ }
265
+ if (cls.state === "released-outdated") {
266
+ const preservedSiblings = Math.max(groupHandlerCount(parsed, cls.groupIndex ?? -1) - 1, 0);
267
+ return {
268
+ refused: false,
269
+ state: cls.state,
270
+ action: "replace",
271
+ groupIndex: cls.groupIndex,
272
+ handlerIndex: cls.handlerIndex,
273
+ preservedSiblings,
274
+ };
275
+ }
276
+ return {
277
+ refused: true,
278
+ reason: `refuse merge for state ${cls.state}`,
279
+ state: cls.state,
280
+ action: "refuse",
281
+ };
282
+ }
283
+ //# sourceMappingURL=claude-hook-settings.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.28.1",
3
+ "version": "1.29.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {