javi-forge 1.26.0 → 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.
@@ -1,6 +1,13 @@
1
1
  #!/bin/bash
2
2
  set -e
3
3
  # =============================================================================
4
+ # LEGACY — retained for reference only (hook-consolidation).
5
+ # The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
6
+ # (javi-forge ci init / init). The shipped commit-msg shim lives in
7
+ # assets/hooks/commit-msg; `javi-forge init` no longer copies this directory or
8
+ # sets `core.hooksPath=ci-local/hooks`. This body is kept for historical
9
+ # reference and is NOT the source of truth. See docs/commands.md → hooks.
10
+ # =============================================================================
4
11
  # COMMIT-MSG: Block accidental AI-attribution in commit messages
5
12
  # =============================================================================
6
13
  # Best-effort, NOT an adversarial-grade security control.
@@ -1,5 +1,13 @@
1
1
  #!/bin/bash
2
2
  # =============================================================================
3
+ # LEGACY — retained for reference only (hook-consolidation).
4
+ # The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
5
+ # (javi-forge ci init / init), whose static shims exec the dispatcher
6
+ # `javi-forge hooks run <name>`. `javi-forge init` no longer copies this
7
+ # directory or sets `core.hooksPath=ci-local/hooks`; a repo still on that legacy
8
+ # path is auto-migrated on the next install. This body is kept for historical
9
+ # reference and is NOT the source of truth. See docs/commands.md → hooks.
10
+ # =============================================================================
3
11
  # PRE-COMMIT: Quick CI check via javi-forge
4
12
  # =============================================================================
5
13
  # Requires: npm install -g javi-forge
@@ -1,5 +1,13 @@
1
1
  #!/bin/bash
2
2
  # =============================================================================
3
+ # LEGACY — retained for reference only (hook-consolidation).
4
+ # The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
5
+ # (javi-forge ci init / init), whose static shims exec the dispatcher
6
+ # `javi-forge hooks run <name>`. `javi-forge init` no longer copies this
7
+ # directory or sets `core.hooksPath=ci-local/hooks`; a repo still on that legacy
8
+ # path is auto-migrated on the next install. This body is kept for historical
9
+ # reference and is NOT the source of truth. See docs/commands.md → hooks.
10
+ # =============================================================================
3
11
  # PRE-PUSH: Full CI simulation via javi-forge
4
12
  # =============================================================================
5
13
  # Requires: npm install -g javi-forge
@@ -83,7 +83,7 @@ export async function handleCi(cli, ctx) {
83
83
  }
84
84
  if (installed.length > 0) {
85
85
  console.log(`✓ Installed git hooks: ${installed.join(", ")}`);
86
- console.log(" Hooks call javi-forge ci (with npx fallback)");
86
+ console.log(" Hooks call javi-forge hooks run (with npx fallback)");
87
87
  }
88
88
  // Upgrades are reported DISTINCTLY from fresh installs: replacing an
89
89
  // older javi-forge hook is not the same event as writing a new one.
@@ -57,6 +57,10 @@ export async function handleSkillsCmd(cli, ctx) {
57
57
  console.error(`\u2717 Skill not found: ${skillPath}`);
58
58
  process.exit(1);
59
59
  }
60
+ if (result.unread) {
61
+ console.error(`\u2717 Skill could not be scored \u2014 ${result.unread}: ${skillPath}`);
62
+ process.exit(1);
63
+ }
60
64
  console.log(`\nSkill: ${result.skillName}`);
61
65
  console.log(` Completeness: ${result.completeness}/100`);
62
66
  console.log(` Clarity: ${result.clarity}/100`);
@@ -77,6 +81,10 @@ export async function handleSkillsCmd(cli, ctx) {
77
81
  console.error(`\u2717 Skill not found: ${skillPath}`);
78
82
  process.exit(1);
79
83
  }
84
+ if (result.unread) {
85
+ console.error(`\u2717 Skill could not be benchmarked \u2014 ${result.unread}: ${skillPath}`);
86
+ process.exit(1);
87
+ }
80
88
  console.log(`\nBenchmark: ${result.skillName}`);
81
89
  for (const check of result.checks) {
82
90
  const icon = check.passed ? "\u2713" : "\u2717";
@@ -487,7 +487,11 @@ export async function runCI(options, onStep, onGateOutcome) {
487
487
  try {
488
488
  const ctxResult = await refreshContextDir(projectDir);
489
489
  if (ctxResult) {
490
- report(onStep, stepContext, "Refresh .context/ directory", "done", "INDEX.md + summary.md updated");
490
+ // An unreadable manifest is reported, not silently treated as no deps.
491
+ const detail = ctxResult.warnings.length > 0
492
+ ? `INDEX.md + summary.md updated (manifest warnings: ${ctxResult.warnings.join("; ")})`
493
+ : "INDEX.md + summary.md updated";
494
+ report(onStep, stepContext, "Refresh .context/ directory", "done", detail);
491
495
  }
492
496
  else {
493
497
  report(onStep, stepContext, "Refresh .context/ directory", "skipped", "no .context/ or no manifest");
@@ -319,6 +319,15 @@ export async function runDoctor(projectDir) {
319
319
  status: "ok",
320
320
  detail: "INDEX.md + summary.md updated",
321
321
  });
322
+ // A manifest that could not be read is not "no dependencies" — surface
323
+ // each warning so an unreadable/oversized/invalid manifest is visible.
324
+ for (const warning of result.warnings) {
325
+ contextChecks.push({
326
+ label: "dependency manifest",
327
+ status: "fail",
328
+ detail: warning,
329
+ });
330
+ }
322
331
  }
323
332
  else {
324
333
  contextChecks.push({
@@ -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,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 */
@@ -1,7 +1,8 @@
1
1
  import path from "node:path";
2
2
  import fs from "fs-extra";
3
3
  import { parseFrontmatter } from "../../lib/frontmatter.js";
4
- import { CHARS_PER_TOKEN } from "./constants.js";
4
+ import { describeSafeReadFailure, safeReadFile, } from "../../lib/safe-read.js";
5
+ import { CHARS_PER_TOKEN, MAX_SKILL_BYTES } from "./constants.js";
5
6
  // ── Helpers ──────────────────────────────────────────────────────────────────
6
7
  /** Estimate token count from a string */
7
8
  export function estimateTokens(text) {
@@ -11,7 +12,24 @@ export function estimateTokens(text) {
11
12
  export async function parseSkillFile(skillPath) {
12
13
  if (!(await fs.pathExists(skillPath)))
13
14
  return null;
14
- const raw = await fs.readFile(skillPath, "utf-8");
15
+ const read = await safeReadFile(skillPath, {
16
+ hardRejectOverBytes: MAX_SKILL_BYTES,
17
+ });
18
+ if (!read.ok) {
19
+ // A path that vanished between the existence check and the read is the
20
+ // same "no such skill" case the caller already handles with null.
21
+ if (read.reason === "not-found" || read.reason === "not-a-file")
22
+ return null;
23
+ return {
24
+ name: path.basename(path.dirname(skillPath)),
25
+ rules: [],
26
+ rawContent: "",
27
+ triggers: [],
28
+ skip: { reason: read.reason, message: describeSafeReadFailure(read) },
29
+ truncated: false,
30
+ };
31
+ }
32
+ const raw = read.content;
15
33
  const fm = parseFrontmatter(raw);
16
34
  const rawName = fm?.data?.name;
17
35
  const name = typeof rawName === "string"
@@ -23,7 +41,15 @@ export async function parseSkillFile(skillPath) {
23
41
  const rawDesc = fm?.data?.description;
24
42
  const description = typeof rawDesc === "string" ? rawDesc : "";
25
43
  const triggers = extractTriggers(description);
26
- return { name, rules, rawContent: raw, triggers };
44
+ // rawContent is the kept text, so token estimates reflect what was analysed.
45
+ return {
46
+ name,
47
+ rules,
48
+ rawContent: raw,
49
+ triggers,
50
+ skip: null,
51
+ truncated: read.truncated,
52
+ };
27
53
  }
28
54
  /** Extract critical rules from markdown content */
29
55
  export function extractCriticalRules(content) {
@@ -1,10 +1,11 @@
1
1
  import type { SkillGrade, SkillRegistryGateResult, SkillScore } from "../../types/index.js";
2
- type ParsedSkill = {
3
- name: string;
4
- rules: string[];
5
- rawContent: string;
6
- triggers: string[];
7
- };
2
+ import { type ParsedSkillFile } from "./parsing.js";
3
+ /**
4
+ * The fields the dimension scorers read. Derived from `ParsedSkillFile` with
5
+ * `Pick` (not a hand-copied shape) so the `skip` field can never be silently
6
+ * dropped: a skipped parse is caught in `scoreSkill` before it reaches a scorer.
7
+ */
8
+ type ParsedSkill = Pick<ParsedSkillFile, "name" | "rules" | "rawContent" | "triggers">;
8
9
  /**
9
10
  * Score completeness (0-100): frontmatter fields, critical rules, structure.
10
11
  */
@@ -1,5 +1,5 @@
1
1
  import { DEFAULT_REGISTRY_THRESHOLD, DEFAULT_THRESHOLD } from "./constants.js";
2
- import { estimateTokens, parseSkillFile } from "./parsing.js";
2
+ import { estimateTokens, parseSkillFile, } from "./parsing.js";
3
3
  import { ACTION_VERBS, VAGUE_TERMS } from "./rules.js";
4
4
  // ── Dangerous content patterns (private to scoreSafety) ─────────────────────
5
5
  /** Dangerous patterns in skill content that indicate safety risks */
@@ -243,6 +243,26 @@ export async function scoreSkill(skillPath, threshold = DEFAULT_THRESHOLD) {
243
243
  const parsed = await parseSkillFile(skillPath);
244
244
  if (!parsed)
245
245
  return null;
246
+ // A file that could not be read is NOT a zero-content skill. Scoring it would
247
+ // invent a grade over an empty string — worse, `scoreSafety` starts at 100
248
+ // and finds no dangerous patterns in nothing, so an unreadable file would
249
+ // report perfect safety. Represent it as unread and fail closed instead.
250
+ if (parsed.skip) {
251
+ return {
252
+ skillName: parsed.name,
253
+ completeness: 0,
254
+ clarity: 0,
255
+ testability: 0,
256
+ tokenEfficiency: 0,
257
+ safety: 0,
258
+ agentReadiness: 0,
259
+ overall: 0,
260
+ grade: "F",
261
+ threshold,
262
+ passing: false,
263
+ unread: parsed.skip.message,
264
+ };
265
+ }
246
266
  const completeness = scoreCompleteness(parsed);
247
267
  const clarity = scoreClarity(parsed);
248
268
  const testability = scoreTestability(parsed);
@@ -279,6 +299,16 @@ export async function registryGate(skillPath, threshold = DEFAULT_REGISTRY_THRES
279
299
  const score = await scoreSkill(skillPath, threshold);
280
300
  if (!score)
281
301
  return null;
302
+ // An unread skill can never be accepted into the registry: it was not scored,
303
+ // so it cannot clear any threshold. Reject it with an explicit reason.
304
+ if (score.unread) {
305
+ return {
306
+ skillName: score.skillName,
307
+ score,
308
+ accepted: false,
309
+ reason: `Rejected: could not read skill (${score.unread}) — not scored`,
310
+ };
311
+ }
282
312
  const accepted = score.passing;
283
313
  let reason;
284
314
  if (!accepted) {
@@ -1,9 +1,28 @@
1
1
  import type { InitOptions, StackContextEntry } from "../types/index.js";
2
2
  export declare function buildIndexMd(projectName: string, stackCtx: StackContextEntry, ciProvider: string, memory: string): string;
3
3
  export declare function buildSummaryMd(projectName: string, stack: string, ciProvider: string, memory: string, modules: string[], dependencies?: string[]): string;
4
+ /**
5
+ * Dependency detection outcome. `warnings` explains every manifest that could
6
+ * not be read or parsed, so a failure is distinguishable from "no dependencies"
7
+ * instead of being swallowed by a blanket catch.
8
+ */
9
+ export interface DependencyDetection {
10
+ dependencies: string[];
11
+ warnings: string[];
12
+ }
13
+ /**
14
+ * Detect top-level dependencies from project manifest files, reporting why a
15
+ * manifest was skipped. Returns up to 10 dependency names (key deps only).
16
+ */
17
+ export declare function detectDependenciesDetailed(projectDir: string, stack: string): Promise<DependencyDetection>;
4
18
  /**
5
19
  * Detect top-level dependencies from project manifest files.
6
20
  * Returns up to 10 dependency names (key deps only, not devDeps).
21
+ *
22
+ * List-returning convenience over `detectDependenciesDetailed`. It DISCARDS the
23
+ * read/parse warnings — an unreadable manifest is indistinguishable from an
24
+ * honest empty list through this function. Callers that must tell those two
25
+ * apart (like `refreshContextDir`) call `detectDependenciesDetailed` directly.
7
26
  */
8
27
  export declare function detectDependencies(projectDir: string, stack: string): Promise<string[]>;
9
28
  /**
@@ -23,5 +42,8 @@ export declare function refreshContextDir(projectDir: string): Promise<{
23
42
  index: string;
24
43
  summary: string;
25
44
  updated: boolean;
45
+ /** Manifest read/parse warnings — surfaced so an unreadable manifest is not
46
+ * silently reported as a project with no dependencies. */
47
+ warnings: string[];
26
48
  } | null>;
27
49
  //# sourceMappingURL=context.d.ts.map