javi-forge 1.25.1 → 1.27.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 (49) hide show
  1. package/ci-local/hooks/commit-msg +7 -0
  2. package/ci-local/hooks/pre-commit +8 -0
  3. package/ci-local/hooks/pre-push +8 -0
  4. package/dist/cli/dispatch/ci.js +1 -1
  5. package/dist/cli/dispatch/skills-cmd.js +8 -0
  6. package/dist/commands/ci.js +19 -5
  7. package/dist/commands/doctor.d.ts +8 -0
  8. package/dist/commands/doctor.js +121 -0
  9. package/dist/commands/hooks/sections/deps.d.ts +28 -0
  10. package/dist/commands/hooks/sections/deps.js +126 -0
  11. package/dist/commands/hooks/sections/permissions.d.ts +33 -0
  12. package/dist/commands/hooks/sections/permissions.js +101 -0
  13. package/dist/commands/hooks/sections/secrets.d.ts +58 -0
  14. package/dist/commands/hooks/sections/secrets.js +182 -0
  15. package/dist/commands/hooks.d.ts +4 -4
  16. package/dist/commands/hooks.js +13 -4
  17. package/dist/commands/init/steps/ghagga.d.ts +3 -4
  18. package/dist/commands/init/steps/ghagga.js +5 -15
  19. package/dist/commands/init/steps/security.d.ts +10 -20
  20. package/dist/commands/init/steps/security.js +58 -78
  21. package/dist/commands/init.js +1 -2
  22. package/dist/commands/skills/analysis.js +31 -2
  23. package/dist/commands/skills/benchmark.js +10 -0
  24. package/dist/commands/skills/constants.d.ts +5 -0
  25. package/dist/commands/skills/constants.js +5 -0
  26. package/dist/commands/skills/parsing.d.ts +18 -3
  27. package/dist/commands/skills/parsing.js +29 -3
  28. package/dist/commands/skills/scoring.d.ts +7 -6
  29. package/dist/commands/skills/scoring.js +31 -1
  30. package/dist/constants.d.ts +6 -1
  31. package/dist/constants.js +12 -7
  32. package/dist/lib/context.d.ts +22 -0
  33. package/dist/lib/context.js +120 -79
  34. package/dist/lib/safe-read.d.ts +62 -0
  35. package/dist/lib/safe-read.js +221 -0
  36. package/dist/lib/security-analysis.d.ts +19 -2
  37. package/dist/lib/security-analysis.js +65 -13
  38. package/dist/lib/skill-scanner.d.ts +22 -1
  39. package/dist/lib/skill-scanner.js +76 -4
  40. package/dist/types/index.d.ts +18 -0
  41. package/dist/ui/Skills.js +12 -7
  42. package/package.json +1 -1
  43. package/templates/github/ghagga-review.yml +0 -30
  44. package/templates/security-hooks/commit-msg-signing +0 -29
  45. package/templates/security-hooks/pre-commit-permissions +0 -74
  46. package/templates/security-hooks/pre-commit-secrets +0 -74
  47. package/templates/security-hooks/pre-push-branch-protection +0 -62
  48. package/templates/security-hooks/pre-push-deps +0 -83
  49. package/templates/security-hooks/pre-push-signing +0 -67
@@ -0,0 +1,182 @@
1
+ /**
2
+ * L1 secret-scan section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-commit-secrets` into TypeScript. The
5
+ * bash body built the staged-file list with `git diff --cached --name-only`
6
+ * and then piped it through `xargs git diff` (pre-commit-secrets:52) — a
7
+ * whitespace split that silently dropped any path containing a space and
8
+ * swallowed the git error with `|| true` (K-005). This port builds the staged
9
+ * list NUL-safe (`-z` → split on `"\0"`) and passes each filename as a SEPARATE
10
+ * argv element to `git diff` (no shell, no xargs), so a path like
11
+ * `app secrets.env` is scanned as ONE path and can never be split.
12
+ *
13
+ * A matched pattern in an ADDED diff line blocks the commit (ok:false).
14
+ */
15
+ import { execFileAsync } from "../../../lib/exec.js";
16
+ /**
17
+ * Ported from `templates/security-hooks/pre-commit-secrets` SECRET_PATTERNS.
18
+ * bash `(?i)` → the JS `/i` flag; bash `\x27` (a single quote) → a literal `'`.
19
+ * No `/g` flag is used, so `.test()` never carries `lastIndex` state between
20
+ * lines. Order is preserved from the bash array.
21
+ */
22
+ const SECRET_PATTERNS = [
23
+ // AWS access key id
24
+ { name: "aws-access-key", re: /AKIA[0-9A-Z]{16}/ },
25
+ // Generic API keys / tokens assigned to key-like vars
26
+ {
27
+ name: "generic-api-key",
28
+ re: /(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*["'][A-Za-z0-9+/=_-]{20,}["']/i,
29
+ },
30
+ // Private key headers
31
+ {
32
+ name: "private-key",
33
+ re: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
34
+ },
35
+ // GitHub tokens
36
+ { name: "github-token", re: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
37
+ // Generic password assignments
38
+ {
39
+ name: "password-assignment",
40
+ re: /(password|passwd|pwd)\s*[:=]\s*["'][^\s"']{8,}["']/i,
41
+ },
42
+ // Slack tokens
43
+ { name: "slack-token", re: /xox[baprs]-[0-9a-zA-Z-]+/ },
44
+ // Stripe keys
45
+ { name: "stripe-secret-key", re: /sk_live_[0-9a-zA-Z]{24,}/ },
46
+ { name: "stripe-restricted-key", re: /rk_live_[0-9a-zA-Z]{24,}/ },
47
+ // SendGrid
48
+ {
49
+ name: "sendgrid-key",
50
+ re: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/,
51
+ },
52
+ ];
53
+ /** ARG_MAX guard: cap the number of file paths per `git diff` argv batch. */
54
+ export const DEFAULT_CHUNK_SIZE = 512;
55
+ /**
56
+ * maxBuffer for the content `git diff` read. Node's default is 1 MiB; a staged
57
+ * diff larger than that rejects and (caught) blocks every large legitimate
58
+ * commit with a cryptic buffer error. Raise the ceiling to a generous but
59
+ * BOUNDED 64 MiB so ordinary large commits scan, while a genuinely pathological
60
+ * diff still fails closed instead of exhausting memory.
61
+ */
62
+ export const DIFF_MAX_BUFFER = 64 * 1024 * 1024;
63
+ function defaultDeps() {
64
+ return {
65
+ execFile: async (cmd, args, opts) => {
66
+ const { stdout, stderr } = await execFileAsync(cmd, args, opts);
67
+ return { stdout: String(stdout), stderr: String(stderr) };
68
+ },
69
+ log: (m) => console.log(m),
70
+ chunkSize: DEFAULT_CHUNK_SIZE,
71
+ };
72
+ }
73
+ /** NUL-safe staged-file list (`-z` → split on "\0", drop the trailing empty). */
74
+ async function stagedFiles(deps, projectDir) {
75
+ const { stdout } = await deps.execFile("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"], { cwd: projectDir });
76
+ return stdout.split("\0").filter((f) => f.length > 0);
77
+ }
78
+ function chunk(items, size) {
79
+ const out = [];
80
+ for (let i = 0; i < items.length; i += size)
81
+ out.push(items.slice(i, i + size));
82
+ return out;
83
+ }
84
+ /**
85
+ * Walk a unified diff and report every ADDED line (`+`, never `+++`) that
86
+ * matches a secret pattern, tracking the new-file path and line number so a
87
+ * finding reads `path:line pattern`.
88
+ */
89
+ export function scanDiff(diff) {
90
+ const findings = [];
91
+ let currentFile = "";
92
+ let newLine = 0;
93
+ for (const line of diff.split("\n")) {
94
+ if (line.startsWith("+++ ")) {
95
+ const p = line.slice(4).trim();
96
+ currentFile = p === "/dev/null" ? "" : p.replace(/^b\//, "");
97
+ continue;
98
+ }
99
+ if (line.startsWith("--- "))
100
+ continue;
101
+ const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
102
+ if (hunk) {
103
+ newLine = Number.parseInt(hunk[1], 10);
104
+ continue;
105
+ }
106
+ if (line.startsWith("diff --git") ||
107
+ line.startsWith("index ") ||
108
+ line.startsWith("old mode") ||
109
+ line.startsWith("new mode") ||
110
+ line.startsWith("similarity ") ||
111
+ line.startsWith("rename ") ||
112
+ line.startsWith("new file") ||
113
+ line.startsWith("deleted file")) {
114
+ continue;
115
+ }
116
+ if (line.startsWith("+")) {
117
+ const content = line.slice(1);
118
+ for (const { name, re } of SECRET_PATTERNS) {
119
+ if (re.test(content)) {
120
+ findings.push({ file: currentFile, line: newLine, pattern: name });
121
+ }
122
+ }
123
+ newLine++;
124
+ }
125
+ else if (line.startsWith("-")) {
126
+ // removed line — does not advance the new-file line counter
127
+ }
128
+ else {
129
+ // context (leading space) or blank line
130
+ newLine++;
131
+ }
132
+ }
133
+ return findings;
134
+ }
135
+ /**
136
+ * The `secrets` section factory. Injectable seams default to the real
137
+ * `git`-via-execFileAsync implementation; tests pass mocks. A thrown git error
138
+ * becomes a blocking `{ ok: false }` (never an unhandled rejection), matching
139
+ * the ciSection/tddSection hardening.
140
+ */
141
+ export function secretsSection(overrides = {}) {
142
+ const deps = { ...defaultDeps(), ...overrides };
143
+ return {
144
+ id: "secrets",
145
+ blocking: true,
146
+ async run({ projectDir }) {
147
+ try {
148
+ const files = await stagedFiles(deps, projectDir);
149
+ if (files.length === 0)
150
+ return { ok: true };
151
+ const findings = [];
152
+ for (const batch of chunk(files, deps.chunkSize)) {
153
+ // `--no-color` is MANDATORY: under `color.ui=always` (or
154
+ // `color.diff=always`) git emits ANSI escapes even to a pipe, so
155
+ // added lines render as `\x1b[32m+secret\x1b[m` and both the
156
+ // `+`/`+++ ` line checks in scanDiff fail — the scanner would then
157
+ // find zero secrets and fail OPEN. Forcing color off keeps the diff
158
+ // plain-text and the scan deterministic.
159
+ const { stdout } = await deps.execFile("git", ["diff", "--no-color", "--cached", "--", ...batch], { cwd: projectDir, maxBuffer: DIFF_MAX_BUFFER });
160
+ findings.push(...scanDiff(stdout));
161
+ }
162
+ if (findings.length === 0)
163
+ return { ok: true };
164
+ const summary = findings
165
+ .slice(0, 10)
166
+ .map((f) => `${f.file || "<staged>"}:${f.line} ${f.pattern}`)
167
+ .join("; ");
168
+ return {
169
+ ok: false,
170
+ detail: `${findings.length} potential secret(s): ${summary}`,
171
+ };
172
+ }
173
+ catch (e) {
174
+ return {
175
+ ok: false,
176
+ detail: e instanceof Error ? e.message : String(e),
177
+ };
178
+ }
179
+ },
180
+ };
181
+ }
182
+ //# sourceMappingURL=secrets.js.map
@@ -10,10 +10,10 @@
10
10
  * behavior); a config that FAILS to validate exits 1 — a broken config never
11
11
  * silently skips a gate.
12
12
  *
13
- * S1a/S3 scope: the `ci` and `tdd` sections have bodies. The
14
- * secrets/permissions/deps sections have no factory yet (they land in S4); an
15
- * enabled-but-unregistered feature is skipped by `composeSections` until its
16
- * slice wires a factory.
13
+ * S1a/S3/S4 scope: the `ci`, `tdd`, `secrets`, `permissions` and `deps`
14
+ * sections all have factories now. `composeSections` still skips any enabled
15
+ * feature with no registered factory, so a future feature can land the same
16
+ * way.
17
17
  */
18
18
  import { type CIHooksConfig } from "../lib/ci-config.js";
19
19
  import type { Stack } from "../types/index.js";
@@ -10,14 +10,17 @@
10
10
  * behavior); a config that FAILS to validate exits 1 — a broken config never
11
11
  * silently skips a gate.
12
12
  *
13
- * S1a/S3 scope: the `ci` and `tdd` sections have bodies. The
14
- * secrets/permissions/deps sections have no factory yet (they land in S4); an
15
- * enabled-but-unregistered feature is skipped by `composeSections` until its
16
- * slice wires a factory.
13
+ * S1a/S3/S4 scope: the `ci`, `tdd`, `secrets`, `permissions` and `deps`
14
+ * sections all have factories now. `composeSections` still skips any enabled
15
+ * feature with no registered factory, so a future feature can land the same
16
+ * way.
17
17
  */
18
18
  import { spawn } from "node:child_process";
19
19
  import { findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
20
20
  import { detectCIStack, runCI } from "./ci.js";
21
+ import { depsSection } from "./hooks/sections/deps.js";
22
+ import { permissionsSection } from "./hooks/sections/permissions.js";
23
+ import { secretsSection } from "./hooks/sections/secrets.js";
21
24
  import { getTddTestCommand } from "./tdd.js";
22
25
  /** Fixed cheap→expensive order per hook (deterministic is a feature). */
23
26
  const PRE_COMMIT_ORDER = ["secrets", "permissions", "tdd", "ci"];
@@ -193,6 +196,12 @@ export function defaultRegistry(runCIImpl, log, tddDeps = {}) {
193
196
  runCommand: tddDeps.runCommand ?? runTestCommand,
194
197
  log: tddDeps.log ?? log,
195
198
  }),
199
+ // S4 security sections. Each resolves its git/fs/exec seams at run time
200
+ // via the section's own defaults; unit tests inject mocks by importing
201
+ // the factory directly (see src/commands/hooks/sections/*.test.ts).
202
+ secrets: () => secretsSection({ log }),
203
+ permissions: () => permissionsSection({ log }),
204
+ deps: () => depsSection({ log }),
196
205
  };
197
206
  }
198
207
  /** Resolve the parsed `hooks:` config for a project (null → default [ci]). */
@@ -1,15 +1,14 @@
1
1
  import type { StepFn } from "../types.js";
2
2
  /**
3
- * Step 9: Install GHAGGA review system.
3
+ * Step 9: Install the local GHAGGA review module.
4
4
  *
5
5
  * - When ghagga is false, reports "skipped".
6
6
  * - Copies <MODULES_DIR>/ghagga → <project>/.javi-forge/modules/ghagga (no overwrite).
7
- * - For GitHub provider, also copies the ghagga-review.yml caller workflow into
8
- * <project>/.github/workflows/.
9
7
  * - If module source dir is missing, reports "error" with "module not found".
10
8
  * - Errors are swallowed and reported as status:"error" — never thrown.
11
9
  *
12
- * Extracted VERBATIM from src/commands/init.ts (PR 3 of 6).
10
+ * The GitHub Action review workflow is intentionally NOT scaffolded — ghagga
11
+ * runs locally/self-hosted, not as a GitHub Action.
13
12
  */
14
13
  export declare const stepGhagga: StepFn;
15
14
  //# sourceMappingURL=ghagga.d.ts.map
@@ -1,23 +1,22 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
- import { FORGE_ROOT, MODULES_DIR } from "../../../constants.js";
3
+ import { MODULES_DIR } from "../../../constants.js";
4
4
  import { ensureDirExists } from "../../../lib/common.js";
5
5
  import { report } from "../report.js";
6
6
  /**
7
- * Step 9: Install GHAGGA review system.
7
+ * Step 9: Install the local GHAGGA review module.
8
8
  *
9
9
  * - When ghagga is false, reports "skipped".
10
10
  * - Copies <MODULES_DIR>/ghagga → <project>/.javi-forge/modules/ghagga (no overwrite).
11
- * - For GitHub provider, also copies the ghagga-review.yml caller workflow into
12
- * <project>/.github/workflows/.
13
11
  * - If module source dir is missing, reports "error" with "module not found".
14
12
  * - Errors are swallowed and reported as status:"error" — never thrown.
15
13
  *
16
- * Extracted VERBATIM from src/commands/init.ts (PR 3 of 6).
14
+ * The GitHub Action review workflow is intentionally NOT scaffolded — ghagga
15
+ * runs locally/self-hosted, not as a GitHub Action.
17
16
  */
18
17
  export const stepGhagga = async (ctx) => {
19
18
  const { projectDir, dryRun, onStep, options } = ctx;
20
- const { ghagga, ciProvider } = options;
19
+ const { ghagga } = options;
21
20
  const stepId = "ghagga";
22
21
  report(onStep, stepId, "Install GHAGGA review system", "running");
23
22
  try {
@@ -31,15 +30,6 @@ export const stepGhagga = async (ctx) => {
31
30
  overwrite: false,
32
31
  errorOnExist: false,
33
32
  });
34
- // Copy ghagga caller workflow to CI provider location
35
- if (ciProvider === "github") {
36
- const workflowSrc = path.join(FORGE_ROOT, "templates", "github", "ghagga-review.yml");
37
- if (await fs.pathExists(workflowSrc)) {
38
- const workflowDest = path.join(projectDir, ".github", "workflows", "ghagga-review.yml");
39
- await ensureDirExists(path.dirname(workflowDest));
40
- await fs.copy(workflowSrc, workflowDest, { overwrite: false });
41
- }
42
- }
43
33
  }
44
34
  report(onStep, stepId, "Install GHAGGA review system", "done");
45
35
  }
@@ -1,28 +1,18 @@
1
1
  import type { StepFn } from "../types.js";
2
2
  /**
3
- * Step 14: Scaffold security hooks.
3
+ * Step 14: Scaffold security hooks (hook-consolidation S4 fold).
4
4
  *
5
5
  * - When options.securityHooks is false, reports "skipped".
6
- * - When SECURITY_HOOKS_DIR templates are missing, reports "error".
7
- * - Otherwise copies the 6-layer git security hooks into
8
- * ci-local/hooks/security/ (skipping existing files, chmod 0755) and copies
9
- * the kiteguard-style runtime settings to .claude/settings.json when absent.
6
+ * - Otherwise:
7
+ * 1. Copies the kiteguard-style runtime settings to `.claude/settings.json`
8
+ * when absent (KEPT — this is a real feature).
9
+ * 2. Merges the `hooks:` security sections for the selected reliability
10
+ * profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
11
+ * minimal `version: 2` config when absent). The dispatcher composes these
12
+ * sections at hook-run time (see src/commands/hooks.ts).
13
+ * - The old inert `ci-local/hooks/security/` git-hook copy is GONE (those hook
14
+ * bodies were ported to TypeScript sections in S4).
10
15
  * - Errors are swallowed and reported as status:"error" — never thrown.
11
- *
12
- * Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
13
- * Grouped with stepHookProfile for cohesion — both manage ci-local/hooks/.
14
16
  */
15
17
  export declare const stepSecurityHooks: StepFn;
16
- /**
17
- * Step 14b: Write hook reliability profile.
18
- *
19
- * - When options.securityHooks is false, reports "skipped".
20
- * - Otherwise writes ci-local/hooks/profile.json with the resolved profile
21
- * (defaults to "standard" when hookProfile is undefined).
22
- * - Errors are swallowed and reported as status:"error" — never thrown.
23
- *
24
- * Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
25
- * Grouped with stepSecurityHooks for cohesion — both manage ci-local/hooks/.
26
- */
27
- export declare const stepHookProfile: StepFn;
28
18
  //# sourceMappingURL=security.d.ts.map
@@ -1,103 +1,83 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { SECURITY_HOOKS_DIR } from "../../../constants.js";
4
+ import { setHookFeature } from "../../../lib/ci-config.js";
4
5
  import { ensureDirExists } from "../../../lib/common.js";
5
6
  import { report } from "../report.js";
6
7
  /**
7
- * Step 14: Scaffold security hooks.
8
+ * Hook-feature preset per reliability profile (hook-consolidation S4).
8
9
  *
9
- * - When options.securityHooks is false, reports "skipped".
10
- * - When SECURITY_HOOKS_DIR templates are missing, reports "error".
11
- * - Otherwise copies the 6-layer git security hooks into
12
- * ci-local/hooks/security/ (skipping existing files, chmod 0755) and copies
13
- * the kiteguard-style runtime settings to .claude/settings.json when absent.
14
- * - Errors are swallowed and reported as status:"error" — never thrown.
15
- *
16
- * Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
17
- * Grouped with stepHookProfile for cohesion — both manage ci-local/hooks/.
10
+ * The old `stepHookProfile` wrote a `ci-local/hooks/profile.json` that had ZERO
11
+ * runtime readers (design D7). The selector is repurposed: the chosen profile
12
+ * now drives WHICH `hooks:` security sections get merged into
13
+ * `.javi-forge/ci.yaml`, using the EXISTING `HookProfile` values (no "relaxed"):
14
+ * - strict → secrets + permissions + deps (every section)
15
+ * - standard → secrets + deps
16
+ * - minimal → CI gate only (no security sections)
18
17
  */
19
- export const stepSecurityHooks = async (ctx) => {
20
- const { projectDir, dryRun, onStep, options } = ctx;
21
- const { securityHooks } = options;
22
- const stepId = "security-hooks";
23
- report(onStep, stepId, "Scaffold security hooks", "running");
24
- try {
25
- if (securityHooks) {
26
- if (await fs.pathExists(SECURITY_HOOKS_DIR)) {
27
- if (!dryRun) {
28
- // Copy 6-layer git security hooks into ci-local/hooks/security/
29
- const secHooksDest = path.join(projectDir, "ci-local", "hooks", "security");
30
- await ensureDirExists(secHooksDest);
31
- const hookFiles = await fs.readdir(SECURITY_HOOKS_DIR);
32
- const gitHooks = hookFiles.filter((f) => !f.endsWith(".json"));
33
- for (const hook of gitHooks) {
34
- const src = path.join(SECURITY_HOOKS_DIR, hook);
35
- const dest = path.join(secHooksDest, hook);
36
- await fs.copy(src, dest, { overwrite: false });
37
- await fs.chmod(dest, 0o755);
38
- }
39
- // Copy runtime security settings (kiteguard-style) to .claude/
40
- const settingsSrc = path.join(SECURITY_HOOKS_DIR, "claude-settings-security.json");
41
- if (await fs.pathExists(settingsSrc)) {
42
- const claudeDir = path.join(projectDir, ".claude");
43
- await ensureDirExists(claudeDir);
44
- const settingsDest = path.join(claudeDir, "settings.json");
45
- if (!(await fs.pathExists(settingsDest))) {
46
- await fs.copy(settingsSrc, settingsDest);
47
- }
48
- }
49
- }
50
- report(onStep, stepId, "Scaffold security hooks", "done", dryRun
51
- ? "dry-run: would scaffold security hooks"
52
- : "6 git layers + runtime hooks");
53
- }
54
- else {
55
- report(onStep, stepId, "Scaffold security hooks", "error", "security-hooks templates not found");
56
- }
57
- }
58
- else {
59
- report(onStep, stepId, "Scaffold security hooks", "skipped", "not selected");
60
- }
61
- }
62
- catch (e) {
63
- report(onStep, stepId, "Scaffold security hooks", "error", String(e));
64
- }
18
+ const PROFILE_PRESET = {
19
+ minimal: { preCommit: [], prePush: [] },
20
+ standard: { preCommit: ["secrets"], prePush: ["deps"] },
21
+ strict: { preCommit: ["secrets", "permissions"], prePush: ["deps"] },
65
22
  };
66
23
  /**
67
- * Step 14b: Write hook reliability profile.
24
+ * Step 14: Scaffold security hooks (hook-consolidation S4 fold).
68
25
  *
69
26
  * - When options.securityHooks is false, reports "skipped".
70
- * - Otherwise writes ci-local/hooks/profile.json with the resolved profile
71
- * (defaults to "standard" when hookProfile is undefined).
27
+ * - Otherwise:
28
+ * 1. Copies the kiteguard-style runtime settings to `.claude/settings.json`
29
+ * when absent (KEPT — this is a real feature).
30
+ * 2. Merges the `hooks:` security sections for the selected reliability
31
+ * profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
32
+ * minimal `version: 2` config when absent). The dispatcher composes these
33
+ * sections at hook-run time (see src/commands/hooks.ts).
34
+ * - The old inert `ci-local/hooks/security/` git-hook copy is GONE (those hook
35
+ * bodies were ported to TypeScript sections in S4).
72
36
  * - Errors are swallowed and reported as status:"error" — never thrown.
73
- *
74
- * Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
75
- * Grouped with stepSecurityHooks for cohesion — both manage ci-local/hooks/.
76
37
  */
77
- export const stepHookProfile = async (ctx) => {
38
+ export const stepSecurityHooks = async (ctx) => {
78
39
  const { projectDir, dryRun, onStep, options } = ctx;
79
40
  const { securityHooks, hookProfile } = options;
80
- const stepId = "hook-profile";
81
- report(onStep, stepId, "Write hook reliability profile", "running");
41
+ const stepId = "security-hooks";
42
+ report(onStep, stepId, "Scaffold security hooks", "running");
82
43
  try {
83
- if (securityHooks) {
84
- if (!dryRun) {
85
- const hooksDir = path.join(projectDir, "ci-local", "hooks");
86
- await ensureDirExists(hooksDir);
87
- const profilePath = path.join(hooksDir, "profile.json");
88
- const resolvedProfile = hookProfile ?? "standard";
89
- await fs.writeJson(profilePath, { profile: resolvedProfile }, { spaces: 2 });
44
+ if (!securityHooks) {
45
+ report(onStep, stepId, "Scaffold security hooks", "skipped", "not selected");
46
+ return;
47
+ }
48
+ const profile = hookProfile ?? "standard";
49
+ const preset = PROFILE_PRESET[profile];
50
+ if (dryRun) {
51
+ report(onStep, stepId, "Scaffold security hooks", "done", `dry-run: would merge ${profile} hooks preset + copy .claude/settings.json`);
52
+ return;
53
+ }
54
+ // 1. Copy the kiteguard-style runtime security settings to .claude/.
55
+ const settingsSrc = path.join(SECURITY_HOOKS_DIR, "claude-settings-security.json");
56
+ if (await fs.pathExists(settingsSrc)) {
57
+ const claudeDir = path.join(projectDir, ".claude");
58
+ await ensureDirExists(claudeDir);
59
+ const settingsDest = path.join(claudeDir, "settings.json");
60
+ if (!(await fs.pathExists(settingsDest))) {
61
+ await fs.copy(settingsSrc, settingsDest);
90
62
  }
91
- report(onStep, stepId, "Write hook reliability profile", "done", dryRun
92
- ? `dry-run: would write profile.json (${hookProfile ?? "standard"})`
93
- : `ci-local/hooks/profile.json (${hookProfile ?? "standard"})`);
94
63
  }
95
- else {
96
- report(onStep, stepId, "Write hook reliability profile", "skipped", "security hooks not selected");
64
+ // 2. Merge the profile's security sections into .javi-forge/ci.yaml.
65
+ for (const feature of preset.preCommit) {
66
+ await setHookFeature(projectDir, "pre-commit", feature, true);
67
+ }
68
+ for (const feature of preset.prePush) {
69
+ await setHookFeature(projectDir, "pre-push", feature, true);
97
70
  }
71
+ const merged = [
72
+ ...preset.preCommit.map((f) => `pre-commit.${f}`),
73
+ ...preset.prePush.map((f) => `pre-push.${f}`),
74
+ ];
75
+ report(onStep, stepId, "Scaffold security hooks", "done", merged.length > 0
76
+ ? `${profile} preset: ${merged.join(", ")}`
77
+ : `${profile} preset: CI gate only (no security sections)`);
98
78
  }
99
79
  catch (e) {
100
- report(onStep, stepId, "Write hook reliability profile", "error", String(e));
80
+ report(onStep, stepId, "Scaffold security hooks", "error", String(e));
101
81
  }
102
82
  };
103
83
  //# sourceMappingURL=security.js.map
@@ -14,7 +14,7 @@ import { stepManifest } from "./init/steps/manifest.js";
14
14
  import { stepMemory } from "./init/steps/memory.js";
15
15
  import { stepMock } from "./init/steps/mock.js";
16
16
  import { stepSDD } from "./init/steps/sdd.js";
17
- import { stepHookProfile, stepSecurityHooks } from "./init/steps/security.js";
17
+ import { stepSecurityHooks } from "./init/steps/security.js";
18
18
  /**
19
19
  * Main init orchestrator: bootstraps a project with CI, git hooks,
20
20
  * memory module, AI config sync, SDD, ghagga, and friends.
@@ -43,7 +43,6 @@ export async function initProject(options, onStep) {
43
43
  await stepClaudeMd(ctx);
44
44
  await stepDockerDeploy(ctx);
45
45
  await stepSecurityHooks(ctx);
46
- await stepHookProfile(ctx);
47
46
  await stepCodeGraph(ctx);
48
47
  await stepLocalAi(ctx);
49
48
  await stepAgentSkills(ctx);
@@ -1,4 +1,5 @@
1
- import { CONTRADICTION_PAIRS, DEFAULT_BUDGET } from "./constants.js";
1
+ import fs from "fs-extra";
2
+ import { CHARS_PER_TOKEN, CONTRADICTION_PAIRS, DEFAULT_BUDGET, } from "./constants.js";
2
3
  import { detectDirectiveClash } from "./directives.js";
3
4
  import { discoverSkills, estimateTokens, parseSkillFile } from "./parsing.js";
4
5
  // ── Budget Optimization ─────────────────────────────────────────────────────
@@ -98,6 +99,12 @@ export async function findConflicts(skillsDir) {
98
99
  const parsed = await parseSkillFile(sp);
99
100
  if (!parsed)
100
101
  continue;
102
+ // A skipped file has no readable rules to compare. Don't treat its empty
103
+ // rule list as "no conflicts" — it is surfaced as a skip in the budget
104
+ // section (calculateBudget always runs alongside conflict detection), so
105
+ // skip it here explicitly rather than letting it pass silently.
106
+ if (parsed.skip)
107
+ continue;
101
108
  for (const rule of parsed.rules) {
102
109
  allRules.push({
103
110
  skillName: parsed.name,
@@ -138,10 +145,32 @@ export async function calculateBudget(skillsDir, budget = DEFAULT_BUDGET) {
138
145
  const parsed = await parseSkillFile(sp);
139
146
  if (!parsed)
140
147
  continue;
148
+ // Token count reflects the bytes actually kept by the guarded read, so a
149
+ // skipped or truncated skill is reported rather than counted as normal.
150
+ const note = parsed.skip
151
+ ? `skipped: ${parsed.skip.message}`
152
+ : parsed.truncated
153
+ ? "truncated: read budget reached"
154
+ : undefined;
155
+ // A skipped skill has empty `rawContent`, so token estimation from it would
156
+ // be 0 and the file would sort last — the budget tool would hide the very
157
+ // oversized file it exists to flag. Fall back to the real on-disk byte size
158
+ // so an unread skill still counts toward the total and over-budget check.
159
+ let tokens = estimateTokens(parsed.rawContent);
160
+ if (parsed.skip) {
161
+ try {
162
+ const { size } = await fs.stat(sp);
163
+ tokens = Math.ceil(size / CHARS_PER_TOKEN);
164
+ }
165
+ catch {
166
+ // Vanished between discovery and stat — leave 0; the note still flags it.
167
+ }
168
+ }
141
169
  entries.push({
142
170
  skillName: parsed.name,
143
171
  skillPath: sp,
144
- tokens: estimateTokens(parsed.rawContent),
172
+ tokens,
173
+ ...(note ? { note } : {}),
145
174
  });
146
175
  }
147
176
  // Sort by token count descending (biggest consumers first)
@@ -9,6 +9,16 @@ export async function benchmarkSkill(skillPath) {
9
9
  const parsed = await parseSkillFile(skillPath);
10
10
  if (!parsed)
11
11
  return null;
12
+ // A file that could not be read has no structure to benchmark. Reporting it
13
+ // as a skill that failed every check is misleading — mark it unread instead.
14
+ if (parsed.skip) {
15
+ return {
16
+ skillName: parsed.name,
17
+ checks: [],
18
+ passRate: 0,
19
+ unread: parsed.skip.message,
20
+ };
21
+ }
12
22
  const checks = [];
13
23
  // Check 1: Has YAML frontmatter with name
14
24
  checks.push({
@@ -6,5 +6,10 @@ export declare const CHARS_PER_TOKEN = 4;
6
6
  export declare const DEFAULT_THRESHOLD = 50;
7
7
  /** Default registry quality threshold */
8
8
  export declare const DEFAULT_REGISTRY_THRESHOLD = 60;
9
+ /**
10
+ * Hard ceiling for a SKILL.md. A skill file past this is not a skill — it is a
11
+ * dumped log or a vendored bundle — so it is skipped instead of truncated.
12
+ */
13
+ export declare const MAX_SKILL_BYTES: number;
9
14
  export declare const CONTRADICTION_PAIRS: [RegExp, RegExp][];
10
15
  //# sourceMappingURL=constants.d.ts.map
@@ -8,6 +8,11 @@ export const CHARS_PER_TOKEN = 4;
8
8
  export const DEFAULT_THRESHOLD = 50;
9
9
  /** Default registry quality threshold */
10
10
  export const DEFAULT_REGISTRY_THRESHOLD = 60;
11
+ /**
12
+ * Hard ceiling for a SKILL.md. A skill file past this is not a skill — it is a
13
+ * dumped log or a vendored bundle — so it is skipped instead of truncated.
14
+ */
15
+ export const MAX_SKILL_BYTES = 1024 * 1024;
11
16
  // ── Contradiction keywords (pairs that signal opposite intent) ───────────────
12
17
  export const CONTRADICTION_PAIRS = [
13
18
  [/\buse semicolons\b/i, /\bno semicolons\b/i],
@@ -1,12 +1,27 @@
1
+ import { type SafeReadFailureReason } from "../../lib/safe-read.js";
1
2
  /** Estimate token count from a string */
2
3
  export declare function estimateTokens(text: string): number;
3
- /** Read a SKILL.md and extract its name + critical rules section */
4
- export declare function parseSkillFile(skillPath: string): Promise<{
4
+ /** Why a SKILL.md could not be analysed — reportable, never thrown */
5
+ export interface SkillReadSkip {
6
+ reason: SafeReadFailureReason;
7
+ message: string;
8
+ }
9
+ export interface ParsedSkillFile {
5
10
  name: string;
6
11
  rules: string[];
7
12
  rawContent: string;
8
13
  triggers: string[];
9
- } | null>;
14
+ /**
15
+ * Non-null when the file could not be read as analysable text (binary,
16
+ * oversized, permission denied). `rawContent` is empty in that case, so
17
+ * callers can report the skip instead of scoring an empty skill.
18
+ */
19
+ skip: SkillReadSkip | null;
20
+ /** True when the file was longer than the read budget and got cut short. */
21
+ truncated: boolean;
22
+ }
23
+ /** Read a SKILL.md and extract its name + critical rules section */
24
+ export declare function parseSkillFile(skillPath: string): Promise<ParsedSkillFile | null>;
10
25
  /** Extract critical rules from markdown content */
11
26
  export declare function extractCriticalRules(content: string): string[];
12
27
  /** Extract trigger keywords from a skill description */