@fusengine/harness 0.1.29 → 0.1.31

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,3 +1,3 @@
1
- import { a as queryHash, i as jaccardSimilar, n as summarizeIndex, r as compactMarkdown, t as loadIndex } from "../cache-BzbX-ztL.mjs";
2
- import { a as extractText, i as mcpCacheKey, n as cachePath, r as cacheStore, t as cacheLookup } from "../store-DeIsfMg5.mjs";
1
+ import { n as jaccardSimilar, r as queryHash, t as compactMarkdown } from "../cache-C9z9LclL.mjs";
2
+ import { a as extractText, i as mcpCacheKey, n as cachePath, o as loadIndex, r as cacheStore, s as summarizeIndex, t as cacheLookup } from "../store-PrNPm6So.mjs";
3
3
  export { cacheLookup, cachePath, cacheStore, compactMarkdown, extractText, jaccardSimilar, loadIndex, mcpCacheKey, queryHash, summarizeIndex };
@@ -1,4 +1,3 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
1
  import { createHash } from "node:crypto";
3
2
  //#region src/cache/compact.ts
4
3
  const HTML_ENTITIES = {
@@ -54,33 +53,4 @@ function jaccardSimilar(a, b, threshold = .8) {
54
53
  return inter / union > threshold;
55
54
  }
56
55
  //#endregion
57
- //#region src/cache/io.ts
58
- /** Read a JSON array from `path`; [] on missing/corrupt/non-array. */
59
- function loadIndex(path) {
60
- try {
61
- if (!existsSync(path)) return [];
62
- const data = JSON.parse(readFileSync(path, "utf8"));
63
- return Array.isArray(data) ? data : [];
64
- } catch {
65
- return [];
66
- }
67
- }
68
- /** Summarize an index of `{ tool?, ts? }` entries. */
69
- function summarizeIndex(index) {
70
- const byTool = {};
71
- const timestamps = [];
72
- for (const entry of index) {
73
- if (typeof entry !== "object" || entry === null) continue;
74
- const e = entry;
75
- if (typeof e.tool === "string") byTool[e.tool] = (byTool[e.tool] ?? 0) + 1;
76
- if (typeof e.ts === "string") timestamps.push(e.ts);
77
- }
78
- return {
79
- total: index.length,
80
- byTool,
81
- oldestTs: timestamps.length ? timestamps.reduce((a, b) => a < b ? a : b) : null,
82
- newestTs: timestamps.length ? timestamps.reduce((a, b) => a > b ? a : b) : null
83
- };
84
- }
85
- //#endregion
86
- export { queryHash as a, jaccardSimilar as i, summarizeIndex as n, compactMarkdown as r, loadIndex as t };
56
+ export { jaccardSimilar as n, queryHash as r, compactMarkdown as t };
package/dist/cli/bin.mjs CHANGED
@@ -3,7 +3,7 @@ import { r as resolveTtlSec } from "../ttl-BG55s6HZ.mjs";
3
3
  import { t as detectHarness } from "../harness-C8Nxxyn_.mjs";
4
4
  import { n as stagedContent, r as stagedFiles, t as checkStaged } from "../run-CXsV-wIJ.mjs";
5
5
  import { n as writeInitFile, t as initFor } from "../run-D91N4ul1.mjs";
6
- import { t as handleHook } from "../handle-nu3GYVek.mjs";
6
+ import { t as handleHook } from "../handle-DW9cWdVt.mjs";
7
7
  //#region src/cli/bin.ts
8
8
  /**
9
9
  * harness — CLI for @fusengine/harness.
@@ -25,11 +25,22 @@ async function readStdin() {
25
25
  }
26
26
  const cmd = process.argv[2];
27
27
  if (cmd === "hook") {
28
- const outcome = await handleHook(process.argv[3] ?? detectHarness().id, await readStdin(), {
28
+ const id = process.argv[3] ?? detectHarness().id;
29
+ const scopeArg = process.argv[4];
30
+ const scope = scopeArg !== void 0 && (/* @__PURE__ */ new Set([
31
+ "solid",
32
+ "rules",
33
+ "carto",
34
+ "security",
35
+ "changelog",
36
+ "aipilot"
37
+ ])).has(scopeArg) ? scopeArg : "core";
38
+ const outcome = await handleHook(id, await readStdin(), {
29
39
  now: Date.now(),
30
40
  cwd: process.cwd(),
31
41
  refsDir: process.env.FUSE_HARNESS_REFS,
32
- windowMs: resolveTtlSec(process.env) * 1e3
42
+ windowMs: resolveTtlSec(process.env) * 1e3,
43
+ scope
33
44
  });
34
45
  if (outcome.stdout) process.stdout.write(outcome.stdout);
35
46
  process.exit(outcome.exit);
@@ -3,7 +3,8 @@ import { k as countLines } from "./evaluate-j3gRJ_ng.mjs";
3
3
  import { r as isDocConsulted, t as formatDocDeny } from "./doc-helpers-Dd_x1-tZ.mjs";
4
4
  import { t as routeReferences } from "./router-D8cVrI-s.mjs";
5
5
  import { join } from "node:path";
6
- import { existsSync } from "node:fs";
6
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
7
+ import { homedir } from "node:os";
7
8
  //#region src/policy/detect-project.ts
8
9
  /** Keywords that signal a development task (APEX trigger). */
9
10
  const DEV_KEYWORDS = /\b(implement|create|build|fix|add|refactor|develop|feature|bug|update|modify|change|write|code)\b/i;
@@ -673,4 +674,364 @@ function skillTriggerGate(framework, content, refsRead, forcedSkill, cwd) {
673
674
  };
674
675
  }
675
676
  //#endregion
676
- export { detectProjectType as _, MAX_EXA_RESULTS as a, detectCreationIntent as c, docConsultedGate as d, evaluateApex as f, detectModularArchitecture as g, DEV_KEYWORDS as h, frameworkSolidGate as i, APEX_GATES as l, solidReadGate as m, skillTriggerGate as n, MAX_TOKENS as o, freshnessGate as p, SKILL_TRIGGERS as r, capVerbosity as s, detectRequiredSkills as t, brainstormGate as u, isApexCommand as v, requiredArchSkill as y };
677
+ //#region src/policy/claude-md-context.ts
678
+ /** Dev-verb regex (FR/EN) that triggers the APEX preamble (case-insensitive). */
679
+ const DEV_VERBS = /(cr[ée]er|impl[ée]menter|ajouter|d[ée]velopper|construire|build|refactor|migrer|implement|create|add|develop)/i;
680
+ /**
681
+ * Detect the project type from the cwd, reproducing the legacy Python logic:
682
+ * package.json containing "next" → nextjs, else "react" → react; else
683
+ * composer.json+artisan → laravel; else Package.swift / *.xcodeproj → swift;
684
+ * else generic.
685
+ * @param cwd - Project root to scan.
686
+ * @returns The detected project type label.
687
+ */
688
+ function detectClaudeMdProjectType(cwd) {
689
+ const pkg = join(cwd, "package.json");
690
+ if (existsSync(pkg)) try {
691
+ const content = readFileSync(pkg, "utf-8");
692
+ if (content.includes("next")) return "nextjs";
693
+ if (content.includes("react")) return "react";
694
+ } catch {}
695
+ if (existsSync(join(cwd, "composer.json")) && existsSync(join(cwd, "artisan"))) return "laravel";
696
+ if (existsSync(join(cwd, "Package.swift"))) return "swift";
697
+ try {
698
+ if (readdirSync(cwd).some((f) => f.endsWith(".xcodeproj"))) return "swift";
699
+ } catch {}
700
+ return "generic";
701
+ }
702
+ /**
703
+ * Build the APEX instruction preamble for a development task.
704
+ * @param projectType - Detected project type label.
705
+ * @param maxLines - SOLID per-file line ceiling.
706
+ * @returns The APEX instruction text.
707
+ */
708
+ function buildApexInstruction(projectType, maxLines) {
709
+ return `INSTRUCTION: This is a development task. Use APEX methodology:
710
+
711
+ **TRACKING FILE**: [project]/.claude/apex/task.json (auto-created on first Write/Edit)
712
+
713
+ 1. **ANALYZE** (MANDATORY - 3 AGENTS IN PARALLEL):
714
+ - explore-codebase + research-expert + ${projectType}-expert (framework expertise)\n - Project type detected: ${projectType}\n\n2. **PLAN**: Use TaskCreate to break down tasks (<${maxLines} lines per file)\n\n3. **EXECUTE**: ${projectType}-expert, follow SOLID principles, split at ${maxLines - 10} lines\n\n4. **EXAMINE**: Run sniper agent after ANY modification\n\n**IMPORTANT**: Read .claude/apex/task.json to check documentation status before writing code.`;
715
+ }
716
+ /**
717
+ * Build the UserPromptSubmit injection text: read `~/.claude/CLAUDE.md` and,
718
+ * when the prompt matches a dev verb, prepend the APEX instruction. Returns
719
+ * `null` when CLAUDE.md is absent/unreadable (the hook then emits nothing).
720
+ * @param prompt - The raw user prompt.
721
+ * @param cwd - Project root (for project-type detection).
722
+ * @returns The injection text, or `null` to emit nothing.
723
+ */
724
+ function buildClaudeMdContext(prompt, cwd) {
725
+ const claudeMd = join(homedir(), ".claude", "CLAUDE.md");
726
+ if (!existsSync(claudeMd)) return null;
727
+ let claudeContent;
728
+ try {
729
+ claudeContent = readFileSync(claudeMd, "utf-8");
730
+ } catch {
731
+ return null;
732
+ }
733
+ if (!DEV_VERBS.test(prompt)) return `# CLAUDE.md\n${claudeContent}`;
734
+ return `${buildApexInstruction(detectClaudeMdProjectType(cwd), resolveMaxLines())}\n\n# CLAUDE.md\n${claudeContent}`;
735
+ }
736
+ //#endregion
737
+ //#region src/policy/apex-task-context.ts
738
+ /**
739
+ * Read the current task state from `task.json`, reproducing the legacy Python
740
+ * logic. Any read/parse error falls back to `("1", "", "analyze", "none")`.
741
+ * @param taskFile - Absolute path to `.claude/apex/task.json`.
742
+ * @returns The parsed {@link ApexTaskState}.
743
+ */
744
+ function loadApexTaskState(taskFile) {
745
+ try {
746
+ const data = JSON.parse(readFileSync(taskFile, "utf-8"));
747
+ const id = String(data.current_task ?? "1");
748
+ const task = (data.tasks ?? {})[id] ?? {};
749
+ const subject = typeof task.subject === "string" ? task.subject : "";
750
+ const phase = typeof task.phase === "string" ? task.phase : "analyze";
751
+ const consultedMap = task.doc_consulted ?? {};
752
+ return {
753
+ id,
754
+ subject,
755
+ phase,
756
+ docs: Object.entries(consultedMap).filter(([, v]) => typeof v === "object" && v !== null && v.consulted === true).map(([k]) => k).join(", ") || "none"
757
+ };
758
+ } catch {
759
+ return {
760
+ id: "1",
761
+ subject: "",
762
+ phase: "analyze",
763
+ docs: "none"
764
+ };
765
+ }
766
+ }
767
+ /**
768
+ * Build the APEX context string injected into a Task sub-agent prompt.
769
+ * @param state - The parsed task state.
770
+ * @param maxLines - SOLID per-file line ceiling.
771
+ * @returns The injection text.
772
+ */
773
+ function buildApexTaskContext(state, maxLines) {
774
+ return `⚠️ APEX MODE - Read .claude/apex/AGENTS.md for rules\n\nCurrent: Task #${state.id} - ${state.subject} (Phase: ${state.phase})\nDocs consulted: ${state.docs}\n\nAgent must:\n1. Read task.json → find last 3 completed tasks\n2. Read their notes in docs/ (task-{ID}-{subject}.md)\n3. TaskList → see pending tasks\n4. TaskUpdate(in_progress) → before starting\n5. Apply SOLID (files < ${maxLines} lines)\n6. Write notes to docs/task-{ID}-{subject}.md\n7. TaskUpdate(completed) → triggers auto-commit`;
775
+ }
776
+ /**
777
+ * Build the PreToolUse Task injection, gated on the existence of the project's
778
+ * `.claude/apex/` directory. Returns `null` when APEX is not active (no dir).
779
+ * @param projectRoot - `CLAUDE_PROJECT_DIR` or cwd.
780
+ * @returns The injection text, or `null` to emit nothing.
781
+ */
782
+ function buildApexTaskInjection(projectRoot) {
783
+ const apexDir = join(projectRoot, ".claude", "apex");
784
+ if (!existsSync(apexDir)) return null;
785
+ return buildApexTaskContext(loadApexTaskState(join(apexDir, "task.json")), resolveMaxLines());
786
+ }
787
+ //#endregion
788
+ //#region src/policy/cartographer/indicators.ts
789
+ /**
790
+ * Cartographer indicators — pure data sets used to detect a project root and to
791
+ * exclude noise directories when walking a tree. Ports the constant tables from
792
+ * `generate_project_map.py` / `write_recursive.py`.
793
+ */
794
+ /** Filenames whose presence marks a directory as a project root. */
795
+ const PROJECT_INDICATORS = /* @__PURE__ */ new Set([
796
+ "package.json",
797
+ "deno.json",
798
+ "bun.lockb",
799
+ "bun.lock",
800
+ "tsconfig.json",
801
+ "package-lock.json",
802
+ "yarn.lock",
803
+ "pnpm-lock.yaml",
804
+ "pnpm-workspace.yaml",
805
+ "composer.json",
806
+ "artisan",
807
+ "Cargo.toml",
808
+ "rust-toolchain.toml",
809
+ "go.mod",
810
+ "pyproject.toml",
811
+ "setup.py",
812
+ "setup.cfg",
813
+ "Pipfile",
814
+ "requirements.txt",
815
+ "environment.yml",
816
+ "Gemfile",
817
+ "Package.swift",
818
+ "Podfile",
819
+ "pubspec.yaml",
820
+ "pom.xml",
821
+ "build.gradle",
822
+ "build.gradle.kts",
823
+ "settings.gradle",
824
+ "build.sbt",
825
+ "Makefile",
826
+ "CMakeLists.txt",
827
+ "meson.build",
828
+ "configure.ac",
829
+ "Directory.Build.props",
830
+ "global.json",
831
+ "mix.exs",
832
+ "rebar.config",
833
+ "project.clj",
834
+ "deps.edn",
835
+ "stack.yaml",
836
+ "cabal.project",
837
+ "dune-project",
838
+ "build.zig",
839
+ "gleam.toml",
840
+ "v.mod",
841
+ "Project.toml",
842
+ "DESCRIPTION",
843
+ "cpanfile",
844
+ "Makefile.PL",
845
+ ".luacheckrc",
846
+ "astro.config.mjs",
847
+ "next.config.js",
848
+ "next.config.mjs",
849
+ "nuxt.config.ts",
850
+ "vite.config.ts",
851
+ "next.config.ts",
852
+ "angular.json",
853
+ "svelte.config.js",
854
+ "svelte.config.ts",
855
+ "main.tf",
856
+ "ansible.cfg",
857
+ "pulumi.yaml",
858
+ "cdk.json",
859
+ "Chart.yaml",
860
+ "wrangler.toml",
861
+ "fly.toml",
862
+ "turbo.json",
863
+ "nx.json",
864
+ "BUILD",
865
+ "WORKSPACE",
866
+ "Justfile",
867
+ "Taskfile.yml",
868
+ "docker-compose.yml",
869
+ "docker-compose.yaml",
870
+ "compose.yml",
871
+ "compose.yaml",
872
+ "Dockerfile",
873
+ ".git"
874
+ ]);
875
+ /** Directory names skipped entirely during the tree walk. */
876
+ const EXCLUDE_DIRS = /* @__PURE__ */ new Set([
877
+ "node_modules",
878
+ ".git",
879
+ ".next",
880
+ ".nuxt",
881
+ "dist",
882
+ "build",
883
+ ".output",
884
+ "vendor",
885
+ "__pycache__",
886
+ ".venv",
887
+ "venv",
888
+ ".cartographer",
889
+ ".claude",
890
+ ".ruff_cache",
891
+ ".DS_Store",
892
+ "coverage",
893
+ ".turbo",
894
+ ".vercel",
895
+ ".netlify",
896
+ "Pods",
897
+ "DerivedData",
898
+ ".build",
899
+ ".swiftpm"
900
+ ]);
901
+ //#endregion
902
+ //#region src/policy/cartographer/frontmatter.ts
903
+ /**
904
+ * Frontmatter parsing — pure text helpers (no fs). Ports `parse_frontmatter.py`.
905
+ */
906
+ const BLOCK_SCALARS = /* @__PURE__ */ new Set([
907
+ "|",
908
+ ">",
909
+ "|+",
910
+ "|-",
911
+ ">+",
912
+ ">-"
913
+ ]);
914
+ /** Escape regex metacharacters in an arbitrary field name. */
915
+ function escapeRe(s) {
916
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
917
+ }
918
+ /**
919
+ * Extract a single frontmatter field's value from `text`. Strips surrounding
920
+ * quotes; skips YAML block-scalar markers. Returns "" when absent.
921
+ * @param text - The full document text.
922
+ * @param field - The frontmatter key to read.
923
+ * @returns The field value, or "".
924
+ */
925
+ function parseField(text, field) {
926
+ const fm = /^---\s*\n([\s\S]*?)\n---/.exec(text);
927
+ if (!fm || fm[1] === void 0) return "";
928
+ const lineRe = new RegExp(`^${escapeRe(field)}\\s*:\\s*(.+)$`);
929
+ for (const line of fm[1].split("\n")) {
930
+ const m = lineRe.exec(line);
931
+ if (!m || m[1] === void 0) continue;
932
+ const val = m[1].trim().replace(/^["']|["']$/g, "");
933
+ if (BLOCK_SCALARS.has(val)) continue;
934
+ return val;
935
+ }
936
+ return "";
937
+ }
938
+ /**
939
+ * Derive a short description from the body following the frontmatter: the first
940
+ * non-empty trimmed line, sliced to `maxLen`. Returns "" when none.
941
+ * @param text - The full document text.
942
+ * @param maxLen - Maximum length of the returned description.
943
+ * @returns The body-derived description, or "".
944
+ */
945
+ function parseBodyDesc(text, maxLen = 60) {
946
+ const m = /^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)/.exec(text);
947
+ if (!m || m[1] === void 0) return "";
948
+ for (const raw of m[1].split("\n")) {
949
+ const line = raw.trim();
950
+ if (line) return line.slice(0, maxLen);
951
+ }
952
+ return "";
953
+ }
954
+ //#endregion
955
+ //#region src/policy/cartographer/entry.ts
956
+ const ENTRY_RE = /^(.*?)\[([^\]]+)\]\(([^)]+)\)\s*(?:—|-{1,2})\s*(.*)$/;
957
+ const ENRICH_RE = /^(?:.*?)\[([^\]]+)\]\(([^)]+)\)\s*(?:—|-{1,2})\s*(.+)$/;
958
+ /**
959
+ * Parse a `merge_index` tree line into its parts. Returns null on no match.
960
+ * @param line - The raw tree line.
961
+ * @returns The parsed entry, or null.
962
+ */
963
+ function parseEntry(line) {
964
+ const m = ENTRY_RE.exec(line);
965
+ if (!m || m[1] === void 0 || m[2] === void 0 || m[3] === void 0 || m[4] === void 0) return null;
966
+ return {
967
+ prefix: m[1],
968
+ name: m[2],
969
+ path: m[3],
970
+ desc: m[4]
971
+ };
972
+ }
973
+ /**
974
+ * Parse an enrichment line into `[path, desc]`, requiring a non-empty desc.
975
+ * @param line - The raw index line.
976
+ * @returns The `[path, desc]` pair, or null.
977
+ */
978
+ function parseEnrichment(line) {
979
+ const m = ENRICH_RE.exec(line);
980
+ if (!m || m[2] === void 0 || m[3] === void 0) return null;
981
+ const desc = m[3].trim();
982
+ return desc ? [m[2], desc] : null;
983
+ }
984
+ //#endregion
985
+ //#region src/policy/cartographer/describe.ts
986
+ /**
987
+ * File-description heuristics — pure text in, description out (no fs). Ports
988
+ * `describe.py`.
989
+ */
990
+ const SOURCE_SUFFIXES = /* @__PURE__ */ new Set([
991
+ ".ts",
992
+ ".tsx",
993
+ ".js",
994
+ ".jsx",
995
+ ".py",
996
+ ".swift"
997
+ ]);
998
+ /**
999
+ * First `# ` Markdown heading text (sans hashes), sliced to 60. "" when none.
1000
+ * @param text - The document text.
1001
+ * @returns The heading text, or "".
1002
+ */
1003
+ function firstHeading(text) {
1004
+ for (const line of text.split("\n")) if (line.startsWith("# ")) return line.replace(/^#+/, "").trim().slice(0, 60);
1005
+ return "";
1006
+ }
1007
+ /**
1008
+ * First leading comment among the first 10 lines (`//`, `#` but not `#!`, or a
1009
+ * `"""`/`'''` docstring), sliced to 60. "" when none.
1010
+ * @param text - The source text.
1011
+ * @returns The comment text, or "".
1012
+ */
1013
+ function firstComment(text) {
1014
+ const lines = text.split("\n").slice(0, 10);
1015
+ for (const raw of lines) {
1016
+ const line = raw.trim();
1017
+ if ((line.startsWith("//") || line.startsWith("#")) && !line.startsWith("#!")) return line.replace(/^[/#! ]+/, "").slice(0, 60);
1018
+ if (line.startsWith("\"\"\"") || line.startsWith("'''")) return line.replace(/^['"\s]+|['"\s]+$/g, "").slice(0, 60);
1019
+ }
1020
+ return "";
1021
+ }
1022
+ /**
1023
+ * Derive a description from a file's suffix + text. For `.md`, the supplied
1024
+ * frontmatter `description` (truncated) wins over the first heading; for known
1025
+ * source suffixes, the first comment; else "".
1026
+ * @param suffix - The file extension (with dot).
1027
+ * @param text - The file text.
1028
+ * @param mdField - The pre-parsed frontmatter `description` (md only).
1029
+ * @returns The derived description, or "".
1030
+ */
1031
+ function descFromText(suffix, text, mdField) {
1032
+ if (suffix === ".md") return mdField.slice(0, 60) || firstHeading(text);
1033
+ if (SOURCE_SUFFIXES.has(suffix)) return firstComment(text);
1034
+ return "";
1035
+ }
1036
+ //#endregion
1037
+ export { solidReadGate as A, capVerbosity as C, docConsultedGate as D, brainstormGate as E, requiredArchSkill as F, detectModularArchitecture as M, detectProjectType as N, evaluateApex as O, isApexCommand as P, MAX_TOKENS as S, APEX_GATES as T, detectRequiredSkills as _, parseEntry as a, frameworkSolidGate as b, EXCLUDE_DIRS as c, buildApexTaskInjection as d, loadApexTaskState as f, detectClaudeMdProjectType as g, buildClaudeMdContext as h, parseEnrichment as i, DEV_KEYWORDS as j, freshnessGate as k, PROJECT_INDICATORS as l, buildApexInstruction as m, firstComment as n, parseBodyDesc as o, DEV_VERBS as p, firstHeading as r, parseField as s, descFromText as t, buildApexTaskContext as u, skillTriggerGate as v, detectCreationIntent as w, MAX_EXA_RESULTS as x, SKILL_TRIGGERS as y };
@@ -1,3 +1,3 @@
1
1
  import { i as resolveSessions, n as formatDocSatisfactionStatus, r as isDocConsulted, t as formatDocDeny } from "../doc-helpers-Dd_x1-tZ.mjs";
2
- import { t as incrementTrivialEditCounter } from "../freshness-CezohJHo.mjs";
2
+ import { t as incrementTrivialEditCounter } from "../freshness-otdUpuvP.mjs";
3
3
  export { formatDocDeny, formatDocSatisfactionStatus, incrementTrivialEditCounter, isDocConsulted, resolveSessions };
@@ -1,4 +1,4 @@
1
- import { n as readJsonFile, r as writeJsonFile, t as ensureDir } from "./json-io-xpTDuvtn.mjs";
1
+ import { i as writeJsonFile, n as ensureDir, r as readJsonFile } from "./json-io-CAn72gI4.mjs";
2
2
  import { dirname } from "node:path";
3
3
  //#region src/freshness/trivial-edit-counter.ts
4
4
  /**