@haus-tech/haus-workflow 0.31.1 → 0.32.1
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.
- package/CHANGELOG.md +18 -0
- package/dist/cli.js +1494 -530
- package/library/catalog/decisions-triggers.json +26 -0
- package/library/catalog/manifest.json +82 -25
- package/library/global/settings-fragments/hooks.json +7 -0
- package/package.json +8 -8
package/dist/cli.js
CHANGED
|
@@ -1,18 +1,74 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res, err) => function __init() {
|
|
5
|
+
if (err) throw err[0];
|
|
6
|
+
try {
|
|
7
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
8
|
+
} catch (e) {
|
|
9
|
+
throw err = [e], e;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
var __export = (target, all) => {
|
|
13
|
+
for (var name in all)
|
|
14
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/utils/exec.ts
|
|
18
|
+
var exec_exports = {};
|
|
19
|
+
__export(exec_exports, {
|
|
20
|
+
commandExists: () => commandExists,
|
|
21
|
+
runCommand: () => runCommand,
|
|
22
|
+
runGit: () => runGit
|
|
23
|
+
});
|
|
24
|
+
import { execa } from "execa";
|
|
25
|
+
async function runCommand(command, args = [], options = {}) {
|
|
26
|
+
try {
|
|
27
|
+
const result = await execa(command, args, {
|
|
28
|
+
reject: false,
|
|
29
|
+
// non-zero exits are returned, not thrown
|
|
30
|
+
...options
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
command,
|
|
34
|
+
args,
|
|
35
|
+
stdout: String(result.stdout ?? ""),
|
|
36
|
+
stderr: String(result.stderr ?? ""),
|
|
37
|
+
exitCode: result.exitCode ?? 0
|
|
38
|
+
};
|
|
39
|
+
} catch (error2) {
|
|
40
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
41
|
+
throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function runGit(args, options = {}) {
|
|
45
|
+
return runCommand("git", args, options);
|
|
46
|
+
}
|
|
47
|
+
async function commandExists(command) {
|
|
48
|
+
try {
|
|
49
|
+
const result = await runCommand(command, ["--version"]);
|
|
50
|
+
return result.exitCode === 0;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
var init_exec = __esm({
|
|
56
|
+
"src/utils/exec.ts"() {
|
|
57
|
+
"use strict";
|
|
58
|
+
}
|
|
59
|
+
});
|
|
2
60
|
|
|
3
61
|
// src/cli.ts
|
|
4
62
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
5
|
-
import
|
|
63
|
+
import path45 from "node:path";
|
|
6
64
|
import { Command } from "commander";
|
|
7
65
|
|
|
8
66
|
// src/commands/apply.ts
|
|
9
|
-
import
|
|
67
|
+
import path17 from "node:path";
|
|
10
68
|
import checkbox from "@inquirer/checkbox";
|
|
11
69
|
|
|
12
|
-
// src/catalog/
|
|
13
|
-
import
|
|
14
|
-
import path2 from "node:path";
|
|
15
|
-
import fs2 from "fs-extra";
|
|
70
|
+
// src/catalog/load-catalog.ts
|
|
71
|
+
import path4 from "node:path";
|
|
16
72
|
|
|
17
73
|
// src/utils/fs.ts
|
|
18
74
|
import crypto from "node:crypto";
|
|
@@ -90,6 +146,7 @@ async function listFiles(root, patterns) {
|
|
|
90
146
|
cwd: root,
|
|
91
147
|
dot: true,
|
|
92
148
|
onlyFiles: true,
|
|
149
|
+
suppressErrors: true,
|
|
93
150
|
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"]
|
|
94
151
|
});
|
|
95
152
|
return files.sort((a, b) => a.localeCompare(b));
|
|
@@ -108,6 +165,57 @@ async function mapWithConcurrency(items, fn, concurrency = 24) {
|
|
|
108
165
|
return results;
|
|
109
166
|
}
|
|
110
167
|
|
|
168
|
+
// src/utils/paths.ts
|
|
169
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
170
|
+
import os from "node:os";
|
|
171
|
+
import path2 from "node:path";
|
|
172
|
+
import { fileURLToPath } from "node:url";
|
|
173
|
+
var HAUS_DIR = ".haus-workflow";
|
|
174
|
+
function hausPath(root, ...parts) {
|
|
175
|
+
return path2.join(root, HAUS_DIR, ...parts);
|
|
176
|
+
}
|
|
177
|
+
function claudePath(root, ...parts) {
|
|
178
|
+
return path2.join(root, ".claude", ...parts);
|
|
179
|
+
}
|
|
180
|
+
function displayPath(root, targetPath) {
|
|
181
|
+
const rel = path2.relative(root, targetPath).replace(/\\/g, "/");
|
|
182
|
+
if (rel && !rel.startsWith("../") && rel !== "..") {
|
|
183
|
+
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
184
|
+
}
|
|
185
|
+
const home = os.homedir();
|
|
186
|
+
const normalized = targetPath.replace(/\\/g, "/");
|
|
187
|
+
if (home && home.trim().length > 0) {
|
|
188
|
+
const homeRel = path2.relative(home, targetPath).replace(/\\/g, "/");
|
|
189
|
+
if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
|
|
190
|
+
return `~/${homeRel}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return normalized;
|
|
194
|
+
}
|
|
195
|
+
function packageRoot() {
|
|
196
|
+
let dir = path2.dirname(fileURLToPath(import.meta.url));
|
|
197
|
+
for (let i = 0; i < 12; i++) {
|
|
198
|
+
const pkgPath = path2.join(dir, "package.json");
|
|
199
|
+
if (existsSync(pkgPath)) {
|
|
200
|
+
try {
|
|
201
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
202
|
+
if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const parent = path2.dirname(dir);
|
|
207
|
+
if (parent === dir) break;
|
|
208
|
+
dir = parent;
|
|
209
|
+
}
|
|
210
|
+
const file = fileURLToPath(import.meta.url);
|
|
211
|
+
return path2.resolve(path2.dirname(file), "../..");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/catalog/remote-catalog.ts
|
|
215
|
+
import os2 from "node:os";
|
|
216
|
+
import path3 from "node:path";
|
|
217
|
+
import fs2 from "fs-extra";
|
|
218
|
+
|
|
111
219
|
// src/utils/logger.ts
|
|
112
220
|
var log = (msg, ...args) => {
|
|
113
221
|
console.log(msg, ...args);
|
|
@@ -542,48 +650,103 @@ function parseManifest(json) {
|
|
|
542
650
|
}
|
|
543
651
|
|
|
544
652
|
// src/catalog/remote-catalog.ts
|
|
653
|
+
function isTestMode() {
|
|
654
|
+
return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
|
|
655
|
+
}
|
|
545
656
|
function getCacheDir() {
|
|
546
|
-
return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ??
|
|
657
|
+
return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path3.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
|
|
547
658
|
}
|
|
548
659
|
var cachedCatalogRef;
|
|
549
660
|
var cachedBlobPaths;
|
|
661
|
+
function getBundledCatalogRef() {
|
|
662
|
+
try {
|
|
663
|
+
const manifestPath2 = path3.join(packageRoot(), "library/catalog/manifest.json");
|
|
664
|
+
const raw = fs2.readFileSync(manifestPath2, "utf8");
|
|
665
|
+
const data = JSON.parse(raw);
|
|
666
|
+
if (typeof data.version === "string" && data.version) {
|
|
667
|
+
return data.version.startsWith("v") ? data.version : `v${data.version}`;
|
|
668
|
+
}
|
|
669
|
+
} catch {
|
|
670
|
+
}
|
|
671
|
+
return void 0;
|
|
672
|
+
}
|
|
550
673
|
function getResolvedCatalogRef() {
|
|
551
|
-
|
|
674
|
+
const resolved = cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
|
|
675
|
+
if (!resolved) {
|
|
676
|
+
warn(
|
|
677
|
+
"Could not determine catalog ref from cache, env, or bundled snapshot \u2014 falling back to main (moving target)."
|
|
678
|
+
);
|
|
679
|
+
return "main";
|
|
680
|
+
}
|
|
681
|
+
return resolved;
|
|
552
682
|
}
|
|
553
683
|
function isCatalogRefResolved() {
|
|
554
684
|
return cachedCatalogRef !== void 0 || process.env["HAUS_CATALOG_REF"] !== void 0;
|
|
555
685
|
}
|
|
556
686
|
async function resolveCatalogRef(opts) {
|
|
557
687
|
const env = opts?.env ?? process.env;
|
|
558
|
-
if (env["HAUS_CATALOG_REF"])
|
|
688
|
+
if (env["HAUS_CATALOG_REF"]) {
|
|
689
|
+
if (env["HAUS_CATALOG_REF"] === "main") {
|
|
690
|
+
warn(
|
|
691
|
+
"HAUS_CATALOG_REF=main is set \u2014 fetching from the moving main branch. Pin to a release tag for reproducible installs."
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
return env["HAUS_CATALOG_REF"];
|
|
695
|
+
}
|
|
559
696
|
const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
|
|
560
697
|
const tag = await fetchLatest();
|
|
561
|
-
|
|
698
|
+
if (tag !== null) return tag;
|
|
699
|
+
const fallback = opts?.fallbackRef;
|
|
700
|
+
if (fallback) {
|
|
701
|
+
warn(
|
|
702
|
+
`Tag resolution failed \u2014 using cached ref ${fallback}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
703
|
+
);
|
|
704
|
+
return fallback;
|
|
705
|
+
}
|
|
706
|
+
const bundled = getBundledCatalogRef();
|
|
707
|
+
if (bundled) {
|
|
708
|
+
warn(
|
|
709
|
+
`Tag resolution failed \u2014 using bundled snapshot ref ${bundled}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
|
|
710
|
+
);
|
|
711
|
+
return bundled;
|
|
712
|
+
}
|
|
713
|
+
warn(
|
|
714
|
+
"Tag resolution failed and no fallback ref is available. Set HAUS_CATALOG_REF explicitly to avoid fetching from main."
|
|
715
|
+
);
|
|
716
|
+
return "main";
|
|
562
717
|
}
|
|
563
718
|
async function remoteBase() {
|
|
564
|
-
if (process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
719
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
565
720
|
return process.env["HAUS_CATALOG_REMOTE_BASE"];
|
|
566
721
|
}
|
|
567
722
|
if (cachedCatalogRef === void 0) {
|
|
568
|
-
cachedCatalogRef = await resolveCatalogRef();
|
|
723
|
+
cachedCatalogRef = await resolveCatalogRef({ fallbackRef: getBundledCatalogRef() });
|
|
569
724
|
}
|
|
570
725
|
return `${CATALOG_REPO_URL}/${cachedCatalogRef}`;
|
|
571
726
|
}
|
|
572
727
|
async function fetchText(url) {
|
|
573
728
|
try {
|
|
574
729
|
const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
575
|
-
if (!res.ok)
|
|
730
|
+
if (!res.ok) {
|
|
731
|
+
warn(`Catalog fetch HTTP ${res.status}: ${url}`);
|
|
732
|
+
return null;
|
|
733
|
+
}
|
|
576
734
|
return await res.text();
|
|
577
|
-
} catch {
|
|
735
|
+
} catch (e) {
|
|
736
|
+
warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
|
|
578
737
|
return null;
|
|
579
738
|
}
|
|
580
739
|
}
|
|
581
740
|
async function fetchBytes(url) {
|
|
582
741
|
try {
|
|
583
742
|
const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
584
|
-
if (!res.ok)
|
|
743
|
+
if (!res.ok) {
|
|
744
|
+
warn(`Catalog fetch HTTP ${res.status}: ${url}`);
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
585
747
|
return Buffer.from(await res.arrayBuffer());
|
|
586
|
-
} catch {
|
|
748
|
+
} catch (e) {
|
|
749
|
+
warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
|
|
587
750
|
return null;
|
|
588
751
|
}
|
|
589
752
|
}
|
|
@@ -606,13 +769,13 @@ async function writeTextIfChanged(dest, text) {
|
|
|
606
769
|
await fs2.writeFile(dest, text, "utf8");
|
|
607
770
|
return "updated";
|
|
608
771
|
}
|
|
609
|
-
await fs2.ensureDir(
|
|
772
|
+
await fs2.ensureDir(path3.dirname(dest));
|
|
610
773
|
await fs2.writeFile(dest, text, "utf8");
|
|
611
774
|
return "created";
|
|
612
775
|
}
|
|
613
776
|
var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
|
|
614
777
|
async function readWorkflowTemplate(opts = {}) {
|
|
615
|
-
const dest =
|
|
778
|
+
const dest = path3.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
|
|
616
779
|
const base = await remoteBase();
|
|
617
780
|
const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
|
|
618
781
|
if (text === null) {
|
|
@@ -627,14 +790,14 @@ async function readWorkflowTemplate(opts = {}) {
|
|
|
627
790
|
return text;
|
|
628
791
|
}
|
|
629
792
|
function isSafeCatalogPath(itemPath) {
|
|
630
|
-
if (!itemPath ||
|
|
631
|
-
const normalized =
|
|
793
|
+
if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
|
|
794
|
+
const normalized = path3.normalize(itemPath);
|
|
632
795
|
return !normalized.startsWith("..") && !normalized.includes("/..");
|
|
633
796
|
}
|
|
634
797
|
function isSafeRelativeFilePath(rel) {
|
|
635
798
|
if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
|
|
636
|
-
if (
|
|
637
|
-
const normalized =
|
|
799
|
+
if (path3.isAbsolute(rel)) return false;
|
|
800
|
+
const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
|
|
638
801
|
return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
|
|
639
802
|
}
|
|
640
803
|
function githubApiHeaders() {
|
|
@@ -656,8 +819,8 @@ function sanitizeRelativeFilePaths(files, label) {
|
|
|
656
819
|
}
|
|
657
820
|
function safeJoin(base, itemPath) {
|
|
658
821
|
if (!isSafeCatalogPath(itemPath)) return null;
|
|
659
|
-
const resolved =
|
|
660
|
-
return resolved.startsWith(base +
|
|
822
|
+
const resolved = path3.resolve(base, itemPath);
|
|
823
|
+
return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
|
|
661
824
|
}
|
|
662
825
|
var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
|
|
663
826
|
function isMarkdownPath(rel) {
|
|
@@ -665,13 +828,19 @@ function isMarkdownPath(rel) {
|
|
|
665
828
|
}
|
|
666
829
|
async function listFilesRecursive(dir, base = dir) {
|
|
667
830
|
const out = [];
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
831
|
+
let entries;
|
|
832
|
+
try {
|
|
833
|
+
if (!await fs2.pathExists(dir)) return out;
|
|
834
|
+
entries = await fs2.readdir(dir, { withFileTypes: true });
|
|
835
|
+
} catch {
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
for (const entry of entries) {
|
|
839
|
+
const full = path3.join(dir, entry.name);
|
|
671
840
|
if (entry.isDirectory()) {
|
|
672
841
|
out.push(...await listFilesRecursive(full, base));
|
|
673
842
|
} else if (entry.isFile()) {
|
|
674
|
-
out.push(
|
|
843
|
+
out.push(path3.relative(base, full).replace(/\\/g, "/"));
|
|
675
844
|
}
|
|
676
845
|
}
|
|
677
846
|
return out.sort();
|
|
@@ -714,7 +883,7 @@ async function fetchGitHubRecursiveBlobPaths(ref) {
|
|
|
714
883
|
}
|
|
715
884
|
async function fetchCatalogBlobPaths(_base) {
|
|
716
885
|
if (cachedBlobPaths) return cachedBlobPaths;
|
|
717
|
-
if (process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
886
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
718
887
|
const ref = getResolvedCatalogRef();
|
|
719
888
|
const paths = await fetchGitHubRecursiveBlobPaths(ref);
|
|
720
889
|
if (paths) cachedBlobPaths = paths;
|
|
@@ -724,7 +893,7 @@ async function listFilesUnderCatalogPrefix(prefix, base) {
|
|
|
724
893
|
const normalized = prefix.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
725
894
|
const prefixSlash = `${normalized}/`;
|
|
726
895
|
let relFiles;
|
|
727
|
-
if (process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
896
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
|
|
728
897
|
relFiles = await listMockPrefixFiles(base, normalized);
|
|
729
898
|
} else {
|
|
730
899
|
const blobs = await fetchCatalogBlobPaths(base);
|
|
@@ -776,7 +945,7 @@ async function directoryMatchesFetched(destDir, fetched) {
|
|
|
776
945
|
if (!relSet.has(rel)) return false;
|
|
777
946
|
}
|
|
778
947
|
for (const file of fetched) {
|
|
779
|
-
const dest =
|
|
948
|
+
const dest = path3.join(destDir, file.rel);
|
|
780
949
|
if (!await fs2.pathExists(dest)) return false;
|
|
781
950
|
if (file.kind === "text") {
|
|
782
951
|
const local = await fs2.readFile(dest, "utf8");
|
|
@@ -794,8 +963,8 @@ async function writeFetchedDirectory(destDir, fetched) {
|
|
|
794
963
|
}
|
|
795
964
|
await fs2.ensureDir(destDir);
|
|
796
965
|
for (const file of fetched) {
|
|
797
|
-
const dest =
|
|
798
|
-
await fs2.ensureDir(
|
|
966
|
+
const dest = path3.join(destDir, file.rel);
|
|
967
|
+
await fs2.ensureDir(path3.dirname(dest));
|
|
799
968
|
if (file.kind === "text") {
|
|
800
969
|
await fs2.writeFile(dest, file.body, "utf8");
|
|
801
970
|
} else {
|
|
@@ -871,7 +1040,7 @@ async function syncConfigItem(item, base) {
|
|
|
871
1040
|
if (relFiles && relFiles.length > 0) {
|
|
872
1041
|
try {
|
|
873
1042
|
return await syncDirectoryFromPrefix(item, item.path, dest, base, {
|
|
874
|
-
validateMarkdown:
|
|
1043
|
+
validateMarkdown: true,
|
|
875
1044
|
requireSkillMd: false,
|
|
876
1045
|
relFiles
|
|
877
1046
|
});
|
|
@@ -939,7 +1108,7 @@ async function syncOneItem(item, base) {
|
|
|
939
1108
|
return "failed";
|
|
940
1109
|
}
|
|
941
1110
|
const text = await fetchText(`${base}/${item.path}`);
|
|
942
|
-
if (
|
|
1111
|
+
if (text === null) {
|
|
943
1112
|
warn(`Failed to fetch content for ${item.id}`);
|
|
944
1113
|
return "failed";
|
|
945
1114
|
}
|
|
@@ -967,7 +1136,7 @@ async function syncRemoteCatalog() {
|
|
|
967
1136
|
try {
|
|
968
1137
|
await fs2.ensureDir(cacheDir);
|
|
969
1138
|
await fs2.writeFile(
|
|
970
|
-
|
|
1139
|
+
path3.join(cacheDir, "manifest.json"),
|
|
971
1140
|
`${JSON.stringify({ version, items }, null, 2)}
|
|
972
1141
|
`,
|
|
973
1142
|
"utf8"
|
|
@@ -1011,7 +1180,7 @@ function compareSemver(a, b) {
|
|
|
1011
1180
|
return 0;
|
|
1012
1181
|
}
|
|
1013
1182
|
async function fetchLatestCatalogTag() {
|
|
1014
|
-
if (process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
1183
|
+
if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
|
|
1015
1184
|
try {
|
|
1016
1185
|
const res = await fetch(CATALOG_TAGS_API_URL, {
|
|
1017
1186
|
signal: AbortSignal.timeout(5e3),
|
|
@@ -1035,66 +1204,13 @@ async function fetchLatestCatalogTag() {
|
|
|
1035
1204
|
}
|
|
1036
1205
|
async function getCacheManifestAge() {
|
|
1037
1206
|
try {
|
|
1038
|
-
const stat = await fs2.stat(
|
|
1207
|
+
const stat = await fs2.stat(path3.join(getCacheDir(), "manifest.json"));
|
|
1039
1208
|
return Date.now() - stat.mtimeMs;
|
|
1040
1209
|
} catch {
|
|
1041
1210
|
return null;
|
|
1042
1211
|
}
|
|
1043
1212
|
}
|
|
1044
1213
|
|
|
1045
|
-
// src/claude/write-claude-files.ts
|
|
1046
|
-
import path13 from "node:path";
|
|
1047
|
-
import fs13 from "fs-extra";
|
|
1048
|
-
|
|
1049
|
-
// src/catalog/load-catalog.ts
|
|
1050
|
-
import path4 from "node:path";
|
|
1051
|
-
|
|
1052
|
-
// src/utils/paths.ts
|
|
1053
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
1054
|
-
import os2 from "node:os";
|
|
1055
|
-
import path3 from "node:path";
|
|
1056
|
-
import { fileURLToPath } from "node:url";
|
|
1057
|
-
var HAUS_DIR = ".haus-workflow";
|
|
1058
|
-
function hausPath(root, ...parts) {
|
|
1059
|
-
return path3.join(root, HAUS_DIR, ...parts);
|
|
1060
|
-
}
|
|
1061
|
-
function claudePath(root, ...parts) {
|
|
1062
|
-
return path3.join(root, ".claude", ...parts);
|
|
1063
|
-
}
|
|
1064
|
-
function displayPath(root, targetPath) {
|
|
1065
|
-
const rel = path3.relative(root, targetPath).replace(/\\/g, "/");
|
|
1066
|
-
if (rel && !rel.startsWith("../") && rel !== "..") {
|
|
1067
|
-
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
1068
|
-
}
|
|
1069
|
-
const home = os2.homedir();
|
|
1070
|
-
const normalized = targetPath.replace(/\\/g, "/");
|
|
1071
|
-
if (home && home.trim().length > 0) {
|
|
1072
|
-
const homeRel = path3.relative(home, targetPath).replace(/\\/g, "/");
|
|
1073
|
-
if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
|
|
1074
|
-
return `~/${homeRel}`;
|
|
1075
|
-
}
|
|
1076
|
-
}
|
|
1077
|
-
return normalized;
|
|
1078
|
-
}
|
|
1079
|
-
function packageRoot() {
|
|
1080
|
-
let dir = path3.dirname(fileURLToPath(import.meta.url));
|
|
1081
|
-
for (let i = 0; i < 12; i++) {
|
|
1082
|
-
const pkgPath = path3.join(dir, "package.json");
|
|
1083
|
-
if (existsSync(pkgPath)) {
|
|
1084
|
-
try {
|
|
1085
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
1086
|
-
if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
|
|
1087
|
-
} catch {
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
const parent = path3.dirname(dir);
|
|
1091
|
-
if (parent === dir) break;
|
|
1092
|
-
dir = parent;
|
|
1093
|
-
}
|
|
1094
|
-
const file = fileURLToPath(import.meta.url);
|
|
1095
|
-
return path3.resolve(path3.dirname(file), "../..");
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
1214
|
// src/catalog/load-catalog.ts
|
|
1099
1215
|
async function loadCatalogContext(root) {
|
|
1100
1216
|
const envPath = process.env["HAUS_FIXTURE_CATALOG"];
|
|
@@ -1137,6 +1253,10 @@ function catalogItemContentPath(contentRoot, item) {
|
|
|
1137
1253
|
return path4.join(contentRoot, item.path);
|
|
1138
1254
|
}
|
|
1139
1255
|
|
|
1256
|
+
// src/claude/write-claude-files.ts
|
|
1257
|
+
import path14 from "node:path";
|
|
1258
|
+
import fs14 from "fs-extra";
|
|
1259
|
+
|
|
1140
1260
|
// src/update/hash-installed.ts
|
|
1141
1261
|
import path5 from "node:path";
|
|
1142
1262
|
import fg2 from "fast-glob";
|
|
@@ -1660,8 +1780,8 @@ function buildDenyRules() {
|
|
|
1660
1780
|
for (const command of DENY_COMMANDS) {
|
|
1661
1781
|
rules.push(`Bash(${command}:*)`);
|
|
1662
1782
|
}
|
|
1663
|
-
for (const
|
|
1664
|
-
const pattern = DENY_DIRS.has(
|
|
1783
|
+
for (const path46 of DENY_PATHS) {
|
|
1784
|
+
const pattern = DENY_DIRS.has(path46) ? `${path46}/**` : path46;
|
|
1665
1785
|
for (const tool of FILE_TOOLS) {
|
|
1666
1786
|
rules.push(`${tool}(${pattern})`);
|
|
1667
1787
|
}
|
|
@@ -1684,6 +1804,13 @@ var PROJECT_HOOK_FRAGMENTS = [
|
|
|
1684
1804
|
event: "PreToolUse",
|
|
1685
1805
|
matcher: "Bash",
|
|
1686
1806
|
command: "haus guard bash --from-hook"
|
|
1807
|
+
},
|
|
1808
|
+
{
|
|
1809
|
+
id: "haus.decisions-suggest",
|
|
1810
|
+
gate: "keep",
|
|
1811
|
+
event: "Stop",
|
|
1812
|
+
matcher: "*",
|
|
1813
|
+
command: "haus decisions suggest --from-hook"
|
|
1687
1814
|
}
|
|
1688
1815
|
];
|
|
1689
1816
|
async function readProjectSettings(root) {
|
|
@@ -1777,6 +1904,12 @@ var CANONICAL_HOOKS = {
|
|
|
1777
1904
|
matcher: "Bash",
|
|
1778
1905
|
hooks: [{ type: "command", command: "haus guard bash --from-hook" }]
|
|
1779
1906
|
}
|
|
1907
|
+
],
|
|
1908
|
+
Stop: [
|
|
1909
|
+
{
|
|
1910
|
+
matcher: "*",
|
|
1911
|
+
hooks: [{ type: "command", command: "haus decisions suggest --from-hook" }]
|
|
1912
|
+
}
|
|
1780
1913
|
]
|
|
1781
1914
|
}
|
|
1782
1915
|
};
|
|
@@ -1798,9 +1931,11 @@ function collectHookCommands(settings) {
|
|
|
1798
1931
|
}
|
|
1799
1932
|
function hausHookContractSatisfied(project, canonical) {
|
|
1800
1933
|
const present = new Set(collectHookCommands(project));
|
|
1801
|
-
for (const
|
|
1802
|
-
for (const
|
|
1803
|
-
|
|
1934
|
+
for (const blocks of Object.values(canonical.hooks)) {
|
|
1935
|
+
for (const block of blocks ?? []) {
|
|
1936
|
+
for (const h of block.hooks) {
|
|
1937
|
+
if (!present.has(h.command)) return false;
|
|
1938
|
+
}
|
|
1804
1939
|
}
|
|
1805
1940
|
}
|
|
1806
1941
|
const denySet = new Set(project.permissions?.deny ?? []);
|
|
@@ -1851,9 +1986,62 @@ async function verifyProjectSettingsHooksContract(root) {
|
|
|
1851
1986
|
return { ok: true, message: "settings.json carries required haus hook contract." };
|
|
1852
1987
|
}
|
|
1853
1988
|
|
|
1854
|
-
// src/claude/write-
|
|
1989
|
+
// src/claude/write-decisions-seed.ts
|
|
1855
1990
|
import path9 from "node:path";
|
|
1856
1991
|
import fs8 from "fs-extra";
|
|
1992
|
+
|
|
1993
|
+
// library/catalog/decisions-triggers.json
|
|
1994
|
+
var decisions_triggers_default = {
|
|
1995
|
+
decisionsDir: "docs/decisions",
|
|
1996
|
+
legacyDecisionsDir: "docs/adr",
|
|
1997
|
+
pathGlobs: [
|
|
1998
|
+
"**/package.json",
|
|
1999
|
+
"**/pnpm-lock.yaml",
|
|
2000
|
+
"**/yarn.lock",
|
|
2001
|
+
"**/package-lock.json",
|
|
2002
|
+
"**/migrations/**",
|
|
2003
|
+
"**/prisma/schema.prisma",
|
|
2004
|
+
"**/.github/workflows/**",
|
|
2005
|
+
"**/Dockerfile*",
|
|
2006
|
+
"**/docker-compose*.yml",
|
|
2007
|
+
"**/terraform/**",
|
|
2008
|
+
"**/openapi*.yaml",
|
|
2009
|
+
"**/schema.graphql"
|
|
2010
|
+
],
|
|
2011
|
+
securityPathGlobs: ["**/.env.example", "**/auth/**", "**/middleware/**"],
|
|
2012
|
+
pathRegex: ["(^|/)src/.*/auth/", "(^|/)middleware/", "\\.env\\.example$"],
|
|
2013
|
+
minFilesChanged: 8,
|
|
2014
|
+
minLinesChanged: 200,
|
|
2015
|
+
exemptGlobs: ["docs/**", "tests/**", "**/*.md", ".haus-workflow/**"],
|
|
2016
|
+
requiredSections: ["## Context", "## Decision", "## Consequences"],
|
|
2017
|
+
recommendedSections: ["## Motivation (why)", "## Alternatives considered"],
|
|
2018
|
+
decisionFilePattern: "^\\d{4}-.+\\.md$"
|
|
2019
|
+
};
|
|
2020
|
+
|
|
2021
|
+
// src/decisions/triggers.ts
|
|
2022
|
+
var DECISIONS_TRIGGERS = decisions_triggers_default;
|
|
2023
|
+
var DECISION_FILE_RE = new RegExp(DECISIONS_TRIGGERS.decisionFilePattern);
|
|
2024
|
+
|
|
2025
|
+
// src/claude/write-decisions-seed.ts
|
|
2026
|
+
var SEED_README = `# Architecture Decision Records (ADRs)
|
|
2027
|
+
|
|
2028
|
+
Why we chose X \u2014 short, dated, write-once decision log for humans and agents.
|
|
2029
|
+
|
|
2030
|
+
| ADR | Title | Why (one line) | Status |
|
|
2031
|
+
| --- | ----- | -------------- | ------ |
|
|
2032
|
+
`;
|
|
2033
|
+
async function writeDecisionsSeed(root, dryRun) {
|
|
2034
|
+
const decisionsDir = path9.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
2035
|
+
const readmePath = path9.join(decisionsDir, "README.md");
|
|
2036
|
+
if (await fs8.pathExists(readmePath)) return null;
|
|
2037
|
+
if (!dryRun) await fs8.ensureDir(decisionsDir);
|
|
2038
|
+
await writeManagedText(root, readmePath, SEED_README, dryRun);
|
|
2039
|
+
return readmePath;
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// src/claude/write-prettierignore.ts
|
|
2043
|
+
import path10 from "node:path";
|
|
2044
|
+
import fs9 from "fs-extra";
|
|
1857
2045
|
var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
|
|
1858
2046
|
var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
|
|
1859
2047
|
var IGNORED_PATHS = [".haus-workflow/"];
|
|
@@ -1909,20 +2097,21 @@ ${block}
|
|
|
1909
2097
|
`;
|
|
1910
2098
|
}
|
|
1911
2099
|
async function writePrettierIgnore(root, dryRun) {
|
|
1912
|
-
const filePath =
|
|
1913
|
-
const prev = await
|
|
2100
|
+
const filePath = path10.join(root, ".prettierignore");
|
|
2101
|
+
const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
|
|
1914
2102
|
const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
|
|
1915
2103
|
await writeManagedText(root, filePath, next, dryRun);
|
|
1916
2104
|
return filePath;
|
|
1917
2105
|
}
|
|
1918
2106
|
|
|
1919
2107
|
// src/claude/write-root-claude-md.ts
|
|
1920
|
-
import
|
|
1921
|
-
import
|
|
2108
|
+
import path11 from "node:path";
|
|
2109
|
+
import fs10 from "fs-extra";
|
|
1922
2110
|
var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
|
|
1923
2111
|
var BLOCK_END = "<!-- HAUS:END haus-imports -->";
|
|
1924
2112
|
var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
|
|
1925
|
-
@.haus-workflow/workflow-config.md
|
|
2113
|
+
@.haus-workflow/workflow-config.md
|
|
2114
|
+
@docs/decisions/README.md`;
|
|
1926
2115
|
function buildImportBlock() {
|
|
1927
2116
|
return `${BLOCK_BEGIN}
|
|
1928
2117
|
${IMPORT_CONTENT}
|
|
@@ -1983,21 +2172,21 @@ ${block}
|
|
|
1983
2172
|
`;
|
|
1984
2173
|
}
|
|
1985
2174
|
async function writeRootClaudeMd(root, dryRun) {
|
|
1986
|
-
const filePath =
|
|
2175
|
+
const filePath = path11.join(root, "CLAUDE.md");
|
|
1987
2176
|
const block = buildImportBlock();
|
|
1988
|
-
const prev = await
|
|
2177
|
+
const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
|
|
1989
2178
|
const next = injectHausBlock(prev, block);
|
|
1990
2179
|
await writeManagedText(root, filePath, next, dryRun);
|
|
1991
2180
|
return filePath;
|
|
1992
2181
|
}
|
|
1993
2182
|
|
|
1994
2183
|
// src/claude/write-workflow-config.ts
|
|
1995
|
-
import
|
|
1996
|
-
import
|
|
2184
|
+
import path13 from "node:path";
|
|
2185
|
+
import fs12 from "fs-extra";
|
|
1997
2186
|
|
|
1998
2187
|
// src/claude/derive-workflow-config.ts
|
|
1999
|
-
import
|
|
2000
|
-
import
|
|
2188
|
+
import path12 from "node:path";
|
|
2189
|
+
import fs11 from "fs-extra";
|
|
2001
2190
|
function binCmd(pm, bin, args) {
|
|
2002
2191
|
const tail = args ? ` ${args}` : "";
|
|
2003
2192
|
if (pm === "yarn") return `yarn ${bin}${tail}`;
|
|
@@ -2006,7 +2195,7 @@ function binCmd(pm, bin, args) {
|
|
|
2006
2195
|
}
|
|
2007
2196
|
async function deriveWorkflowConfig(root, ctx) {
|
|
2008
2197
|
const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
|
|
2009
|
-
const pkg = await readJson(
|
|
2198
|
+
const pkg = await readJson(path12.join(root, "package.json"));
|
|
2010
2199
|
const scripts = pkg?.scripts ?? {};
|
|
2011
2200
|
const deps = new Set(ctx.dependencies);
|
|
2012
2201
|
const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
|
|
@@ -2016,7 +2205,7 @@ async function deriveWorkflowConfig(root, ctx) {
|
|
|
2016
2205
|
return null;
|
|
2017
2206
|
};
|
|
2018
2207
|
const hasDep = (name) => deps.has(name);
|
|
2019
|
-
const exists = (rel) =>
|
|
2208
|
+
const exists = (rel) => fs11.pathExistsSync(path12.join(root, rel));
|
|
2020
2209
|
const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
|
|
2021
2210
|
const hasCypress = hasDep("cypress");
|
|
2022
2211
|
const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
|
|
@@ -2084,7 +2273,7 @@ var FALLBACK_CONTEXT = {
|
|
|
2084
2273
|
async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
2085
2274
|
const destPath = hausPath(root, "workflow-config.md");
|
|
2086
2275
|
const printable = displayPath(root, destPath);
|
|
2087
|
-
const exists = await
|
|
2276
|
+
const exists = await fs12.pathExists(destPath);
|
|
2088
2277
|
if (exists && !opts.refill) {
|
|
2089
2278
|
if (dryRun) log(printable + ": exists (project-owned, skipping)");
|
|
2090
2279
|
return null;
|
|
@@ -2092,11 +2281,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
2092
2281
|
const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
|
|
2093
2282
|
...FALLBACK_CONTEXT,
|
|
2094
2283
|
root,
|
|
2095
|
-
repoName:
|
|
2284
|
+
repoName: path13.basename(root)
|
|
2096
2285
|
};
|
|
2097
2286
|
const values = await deriveWorkflowConfig(root, ctx);
|
|
2098
2287
|
if (exists) {
|
|
2099
|
-
const current = await
|
|
2288
|
+
const current = await fs12.readFile(destPath, "utf8");
|
|
2100
2289
|
const refilled = refillContent(current, values);
|
|
2101
2290
|
if (refilled === current) {
|
|
2102
2291
|
if (dryRun) log(printable + ": no blank fields to refill");
|
|
@@ -2118,7 +2307,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
2118
2307
|
}
|
|
2119
2308
|
|
|
2120
2309
|
// src/claude/write-workflow.ts
|
|
2121
|
-
import
|
|
2310
|
+
import fs13 from "fs-extra";
|
|
2122
2311
|
var STABLE_ID = "template.workflow";
|
|
2123
2312
|
function makeWorkflowHeader(pkgVersion, contentHash) {
|
|
2124
2313
|
return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
|
|
@@ -2137,8 +2326,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
|
|
|
2137
2326
|
${templateContent}`;
|
|
2138
2327
|
const destPath = hausPath(root, "WORKFLOW.md");
|
|
2139
2328
|
const printable = displayPath(root, destPath);
|
|
2140
|
-
if (await
|
|
2141
|
-
const existing = await
|
|
2329
|
+
if (await fs13.pathExists(destPath)) {
|
|
2330
|
+
const existing = await fs13.readFile(destPath, "utf8");
|
|
2142
2331
|
const firstLine = existing.split("\n")[0] ?? "";
|
|
2143
2332
|
const parsed = parseHausManagedHeader(firstLine);
|
|
2144
2333
|
if (!parsed) {
|
|
@@ -2156,15 +2345,32 @@ ${templateContent}`;
|
|
|
2156
2345
|
return null;
|
|
2157
2346
|
}
|
|
2158
2347
|
const existingContent = existing.slice(firstLine.length + 1);
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
)
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2348
|
+
const bodyMatchesTemplate = hashText(normaliseLF(existingContent)) === contentHash;
|
|
2349
|
+
if (parsed.hash) {
|
|
2350
|
+
const bodyIntact = hashText(normaliseLF(existingContent)) === parsed.hash;
|
|
2351
|
+
if (!bodyIntact && !force) {
|
|
2352
|
+
warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
|
|
2353
|
+
return null;
|
|
2354
|
+
}
|
|
2355
|
+
} else {
|
|
2356
|
+
if (!bodyMatchesTemplate && !force) {
|
|
2357
|
+
const migratedHeader = makeWorkflowHeader(
|
|
2358
|
+
pkgVersion,
|
|
2359
|
+
hashText(normaliseLF(existingContent))
|
|
2360
|
+
);
|
|
2361
|
+
const migrated = `${migratedHeader}
|
|
2362
|
+
${existingContent}`;
|
|
2363
|
+
if (hasTextChanged(existing, migrated)) {
|
|
2364
|
+
if (dryRun) {
|
|
2365
|
+
log(`${printable}: migrating legacy hash header (body preserved)`);
|
|
2366
|
+
} else {
|
|
2367
|
+
await writeText(destPath, migrated);
|
|
2368
|
+
}
|
|
2369
|
+
} else if (dryRun) {
|
|
2370
|
+
log(`${printable}: unchanged`);
|
|
2371
|
+
}
|
|
2372
|
+
return destPath;
|
|
2373
|
+
}
|
|
2168
2374
|
}
|
|
2169
2375
|
if (!hasTextChanged(existing, next)) {
|
|
2170
2376
|
if (dryRun) log(`${printable}: unchanged`);
|
|
@@ -2172,7 +2378,7 @@ ${templateContent}`;
|
|
|
2172
2378
|
}
|
|
2173
2379
|
}
|
|
2174
2380
|
if (dryRun) {
|
|
2175
|
-
const prev = await
|
|
2381
|
+
const prev = await fs13.pathExists(destPath) ? await fs13.readFile(destPath, "utf8") : "";
|
|
2176
2382
|
if (!prev) {
|
|
2177
2383
|
log(createUnifiedDiff(printable, "", next));
|
|
2178
2384
|
} else {
|
|
@@ -2195,6 +2401,8 @@ function targetDirForType(type) {
|
|
|
2195
2401
|
return null;
|
|
2196
2402
|
}
|
|
2197
2403
|
async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
2404
|
+
const say = opts.quiet ? () => {
|
|
2405
|
+
} : log;
|
|
2198
2406
|
const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
|
|
2199
2407
|
recommended: [],
|
|
2200
2408
|
skipped: [],
|
|
@@ -2205,13 +2413,14 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2205
2413
|
estimatedTokenReductionPct: 0
|
|
2206
2414
|
};
|
|
2207
2415
|
const pkgRoot = packageRoot();
|
|
2208
|
-
const hausVersion2 = (await readJson(
|
|
2416
|
+
const hausVersion2 = (await readJson(path14.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
|
|
2209
2417
|
const coreFiles = [
|
|
2210
2418
|
claudePath(root, "settings.json"),
|
|
2211
2419
|
claudePath(root, "rules", "haus.md"),
|
|
2212
2420
|
claudePath(root, "commands", "haus-doctor.md")
|
|
2213
2421
|
];
|
|
2214
2422
|
const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
|
|
2423
|
+
const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
|
|
2215
2424
|
const workflowPath = await writeWorkflow(root, hausVersion2, dryRun, opts.force);
|
|
2216
2425
|
const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
|
|
2217
2426
|
refill: opts.refillConfig
|
|
@@ -2219,6 +2428,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2219
2428
|
const prettierIgnorePath = await writePrettierIgnore(root, dryRun);
|
|
2220
2429
|
const p6Files = [
|
|
2221
2430
|
rootClaudeMdPath,
|
|
2431
|
+
...decisionsSeedPath ? [decisionsSeedPath] : [],
|
|
2222
2432
|
...workflowPath ? [workflowPath] : [],
|
|
2223
2433
|
...workflowConfigPath ? [workflowConfigPath] : [],
|
|
2224
2434
|
prettierIgnorePath
|
|
@@ -2238,16 +2448,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2238
2448
|
dryRun
|
|
2239
2449
|
);
|
|
2240
2450
|
const legacyReviewPath = claudePath(root, "commands", "haus-review.md");
|
|
2241
|
-
if (await
|
|
2242
|
-
const content2 = await
|
|
2451
|
+
if (await fs14.pathExists(legacyReviewPath)) {
|
|
2452
|
+
const content2 = await fs14.readFile(legacyReviewPath, "utf8");
|
|
2243
2453
|
const stub = 'Run `haus context --task "code review"` then review diff.';
|
|
2244
2454
|
if (content2 === stub || content2 === `${stub}
|
|
2245
2455
|
` || content2 === `${stub}\r
|
|
2246
2456
|
`) {
|
|
2247
2457
|
if (dryRun) {
|
|
2248
|
-
|
|
2458
|
+
say(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
|
|
2249
2459
|
} else {
|
|
2250
|
-
await
|
|
2460
|
+
await fs14.remove(legacyReviewPath);
|
|
2251
2461
|
}
|
|
2252
2462
|
}
|
|
2253
2463
|
}
|
|
@@ -2275,16 +2485,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2275
2485
|
dryRun
|
|
2276
2486
|
);
|
|
2277
2487
|
const legacySecurityPath = claudePath(root, "rules", "security.md");
|
|
2278
|
-
if (await
|
|
2279
|
-
const content2 = await
|
|
2488
|
+
if (await fs14.pathExists(legacySecurityPath)) {
|
|
2489
|
+
const content2 = await fs14.readFile(legacySecurityPath, "utf8");
|
|
2280
2490
|
const stub = "- Never read secrets.\n- Block dangerous shell commands.";
|
|
2281
2491
|
if (content2 === stub || content2 === `${stub}
|
|
2282
2492
|
` || content2 === `${stub}\r
|
|
2283
2493
|
`) {
|
|
2284
2494
|
if (dryRun) {
|
|
2285
|
-
|
|
2495
|
+
say(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
|
|
2286
2496
|
} else {
|
|
2287
|
-
await
|
|
2497
|
+
await fs14.remove(legacySecurityPath);
|
|
2288
2498
|
}
|
|
2289
2499
|
}
|
|
2290
2500
|
}
|
|
@@ -2299,11 +2509,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2299
2509
|
];
|
|
2300
2510
|
for (const rel of LEGACY_PRUNED_ARTIFACTS) {
|
|
2301
2511
|
const legacyPath = hausPath(root, rel);
|
|
2302
|
-
if (await
|
|
2512
|
+
if (await fs14.pathExists(legacyPath)) {
|
|
2303
2513
|
if (dryRun) {
|
|
2304
|
-
|
|
2514
|
+
say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
|
|
2305
2515
|
} else {
|
|
2306
|
-
await
|
|
2516
|
+
await fs14.remove(legacyPath);
|
|
2307
2517
|
}
|
|
2308
2518
|
}
|
|
2309
2519
|
}
|
|
@@ -2345,30 +2555,69 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2345
2555
|
);
|
|
2346
2556
|
continue;
|
|
2347
2557
|
}
|
|
2348
|
-
const destination = claudePath(root, target,
|
|
2349
|
-
if (await
|
|
2558
|
+
const destination = claudePath(root, target, path14.basename(sourcePath));
|
|
2559
|
+
if (await fs14.pathExists(sourcePath)) {
|
|
2350
2560
|
if (dryRun) {
|
|
2351
|
-
const exists = await
|
|
2352
|
-
|
|
2561
|
+
const exists = await fs14.pathExists(destination);
|
|
2562
|
+
say(
|
|
2353
2563
|
`${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
|
|
2354
2564
|
);
|
|
2355
2565
|
} else if (item.type === "skill") {
|
|
2566
|
+
const skillFiles = (await fs14.readdir(sourcePath, { recursive: true })).filter(
|
|
2567
|
+
(f) => typeof f === "string" && f.endsWith(".md")
|
|
2568
|
+
);
|
|
2569
|
+
let skillValid = true;
|
|
2570
|
+
for (const relFile of skillFiles) {
|
|
2571
|
+
const mdPath = path14.join(sourcePath, relFile);
|
|
2572
|
+
const mdContent = await fs14.readFile(mdPath, "utf8");
|
|
2573
|
+
const mdValidation = validateCatalogItem(
|
|
2574
|
+
{
|
|
2575
|
+
id: manifestItem.id,
|
|
2576
|
+
type: "skill",
|
|
2577
|
+
path: manifestItem.path,
|
|
2578
|
+
source: manifestItem.source
|
|
2579
|
+
},
|
|
2580
|
+
mdContent
|
|
2581
|
+
);
|
|
2582
|
+
if (!mdValidation.ok) {
|
|
2583
|
+
warn(
|
|
2584
|
+
`Skipping ${item.id}: pre-copy validation failed (${relFile}) \u2014 ${mdValidation.reason}`
|
|
2585
|
+
);
|
|
2586
|
+
skillValid = false;
|
|
2587
|
+
break;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
if (!skillValid) continue;
|
|
2356
2591
|
await installCatalogSkill(sourcePath, destination, {
|
|
2357
2592
|
originSourceId: manifestItem.originSourceId,
|
|
2358
2593
|
dryRun: false
|
|
2359
2594
|
});
|
|
2360
2595
|
} else {
|
|
2361
|
-
await
|
|
2362
|
-
|
|
2596
|
+
const fileContent = await fs14.readFile(sourcePath, "utf8");
|
|
2597
|
+
const validation = validateCatalogItem(
|
|
2598
|
+
{
|
|
2599
|
+
id: manifestItem.id,
|
|
2600
|
+
type: manifestItem.type,
|
|
2601
|
+
path: manifestItem.path,
|
|
2602
|
+
source: manifestItem.source
|
|
2603
|
+
},
|
|
2604
|
+
fileContent
|
|
2605
|
+
);
|
|
2606
|
+
if (!validation.ok) {
|
|
2607
|
+
warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
|
|
2608
|
+
continue;
|
|
2609
|
+
}
|
|
2610
|
+
await fs14.ensureDir(path14.dirname(destination));
|
|
2611
|
+
await fs14.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
2363
2612
|
}
|
|
2364
2613
|
files.push(destination);
|
|
2365
|
-
const relPaths = [
|
|
2614
|
+
const relPaths = [path14.relative(root, destination)];
|
|
2366
2615
|
if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
|
|
2367
2616
|
const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
|
|
2368
2617
|
if (sharedRel) {
|
|
2369
2618
|
superpowersSharedInstalled = true;
|
|
2370
2619
|
relPaths.push(sharedRel);
|
|
2371
|
-
files.push(
|
|
2620
|
+
files.push(path14.join(root, sharedRel));
|
|
2372
2621
|
}
|
|
2373
2622
|
}
|
|
2374
2623
|
const current = installedPathsByItem.get(item.id) ?? [];
|
|
@@ -2385,7 +2634,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2385
2634
|
`${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
|
|
2386
2635
|
);
|
|
2387
2636
|
}
|
|
2388
|
-
await cleanupStaleCatalogItems(root, manifestById, dryRun);
|
|
2637
|
+
await cleanupStaleCatalogItems(root, manifestById, dryRun, opts.quiet);
|
|
2389
2638
|
if (dryRun) return [...new Set(files)];
|
|
2390
2639
|
const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
|
|
2391
2640
|
const prevLock = await readJson(hausPath(root, "haus.lock.json"));
|
|
@@ -2422,7 +2671,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2422
2671
|
await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
|
|
2423
2672
|
return [...new Set(files)];
|
|
2424
2673
|
}
|
|
2425
|
-
async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
|
|
2674
|
+
async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
|
|
2675
|
+
const say = quiet ? () => {
|
|
2676
|
+
} : log;
|
|
2426
2677
|
const prevLock = await readJson(hausPath(root, "haus.lock.json"));
|
|
2427
2678
|
if (!prevLock?.length) return;
|
|
2428
2679
|
for (const entry of prevLock) {
|
|
@@ -2435,7 +2686,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
|
|
|
2435
2686
|
if (relPaths.length === 0) continue;
|
|
2436
2687
|
const existing = [];
|
|
2437
2688
|
for (const rel of relPaths) {
|
|
2438
|
-
if (await
|
|
2689
|
+
if (await fs14.pathExists(path14.join(root, rel))) existing.push(rel);
|
|
2439
2690
|
}
|
|
2440
2691
|
if (existing.length === 0) continue;
|
|
2441
2692
|
const pruneReason = deprecated ? "deprecated" : "stale";
|
|
@@ -2453,18 +2704,115 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
|
|
|
2453
2704
|
continue;
|
|
2454
2705
|
}
|
|
2455
2706
|
for (const rel of existing) {
|
|
2456
|
-
const abs =
|
|
2707
|
+
const abs = path14.join(root, rel);
|
|
2457
2708
|
if (dryRun) {
|
|
2458
|
-
|
|
2709
|
+
say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
2459
2710
|
continue;
|
|
2460
2711
|
}
|
|
2461
|
-
await
|
|
2462
|
-
await pruneEmptyDir(
|
|
2463
|
-
|
|
2712
|
+
await fs14.remove(abs);
|
|
2713
|
+
await pruneEmptyDir(path14.dirname(abs));
|
|
2714
|
+
say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
2464
2715
|
}
|
|
2465
2716
|
}
|
|
2466
2717
|
}
|
|
2467
2718
|
|
|
2719
|
+
// src/refs/fetch-refs.ts
|
|
2720
|
+
import path16 from "node:path";
|
|
2721
|
+
import fs16 from "fs-extra";
|
|
2722
|
+
|
|
2723
|
+
// src/refs/cache-meta.ts
|
|
2724
|
+
import path15 from "node:path";
|
|
2725
|
+
import fs15 from "fs-extra";
|
|
2726
|
+
var META_FILENAME = "cache-meta.json";
|
|
2727
|
+
async function readCacheMeta(cacheDir) {
|
|
2728
|
+
const metaPath = path15.join(cacheDir, META_FILENAME);
|
|
2729
|
+
try {
|
|
2730
|
+
const raw = await fs15.readFile(metaPath, "utf8");
|
|
2731
|
+
return JSON.parse(raw);
|
|
2732
|
+
} catch {
|
|
2733
|
+
return {};
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
async function writeCacheMeta(cacheDir, meta) {
|
|
2737
|
+
await fs15.ensureDir(cacheDir);
|
|
2738
|
+
const metaPath = path15.join(cacheDir, META_FILENAME);
|
|
2739
|
+
await fs15.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// src/refs/fetch-refs.ts
|
|
2743
|
+
function urlToSlug(url) {
|
|
2744
|
+
const u = new URL(url);
|
|
2745
|
+
return (u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
2746
|
+
}
|
|
2747
|
+
function isLlmsTxtUrl(ref) {
|
|
2748
|
+
try {
|
|
2749
|
+
const u = new URL(ref);
|
|
2750
|
+
return (u.protocol === "https:" || u.protocol === "http:") && u.pathname.endsWith("/llms.txt");
|
|
2751
|
+
} catch {
|
|
2752
|
+
return false;
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
async function fetchSingleRef(url, cacheDir, meta) {
|
|
2756
|
+
const existing = meta[url];
|
|
2757
|
+
const headers = {};
|
|
2758
|
+
const safeFile = existing?.file && existing.file === path16.basename(existing.file);
|
|
2759
|
+
const cachedFileExists = safeFile && await fs16.pathExists(path16.join(cacheDir, existing.file));
|
|
2760
|
+
if (cachedFileExists) {
|
|
2761
|
+
if (existing?.etag) headers["If-None-Match"] = existing.etag;
|
|
2762
|
+
if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
|
|
2763
|
+
}
|
|
2764
|
+
try {
|
|
2765
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
|
|
2766
|
+
if (res.status === 304) return { result: "unchanged" };
|
|
2767
|
+
if (!res.ok) return { result: "failed" };
|
|
2768
|
+
const text = await res.text();
|
|
2769
|
+
const file = `${urlToSlug(url)}.md`;
|
|
2770
|
+
await fs16.ensureDir(cacheDir);
|
|
2771
|
+
await fs16.writeFile(path16.join(cacheDir, file), text, "utf8");
|
|
2772
|
+
meta[url] = {
|
|
2773
|
+
url,
|
|
2774
|
+
etag: res.headers.get("etag") ?? void 0,
|
|
2775
|
+
lastModified: res.headers.get("last-modified") ?? void 0,
|
|
2776
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2777
|
+
file
|
|
2778
|
+
};
|
|
2779
|
+
return { result: "fetched", file };
|
|
2780
|
+
} catch {
|
|
2781
|
+
return { result: "failed" };
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
async function fetchRefsForItems(items, cacheDir) {
|
|
2785
|
+
const urls = [...new Set(items.flatMap((item) => (item.references ?? []).filter(isLlmsTxtUrl)))];
|
|
2786
|
+
const summary = {
|
|
2787
|
+
fetched: 0,
|
|
2788
|
+
unchanged: 0,
|
|
2789
|
+
failed: 0,
|
|
2790
|
+
failedUrls: [],
|
|
2791
|
+
cachedFiles: {}
|
|
2792
|
+
};
|
|
2793
|
+
if (urls.length === 0) return summary;
|
|
2794
|
+
const meta = await readCacheMeta(cacheDir);
|
|
2795
|
+
await Promise.all(
|
|
2796
|
+
urls.map(async (url) => {
|
|
2797
|
+
const outcome = await fetchSingleRef(url, cacheDir, meta);
|
|
2798
|
+
if (outcome.result === "fetched") {
|
|
2799
|
+
summary.fetched++;
|
|
2800
|
+
summary.cachedFiles[url] = path16.join(cacheDir, outcome.file);
|
|
2801
|
+
} else if (outcome.result === "unchanged") {
|
|
2802
|
+
summary.unchanged++;
|
|
2803
|
+
if (meta[url]?.file) summary.cachedFiles[url] = path16.join(cacheDir, meta[url].file);
|
|
2804
|
+
} else {
|
|
2805
|
+
summary.failed++;
|
|
2806
|
+
summary.failedUrls.push(url);
|
|
2807
|
+
}
|
|
2808
|
+
})
|
|
2809
|
+
);
|
|
2810
|
+
if (summary.fetched > 0) {
|
|
2811
|
+
await writeCacheMeta(cacheDir, meta);
|
|
2812
|
+
}
|
|
2813
|
+
return summary;
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2468
2816
|
// src/utils/args.ts
|
|
2469
2817
|
function parseIdList(value) {
|
|
2470
2818
|
if (!value) return [];
|
|
@@ -2474,7 +2822,7 @@ function parseIdList(value) {
|
|
|
2474
2822
|
|
|
2475
2823
|
// src/commands/apply.ts
|
|
2476
2824
|
async function cacheHasItems() {
|
|
2477
|
-
const data = await readJson(
|
|
2825
|
+
const data = await readJson(path17.join(getCacheDir(), "manifest.json"));
|
|
2478
2826
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
2479
2827
|
}
|
|
2480
2828
|
function installableRecommendedItems(recommended) {
|
|
@@ -2566,6 +2914,23 @@ async function runApply(options) {
|
|
|
2566
2914
|
refillConfig: options.refillConfig,
|
|
2567
2915
|
force: options.force
|
|
2568
2916
|
});
|
|
2917
|
+
if (!isDryRun) {
|
|
2918
|
+
try {
|
|
2919
|
+
const cacheDir = hausPath(root, "llms-cache");
|
|
2920
|
+
const allItems = await loadCatalog(root);
|
|
2921
|
+
const summary = await fetchRefsForItems(allItems, cacheDir);
|
|
2922
|
+
if (summary.fetched > 0)
|
|
2923
|
+
log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
|
|
2924
|
+
if (summary.failed > 0)
|
|
2925
|
+
warn(
|
|
2926
|
+
`Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
|
|
2927
|
+
);
|
|
2928
|
+
} catch (err) {
|
|
2929
|
+
warn(
|
|
2930
|
+
`Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
|
|
2931
|
+
);
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2569
2934
|
if (isDryRun) {
|
|
2570
2935
|
log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
|
|
2571
2936
|
} else {
|
|
@@ -2579,7 +2944,7 @@ function auditForbiddenTags(items) {
|
|
|
2579
2944
|
const failures = [];
|
|
2580
2945
|
const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
|
|
2581
2946
|
for (const item of items) {
|
|
2582
|
-
for (const tag of item.tags) {
|
|
2947
|
+
for (const tag of Array.isArray(item.tags) ? item.tags : []) {
|
|
2583
2948
|
if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
|
|
2584
2949
|
}
|
|
2585
2950
|
for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
|
|
@@ -2600,84 +2965,509 @@ async function runCatalogAudit() {
|
|
|
2600
2965
|
}
|
|
2601
2966
|
|
|
2602
2967
|
// src/commands/clone.ts
|
|
2968
|
+
init_exec();
|
|
2603
2969
|
import { existsSync as existsSync2 } from "node:fs";
|
|
2604
|
-
import
|
|
2970
|
+
import path18 from "node:path";
|
|
2971
|
+
var GIT_LOCATION_VARS = [
|
|
2972
|
+
"GIT_DIR",
|
|
2973
|
+
"GIT_WORK_TREE",
|
|
2974
|
+
"GIT_INDEX_FILE",
|
|
2975
|
+
"GIT_COMMON_DIR",
|
|
2976
|
+
"GIT_OBJECT_DIRECTORY",
|
|
2977
|
+
"GIT_NAMESPACE",
|
|
2978
|
+
"GIT_PREFIX"
|
|
2979
|
+
];
|
|
2980
|
+
function cloneEnv() {
|
|
2981
|
+
const env = { ...process.env };
|
|
2982
|
+
for (const key of GIT_LOCATION_VARS) delete env[key];
|
|
2983
|
+
return env;
|
|
2984
|
+
}
|
|
2985
|
+
function repoNameFromUrl(url) {
|
|
2986
|
+
const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
|
|
2987
|
+
const tail = trimmed.split(/[/:]/).pop() ?? "";
|
|
2988
|
+
return tail || "repo";
|
|
2989
|
+
}
|
|
2990
|
+
async function runClone(url, opts = {}) {
|
|
2991
|
+
if (!url || !url.trim()) {
|
|
2992
|
+
error("A git URL is required: `haus clone <url> [dir]`.");
|
|
2993
|
+
process.exitCode = 1;
|
|
2994
|
+
return;
|
|
2995
|
+
}
|
|
2996
|
+
const target = path18.resolve(opts.dir?.trim() || repoNameFromUrl(url));
|
|
2997
|
+
if (existsSync2(target)) {
|
|
2998
|
+
log(`\u2022 ${path18.basename(target)} already present at ${target} \u2014 skipped`);
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
if (opts.dryRun) {
|
|
3002
|
+
log(`would clone ${url} \u2192 ${target}`);
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
3005
|
+
const res = await runGit(["clone", "--", url, target], { env: cloneEnv(), extendEnv: false });
|
|
3006
|
+
if (res.exitCode !== 0) {
|
|
3007
|
+
error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
|
|
3008
|
+
process.exitCode = 1;
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
log(`\u2713 cloned ${url} \u2192 ${target}`);
|
|
3012
|
+
}
|
|
2605
3013
|
|
|
2606
|
-
// src/
|
|
2607
|
-
import
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
3014
|
+
// src/commands/decisions.ts
|
|
3015
|
+
import path22 from "node:path";
|
|
3016
|
+
|
|
3017
|
+
// src/decisions/check.ts
|
|
3018
|
+
import path21 from "node:path";
|
|
3019
|
+
import fs19 from "fs-extra";
|
|
3020
|
+
|
|
3021
|
+
// src/decisions/diff.ts
|
|
3022
|
+
init_exec();
|
|
3023
|
+
async function collectDiffStats(root, opts) {
|
|
3024
|
+
const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
|
|
3025
|
+
const names = await runGit(gitArgs, { cwd: root });
|
|
3026
|
+
const files = names.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
3027
|
+
const numstatArgs = opts.staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat", opts.range ?? "HEAD"];
|
|
3028
|
+
const numstat = await runGit(numstatArgs, { cwd: root });
|
|
3029
|
+
let linesAdded = 0;
|
|
3030
|
+
let linesRemoved = 0;
|
|
3031
|
+
for (const line2 of numstat.stdout.split("\n")) {
|
|
3032
|
+
const parts = line2.trim().split(/\s+/);
|
|
3033
|
+
if (parts.length < 3) continue;
|
|
3034
|
+
const added = parts[0] === "-" ? 0 : Number(parts[0]);
|
|
3035
|
+
const removed = parts[1] === "-" ? 0 : Number(parts[1]);
|
|
3036
|
+
if (!Number.isNaN(added)) linesAdded += added;
|
|
3037
|
+
if (!Number.isNaN(removed)) linesRemoved += removed;
|
|
3038
|
+
}
|
|
3039
|
+
return { files, linesAdded, linesRemoved };
|
|
3040
|
+
}
|
|
3041
|
+
|
|
3042
|
+
// src/decisions/match.ts
|
|
3043
|
+
function segmentToRegex(segment) {
|
|
3044
|
+
if (segment === "**") return ".*";
|
|
3045
|
+
return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
|
3046
|
+
}
|
|
3047
|
+
function globToRegExp(pattern) {
|
|
3048
|
+
const pat = pattern.replace(/\\/g, "/");
|
|
3049
|
+
if (pat.startsWith("**/") && pat.endsWith("/**")) {
|
|
3050
|
+
const middle = pat.slice(3, -3);
|
|
3051
|
+
const midRe = middle.split("/").map(segmentToRegex).join("/");
|
|
3052
|
+
return new RegExp(`(^|/)${midRe}(/|$)`);
|
|
3053
|
+
}
|
|
3054
|
+
if (pat.startsWith("**/")) {
|
|
3055
|
+
const rest = pat.slice(3).split("/").map(segmentToRegex).join("/");
|
|
3056
|
+
return new RegExp(`^(?:.*/)?${rest}$`);
|
|
3057
|
+
}
|
|
3058
|
+
const reSource = pat.split("/").map(segmentToRegex).join("/");
|
|
3059
|
+
return new RegExp(`^${reSource}$`);
|
|
3060
|
+
}
|
|
3061
|
+
function matchesPathGlob(filePath, pattern) {
|
|
3062
|
+
const norm = filePath.replace(/\\/g, "/");
|
|
3063
|
+
return globToRegExp(pattern).test(norm);
|
|
3064
|
+
}
|
|
3065
|
+
function matchesAnyGlob(filePath, patterns) {
|
|
3066
|
+
return patterns.some((p) => matchesPathGlob(filePath, p));
|
|
3067
|
+
}
|
|
3068
|
+
function matchesAnyRegex(filePath, patterns) {
|
|
3069
|
+
const norm = filePath.replace(/\\/g, "/");
|
|
3070
|
+
return patterns.some((p) => new RegExp(p).test(norm));
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
// src/decisions/paths.ts
|
|
3074
|
+
import path19 from "node:path";
|
|
3075
|
+
import fs17 from "fs-extra";
|
|
3076
|
+
async function resolveDecisionsDir(root) {
|
|
3077
|
+
const primary = path19.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
3078
|
+
if (await fs17.pathExists(primary)) return primary;
|
|
3079
|
+
const legacy = path19.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
3080
|
+
if (await fs17.pathExists(legacy)) return legacy;
|
|
3081
|
+
return primary;
|
|
3082
|
+
}
|
|
3083
|
+
function relativeDecisionPath(decisionsDir, root) {
|
|
3084
|
+
return path19.relative(root, decisionsDir).replace(/\\/g, "/");
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
// src/decisions/validate.ts
|
|
3088
|
+
import path20 from "node:path";
|
|
3089
|
+
import fs18 from "fs-extra";
|
|
3090
|
+
function validateDecisionContent(content2) {
|
|
3091
|
+
const errors = [];
|
|
3092
|
+
for (const section of DECISIONS_TRIGGERS.requiredSections) {
|
|
3093
|
+
if (!content2.includes(section)) {
|
|
3094
|
+
errors.push(`missing required section: ${section}`);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
if (!/^#\s+ADR-\d{4}:/m.test(content2)) {
|
|
3098
|
+
errors.push('heading must match "# ADR-NNNN: Title"');
|
|
3099
|
+
}
|
|
3100
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
3101
|
+
}
|
|
3102
|
+
function indexReferencesNumber(readme, number) {
|
|
3103
|
+
const padded = number.padStart(4, "0");
|
|
3104
|
+
return readme.includes(`[${padded}]`) || readme.includes(`(${padded}-`) || readme.includes(`ADR-${padded}`);
|
|
3105
|
+
}
|
|
3106
|
+
async function validateDecisionFile(filePath) {
|
|
3107
|
+
const content2 = await readText(filePath) ?? "";
|
|
3108
|
+
return validateDecisionContent(content2);
|
|
3109
|
+
}
|
|
3110
|
+
function isDecisionFileName(name) {
|
|
3111
|
+
return DECISION_FILE_RE.test(name);
|
|
3112
|
+
}
|
|
3113
|
+
function decisionNumberFromFile(name) {
|
|
3114
|
+
const match = /^(\d{4})-/.exec(name);
|
|
3115
|
+
return match?.[1] ?? null;
|
|
3116
|
+
}
|
|
3117
|
+
async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
3118
|
+
const readmePath = path20.join(decisionsDir, "README.md");
|
|
3119
|
+
if (!await fs18.pathExists(readmePath)) {
|
|
3120
|
+
return ["docs/decisions/README.md is missing"];
|
|
3121
|
+
}
|
|
3122
|
+
const readme = await readText(readmePath) ?? "";
|
|
3123
|
+
const errors = [];
|
|
3124
|
+
for (const file of decisionFiles) {
|
|
3125
|
+
const num = decisionNumberFromFile(path20.basename(file));
|
|
3126
|
+
if (num && !indexReferencesNumber(readme, num)) {
|
|
3127
|
+
errors.push(`README.md missing index row for ADR-${num}`);
|
|
3128
|
+
}
|
|
3129
|
+
}
|
|
3130
|
+
return errors;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
// src/decisions/check.ts
|
|
3134
|
+
function isExempt(file) {
|
|
3135
|
+
return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs);
|
|
3136
|
+
}
|
|
3137
|
+
function countTopLevelDirs(files) {
|
|
3138
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
3139
|
+
for (const f of files) {
|
|
3140
|
+
const parts = f.split("/");
|
|
3141
|
+
if (parts.length > 1) dirs.add(parts[0]);
|
|
3142
|
+
}
|
|
3143
|
+
return dirs.size;
|
|
3144
|
+
}
|
|
3145
|
+
function isDecisionTriggered(stats) {
|
|
3146
|
+
const relevant = stats.files.filter((f) => !isExempt(f));
|
|
3147
|
+
if (relevant.length === 0) return false;
|
|
3148
|
+
for (const file of relevant) {
|
|
3149
|
+
if (matchesAnyGlob(file, DECISIONS_TRIGGERS.pathGlobs)) return true;
|
|
3150
|
+
if (matchesAnyRegex(file, DECISIONS_TRIGGERS.pathRegex)) return true;
|
|
3151
|
+
}
|
|
3152
|
+
const totalLines = stats.linesAdded + stats.linesRemoved;
|
|
3153
|
+
if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && countTopLevelDirs(relevant) >= 2) {
|
|
3154
|
+
return true;
|
|
3155
|
+
}
|
|
3156
|
+
if (totalLines >= DECISIONS_TRIGGERS.minLinesChanged && relevant.some((f) => !f.startsWith("docs/"))) {
|
|
3157
|
+
return true;
|
|
3158
|
+
}
|
|
3159
|
+
return false;
|
|
3160
|
+
}
|
|
3161
|
+
function isSecurityTriggered(stats) {
|
|
3162
|
+
const relevant = stats.files.filter((f) => !isExempt(f));
|
|
3163
|
+
const security = DECISIONS_TRIGGERS.securityPathGlobs ?? [];
|
|
3164
|
+
return relevant.some(
|
|
3165
|
+
(f) => matchesAnyGlob(f, security) || matchesAnyRegex(f, DECISIONS_TRIGGERS.pathRegex)
|
|
3166
|
+
);
|
|
3167
|
+
}
|
|
3168
|
+
async function readGateMode(root) {
|
|
3169
|
+
const cfg = await readJson(hausPath(root, "adr-gate.json"));
|
|
3170
|
+
return cfg?.mode === "off" ? "off" : "on";
|
|
3171
|
+
}
|
|
3172
|
+
function hasSkipToken(text) {
|
|
3173
|
+
return text.includes("[adr-skip]");
|
|
3174
|
+
}
|
|
3175
|
+
async function checkSatisfaction(input2) {
|
|
3176
|
+
const errors = [];
|
|
3177
|
+
const decisionsPrefix = `${input2.decisionsRel}/`;
|
|
3178
|
+
const newDecisionFiles = input2.changedFiles.filter(
|
|
3179
|
+
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path21.basename(f))
|
|
3180
|
+
);
|
|
3181
|
+
if (newDecisionFiles.length === 0) {
|
|
3182
|
+
errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
|
|
3183
|
+
return errors;
|
|
3184
|
+
}
|
|
3185
|
+
const decisionsDir = path21.join(input2.root, input2.decisionsRel);
|
|
3186
|
+
for (const rel of newDecisionFiles) {
|
|
3187
|
+
const abs = path21.join(input2.root, rel);
|
|
3188
|
+
const content2 = await readText(abs) ?? "";
|
|
3189
|
+
const valid = validateDecisionContent(content2);
|
|
3190
|
+
if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
|
|
3191
|
+
}
|
|
3192
|
+
const indexErrors = await validateIndexSync(
|
|
3193
|
+
decisionsDir,
|
|
3194
|
+
newDecisionFiles.map((f) => path21.basename(f))
|
|
3195
|
+
);
|
|
3196
|
+
errors.push(...indexErrors);
|
|
3197
|
+
return errors;
|
|
3198
|
+
}
|
|
3199
|
+
async function runDecisionsCheck(root, opts = {}) {
|
|
3200
|
+
const reasons = [];
|
|
3201
|
+
if (await readGateMode(root) === "off") {
|
|
2615
3202
|
return {
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
3203
|
+
triggered: false,
|
|
3204
|
+
satisfied: true,
|
|
3205
|
+
reasons: ["gate disabled via .haus-workflow/adr-gate.json"],
|
|
3206
|
+
securityTriggered: false
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
const stats = await collectDiffStats(root, opts);
|
|
3210
|
+
const triggered = isDecisionTriggered(stats);
|
|
3211
|
+
const securityTriggered = isSecurityTriggered(stats);
|
|
3212
|
+
if (!triggered) {
|
|
3213
|
+
return {
|
|
3214
|
+
triggered: false,
|
|
3215
|
+
satisfied: true,
|
|
3216
|
+
reasons: ["no decision-worthy changes"],
|
|
3217
|
+
securityTriggered
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
const skipText = `${opts.prBody ?? ""}
|
|
3221
|
+
${opts.commitMessages ?? ""}`;
|
|
3222
|
+
if (hasSkipToken(skipText) && !securityTriggered) {
|
|
3223
|
+
reasons.push("[adr-skip] present \u2014 gate waived (non-security change)");
|
|
3224
|
+
return { triggered: true, satisfied: true, reasons, securityTriggered };
|
|
3225
|
+
}
|
|
3226
|
+
if (hasSkipToken(skipText) && securityTriggered) {
|
|
3227
|
+
reasons.push("[adr-skip] ignored for security/auth path changes");
|
|
3228
|
+
}
|
|
3229
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3230
|
+
const decisionsRel = relativeDecisionPath(decisionsDir, root);
|
|
3231
|
+
const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
|
|
3232
|
+
let satisfactionErrors = await checkSatisfaction({
|
|
3233
|
+
root,
|
|
3234
|
+
decisionsRel,
|
|
3235
|
+
changedFiles: stats.files
|
|
3236
|
+
});
|
|
3237
|
+
if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
|
|
3238
|
+
const legacyExists = await fs19.pathExists(path21.join(root, legacyRel));
|
|
3239
|
+
if (legacyExists && !await fs19.pathExists(decisionsDir)) {
|
|
3240
|
+
satisfactionErrors = await checkSatisfaction({
|
|
3241
|
+
root,
|
|
3242
|
+
decisionsRel: legacyRel,
|
|
3243
|
+
changedFiles: stats.files
|
|
3244
|
+
});
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
if (satisfactionErrors.length === 0) {
|
|
3248
|
+
return {
|
|
3249
|
+
triggered: true,
|
|
3250
|
+
satisfied: true,
|
|
3251
|
+
reasons: ["decision record present"],
|
|
3252
|
+
securityTriggered
|
|
2621
3253
|
};
|
|
2622
|
-
} catch (error2) {
|
|
2623
|
-
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
2624
|
-
throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
|
|
2625
3254
|
}
|
|
3255
|
+
reasons.push(...satisfactionErrors);
|
|
3256
|
+
return { triggered: true, satisfied: false, reasons, securityTriggered };
|
|
2626
3257
|
}
|
|
2627
|
-
async function
|
|
2628
|
-
|
|
3258
|
+
async function listDecisionNumbers(decisionsDir) {
|
|
3259
|
+
if (!await fs19.pathExists(decisionsDir)) return [];
|
|
3260
|
+
const entries = await fs19.readdir(decisionsDir);
|
|
3261
|
+
return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
|
|
2629
3262
|
}
|
|
2630
3263
|
|
|
2631
|
-
// src/
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
"
|
|
2637
|
-
"GIT_OBJECT_DIRECTORY",
|
|
2638
|
-
"GIT_NAMESPACE",
|
|
2639
|
-
"GIT_PREFIX"
|
|
2640
|
-
];
|
|
2641
|
-
function cloneEnv() {
|
|
2642
|
-
const env = { ...process.env };
|
|
2643
|
-
for (const key of GIT_LOCATION_VARS) delete env[key];
|
|
2644
|
-
return env;
|
|
3264
|
+
// src/decisions/next-number.ts
|
|
3265
|
+
async function nextDecisionNumber(root) {
|
|
3266
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3267
|
+
const numbers = await listDecisionNumbers(decisionsDir);
|
|
3268
|
+
const max = numbers.length === 0 ? 0 : Math.max(...numbers);
|
|
3269
|
+
return String(max + 1).padStart(4, "0");
|
|
2645
3270
|
}
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3271
|
+
|
|
3272
|
+
// src/decisions/suggest.ts
|
|
3273
|
+
function draftTemplate(number, title) {
|
|
3274
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3275
|
+
return `# ADR-${number}: ${title}
|
|
3276
|
+
|
|
3277
|
+
- **Status:** Proposed
|
|
3278
|
+
- **Date:** ${today}
|
|
3279
|
+
- **Decided by:** _(pending approval)_ (draft by haus decisions suggest)
|
|
3280
|
+
- **Affects:** _(paths/components)_
|
|
3281
|
+
- **Related:** _(PR/issue link)_
|
|
3282
|
+
|
|
3283
|
+
## Context
|
|
3284
|
+
|
|
3285
|
+
_(What problem or situation triggered this decision?)_
|
|
3286
|
+
|
|
3287
|
+
## Decision
|
|
3288
|
+
|
|
3289
|
+
_(One clear sentence: what we chose.)_
|
|
3290
|
+
|
|
3291
|
+
## Motivation (why)
|
|
3292
|
+
|
|
3293
|
+
_(Why this over alternatives \u2014 the line agents read for "why".)_
|
|
3294
|
+
|
|
3295
|
+
## Alternatives considered
|
|
3296
|
+
|
|
3297
|
+
- _(Alternative)_ \u2014 rejected because _(reason)_.
|
|
3298
|
+
|
|
3299
|
+
## Consequences
|
|
3300
|
+
|
|
3301
|
+
- _(Positive and negative follow-ons.)_
|
|
3302
|
+
`;
|
|
2650
3303
|
}
|
|
2651
|
-
async function
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
3304
|
+
async function runDecisionsSuggest(root, opts = {}) {
|
|
3305
|
+
const stats = await collectDiffStats(root, { staged: true });
|
|
3306
|
+
const stagedCheck = await runDecisionsCheck(root, { staged: true });
|
|
3307
|
+
const unstagedStats = await collectDiffStats(root, {});
|
|
3308
|
+
const triggered = isDecisionTriggered(stats) || isDecisionTriggered(unstagedStats);
|
|
3309
|
+
if (!triggered) return;
|
|
3310
|
+
if (stagedCheck.triggered && stagedCheck.satisfied) return;
|
|
3311
|
+
const number = await nextDecisionNumber(root);
|
|
3312
|
+
const title = opts.title ?? "Decision from current changes";
|
|
3313
|
+
const draft = draftTemplate(number, title);
|
|
3314
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3315
|
+
const rel = relativeDecisionPath(decisionsDir, root);
|
|
3316
|
+
const file = `${rel}/${number}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "decision"}.md`;
|
|
3317
|
+
const payload = {
|
|
3318
|
+
action: "propose_decision",
|
|
3319
|
+
message: "Decision-worthy change detected. Draft an ADR from this template and ask the user to approve before writing.",
|
|
3320
|
+
file,
|
|
3321
|
+
draft,
|
|
3322
|
+
indexHint: `Add a row to ${rel}/README.md for ADR-${number}.`
|
|
3323
|
+
};
|
|
3324
|
+
if (opts.fromHook) {
|
|
3325
|
+
process.stdout.write(`${JSON.stringify(payload)}
|
|
3326
|
+
`);
|
|
2655
3327
|
return;
|
|
2656
3328
|
}
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
3329
|
+
process.stdout.write(`${draft}
|
|
3330
|
+
`);
|
|
3331
|
+
}
|
|
3332
|
+
|
|
3333
|
+
// src/commands/decisions.ts
|
|
3334
|
+
async function runDecisions(sub, arg, options = {}) {
|
|
3335
|
+
const root = process.cwd();
|
|
3336
|
+
if (sub === "check") {
|
|
3337
|
+
const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
|
|
3338
|
+
(m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
|
|
3339
|
+
)).stdout : "";
|
|
3340
|
+
const result = await runDecisionsCheck(root, {
|
|
3341
|
+
staged: options.staged,
|
|
3342
|
+
range: options.range,
|
|
3343
|
+
prBody: options.prBody ?? process.env.PR_BODY,
|
|
3344
|
+
commitMessages
|
|
3345
|
+
});
|
|
3346
|
+
if (!result.triggered) {
|
|
3347
|
+
log(result.reasons[0] ?? "no decision-worthy changes");
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
if (result.satisfied) {
|
|
3351
|
+
if (result.reasons.some((r) => r.includes("[adr-skip]"))) {
|
|
3352
|
+
warn(`::warning::${result.reasons.join("; ")}`);
|
|
3353
|
+
}
|
|
3354
|
+
log(`decisions gate: satisfied (${result.reasons.join("; ")})`);
|
|
3355
|
+
return;
|
|
3356
|
+
}
|
|
3357
|
+
for (const reason of result.reasons) warn(reason);
|
|
3358
|
+
error(
|
|
3359
|
+
"decisions gate: decision-worthy change without a valid ADR under docs/decisions/. Run `haus decisions suggest`, add docs/decisions/NNNN-*.md + README row, or use [adr-skip] with justification."
|
|
3360
|
+
);
|
|
3361
|
+
process.exitCode = 1;
|
|
2660
3362
|
return;
|
|
2661
3363
|
}
|
|
2662
|
-
if (
|
|
2663
|
-
|
|
3364
|
+
if (sub === "suggest") {
|
|
3365
|
+
await runDecisionsSuggest(root, { fromHook: options.fromHook, title: options.title });
|
|
2664
3366
|
return;
|
|
2665
3367
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
|
|
2669
|
-
process.exitCode = 1;
|
|
3368
|
+
if (sub === "next-number") {
|
|
3369
|
+
log(await nextDecisionNumber(root));
|
|
2670
3370
|
return;
|
|
2671
3371
|
}
|
|
2672
|
-
|
|
3372
|
+
if (sub === "validate") {
|
|
3373
|
+
const target = arg ? path22.resolve(root, arg) : void 0;
|
|
3374
|
+
if (!target) {
|
|
3375
|
+
error("usage: haus decisions validate <path-to-decision.md>");
|
|
3376
|
+
process.exitCode = 1;
|
|
3377
|
+
return;
|
|
3378
|
+
}
|
|
3379
|
+
const result = await validateDecisionFile(target);
|
|
3380
|
+
if (!result.ok) {
|
|
3381
|
+
for (const e of result.errors) error(e);
|
|
3382
|
+
process.exitCode = 1;
|
|
3383
|
+
return;
|
|
3384
|
+
}
|
|
3385
|
+
log("valid");
|
|
3386
|
+
}
|
|
2673
3387
|
}
|
|
2674
3388
|
|
|
2675
3389
|
// src/commands/doctor.ts
|
|
2676
|
-
import
|
|
2677
|
-
import
|
|
3390
|
+
import path28 from "node:path";
|
|
3391
|
+
import fs22 from "fs-extra";
|
|
3392
|
+
|
|
3393
|
+
// src/decisions/doctor.ts
|
|
3394
|
+
import path23 from "node:path";
|
|
3395
|
+
import fs20 from "fs-extra";
|
|
3396
|
+
async function auditDecisionsLayout(root) {
|
|
3397
|
+
const findings = [];
|
|
3398
|
+
const primary = path23.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
3399
|
+
const legacy = path23.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
3400
|
+
const hasPrimary = await fs20.pathExists(primary);
|
|
3401
|
+
const hasLegacy = await fs20.pathExists(legacy);
|
|
3402
|
+
if (!hasPrimary && hasLegacy) {
|
|
3403
|
+
findings.push({
|
|
3404
|
+
level: "advisory",
|
|
3405
|
+
line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present \u2014 migrate to docs/decisions/`,
|
|
3406
|
+
sentence: "Decision records still live under the legacy docs/adr/ path",
|
|
3407
|
+
fix: `git mv ${DECISIONS_TRIGGERS.legacyDecisionsDir} ${DECISIONS_TRIGGERS.decisionsDir}`
|
|
3408
|
+
});
|
|
3409
|
+
return findings;
|
|
3410
|
+
}
|
|
3411
|
+
if (!hasPrimary) return findings;
|
|
3412
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3413
|
+
const rel = relativeDecisionPath(decisionsDir, root);
|
|
3414
|
+
const readmePath = path23.join(decisionsDir, "README.md");
|
|
3415
|
+
if (!await fs20.pathExists(readmePath)) {
|
|
3416
|
+
findings.push({
|
|
3417
|
+
level: "advisory",
|
|
3418
|
+
line: `- DECISIONS: missing ${rel}/README.md index`,
|
|
3419
|
+
sentence: "No decision index for agents to load at session start",
|
|
3420
|
+
fix: `create ${rel}/README.md and @import it from CLAUDE.md`
|
|
3421
|
+
});
|
|
3422
|
+
return findings;
|
|
3423
|
+
}
|
|
3424
|
+
const readme = await readText(readmePath);
|
|
3425
|
+
const entries = (await fs20.readdir(decisionsDir)).filter(
|
|
3426
|
+
(f) => f.endsWith(".md") && f !== "README.md"
|
|
3427
|
+
);
|
|
3428
|
+
for (const file of entries) {
|
|
3429
|
+
const num = decisionNumberFromFile(file);
|
|
3430
|
+
if (num && readme && !readme.includes(`[${num}]`) && !readme.includes(`(${num}-`)) {
|
|
3431
|
+
findings.push({
|
|
3432
|
+
level: "advisory",
|
|
3433
|
+
line: `- DECISIONS: ${file} missing from ${rel}/README.md`,
|
|
3434
|
+
sentence: `ADR-${num} is not listed in the decision index`,
|
|
3435
|
+
fix: `add a row for ADR-${num} to ${rel}/README.md`
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
}
|
|
3439
|
+
const claude = await readText(path23.join(root, "CLAUDE.md"));
|
|
3440
|
+
if (claude && !claude.includes(`@${rel}/README.md`)) {
|
|
3441
|
+
findings.push({
|
|
3442
|
+
level: "advisory",
|
|
3443
|
+
line: `- DECISIONS: CLAUDE.md does not @import @${rel}/README.md`,
|
|
3444
|
+
sentence: "Agents may not load the decision index at session start",
|
|
3445
|
+
fix: `add @${rel}/README.md to CLAUDE.md`
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3448
|
+
const numbers = await listDecisionNumbers(decisionsDir);
|
|
3449
|
+
const dupes = numbers.filter((n, i) => numbers.indexOf(n) !== i);
|
|
3450
|
+
if (dupes.length > 0) {
|
|
3451
|
+
findings.push({
|
|
3452
|
+
level: "advisory",
|
|
3453
|
+
line: `- DECISIONS: duplicate numbers: ${[...new Set(dupes)].join(", ")}`,
|
|
3454
|
+
sentence: "Multiple decision files share the same number",
|
|
3455
|
+
fix: "renumber one file and update README.md"
|
|
3456
|
+
});
|
|
3457
|
+
}
|
|
3458
|
+
if (findings.length === 0) {
|
|
3459
|
+
findings.push({
|
|
3460
|
+
level: "ok",
|
|
3461
|
+
line: `- DECISIONS OK: ${rel}/ (${entries.length} record(s))`,
|
|
3462
|
+
sentence: "",
|
|
3463
|
+
fix: ""
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3466
|
+
return findings;
|
|
3467
|
+
}
|
|
2678
3468
|
|
|
2679
3469
|
// src/scanner/scan-project.ts
|
|
2680
|
-
import
|
|
3470
|
+
import path27 from "node:path";
|
|
2681
3471
|
|
|
2682
3472
|
// src/utils/audit-checks.ts
|
|
2683
3473
|
function isRecord(v) {
|
|
@@ -2704,8 +3494,8 @@ function compareVersions(a, b) {
|
|
|
2704
3494
|
}
|
|
2705
3495
|
|
|
2706
3496
|
// src/scanner/detect-package-manager.ts
|
|
2707
|
-
import
|
|
2708
|
-
import
|
|
3497
|
+
import path24 from "node:path";
|
|
3498
|
+
import fs21 from "fs-extra";
|
|
2709
3499
|
function detectPackageManager(root, packageManagerField) {
|
|
2710
3500
|
const field = String(packageManagerField ?? "").trim();
|
|
2711
3501
|
if (field.startsWith("yarn@")) {
|
|
@@ -2723,9 +3513,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
2723
3513
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
2724
3514
|
return "unknown";
|
|
2725
3515
|
}
|
|
2726
|
-
if (
|
|
2727
|
-
if (
|
|
2728
|
-
if (
|
|
3516
|
+
if (fs21.existsSync(path24.join(root, "yarn.lock"))) return "yarn";
|
|
3517
|
+
if (fs21.existsSync(path24.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
3518
|
+
if (fs21.existsSync(path24.join(root, "package-lock.json"))) return "npm";
|
|
2729
3519
|
return "unknown";
|
|
2730
3520
|
}
|
|
2731
3521
|
|
|
@@ -2927,7 +3717,7 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
2927
3717
|
}
|
|
2928
3718
|
|
|
2929
3719
|
// src/scanner/detection.ts
|
|
2930
|
-
import
|
|
3720
|
+
import path25 from "node:path";
|
|
2931
3721
|
var UNSUPPORTED_MARKERS = {
|
|
2932
3722
|
"requirements.txt": "python",
|
|
2933
3723
|
"pyproject.toml": "python",
|
|
@@ -2981,14 +3771,14 @@ function finalizeRoles(registryRoles, deps, files) {
|
|
|
2981
3771
|
function collectUnsupportedSignals(files) {
|
|
2982
3772
|
return [
|
|
2983
3773
|
...new Set(
|
|
2984
|
-
files.map((f) => UNSUPPORTED_MARKERS[
|
|
3774
|
+
files.map((f) => UNSUPPORTED_MARKERS[path25.basename(f)]).filter((s) => Boolean(s))
|
|
2985
3775
|
)
|
|
2986
3776
|
].sort();
|
|
2987
3777
|
}
|
|
2988
3778
|
|
|
2989
3779
|
// src/scanner/render.ts
|
|
2990
3780
|
import { readFile } from "node:fs/promises";
|
|
2991
|
-
import
|
|
3781
|
+
import path26 from "node:path";
|
|
2992
3782
|
async function buildContentBlob(root, files) {
|
|
2993
3783
|
const candidates = files.filter(
|
|
2994
3784
|
(f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
|
|
@@ -2996,7 +3786,7 @@ async function buildContentBlob(root, files) {
|
|
|
2996
3786
|
const slice = candidates.slice(0, 300);
|
|
2997
3787
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
2998
3788
|
try {
|
|
2999
|
-
return await readFile(
|
|
3789
|
+
return await readFile(path26.join(root, rel), "utf8");
|
|
3000
3790
|
} catch {
|
|
3001
3791
|
return "";
|
|
3002
3792
|
}
|
|
@@ -3026,7 +3816,7 @@ function buildSourcesReport(items) {
|
|
|
3026
3816
|
}
|
|
3027
3817
|
return {
|
|
3028
3818
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3029
|
-
items: [...statusBySource.entries()].map(([
|
|
3819
|
+
items: [...statusBySource.entries()].map(([source, status]) => ({ source, status })).sort((a, b) => a.source.localeCompare(b.source))
|
|
3030
3820
|
};
|
|
3031
3821
|
}
|
|
3032
3822
|
async function writeSourcesReport(root) {
|
|
@@ -3083,8 +3873,8 @@ var SAFE_FILES = [
|
|
|
3083
3873
|
"Gemfile"
|
|
3084
3874
|
];
|
|
3085
3875
|
async function scanProject(root) {
|
|
3086
|
-
const pkg = await readJson(
|
|
3087
|
-
const composer = await readJson(
|
|
3876
|
+
const pkg = await readJson(path27.join(root, "package.json"));
|
|
3877
|
+
const composer = await readJson(path27.join(root, "composer.json"));
|
|
3088
3878
|
const files = await listFiles(root, SAFE_FILES);
|
|
3089
3879
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
3090
3880
|
const deps = dependencySet(pkg, composer);
|
|
@@ -3106,14 +3896,12 @@ async function scanProject(root) {
|
|
|
3106
3896
|
crossRepoHints.push("Containerized services detected");
|
|
3107
3897
|
if (safeFiles.some((f) => f.includes("turbo.json") || f.includes("nx.json")))
|
|
3108
3898
|
crossRepoHints.push("Monorepo orchestration detected");
|
|
3109
|
-
if (safeFiles.some((f) => f.includes("wp-content/uploads")))
|
|
3110
|
-
securityRisks.push("Uploads directory present");
|
|
3111
3899
|
const unsupportedSignals = collectUnsupportedSignals(safeFiles);
|
|
3112
3900
|
const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
|
|
3113
3901
|
const context = {
|
|
3114
3902
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3115
3903
|
root,
|
|
3116
|
-
repoName: String(pkg?.name ??
|
|
3904
|
+
repoName: String(pkg?.name ?? path27.basename(root)),
|
|
3117
3905
|
packageManager,
|
|
3118
3906
|
repoRoles: roles,
|
|
3119
3907
|
detectedStacks: stacks,
|
|
@@ -3184,11 +3972,16 @@ async function runDoctor(options) {
|
|
|
3184
3972
|
);
|
|
3185
3973
|
const detail = [];
|
|
3186
3974
|
const attention = [];
|
|
3975
|
+
const suggestions = [];
|
|
3187
3976
|
const ok = (text) => detail.push({ stream: "log", text });
|
|
3188
3977
|
const flag = (text, sentence, fix) => {
|
|
3189
3978
|
detail.push({ stream: "warn", text });
|
|
3190
3979
|
attention.push({ sentence, fix });
|
|
3191
3980
|
};
|
|
3981
|
+
const suggest = (text, sentence, fix) => {
|
|
3982
|
+
detail.push({ stream: "warn", text });
|
|
3983
|
+
suggestions.push({ sentence, fix });
|
|
3984
|
+
};
|
|
3192
3985
|
ok(`Repo: ${context.repoName}`);
|
|
3193
3986
|
ok(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
|
|
3194
3987
|
ok(`Package manager: ${context.packageManager}`);
|
|
@@ -3199,7 +3992,11 @@ async function runDoctor(options) {
|
|
|
3199
3992
|
}
|
|
3200
3993
|
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
3201
3994
|
if (hooks.skipped) {
|
|
3202
|
-
|
|
3995
|
+
flag(
|
|
3996
|
+
`- HOOKS: (skipped) ${hooks.message}`,
|
|
3997
|
+
"No Claude Code settings.json \u2014 haus security hooks are not installed",
|
|
3998
|
+
"haus apply --write"
|
|
3999
|
+
);
|
|
3203
4000
|
} else if (!hooks.ok) {
|
|
3204
4001
|
flag(
|
|
3205
4002
|
`- HOOKS FAIL: ${hooks.message}`,
|
|
@@ -3210,7 +4007,7 @@ async function runDoctor(options) {
|
|
|
3210
4007
|
} else {
|
|
3211
4008
|
ok(`- HOOKS OK: ${hooks.message}`);
|
|
3212
4009
|
}
|
|
3213
|
-
const rootClaudeMdPath =
|
|
4010
|
+
const rootClaudeMdPath = path28.join(root, "CLAUDE.md");
|
|
3214
4011
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
3215
4012
|
if (!rootClaudeMdContent) {
|
|
3216
4013
|
flag(
|
|
@@ -3238,7 +4035,7 @@ async function runDoctor(options) {
|
|
|
3238
4035
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
3239
4036
|
const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
|
|
3240
4037
|
for (const target of importTargets) {
|
|
3241
|
-
if (!await
|
|
4038
|
+
if (!await fs22.pathExists(hausPath(root, target))) {
|
|
3242
4039
|
flag(
|
|
3243
4040
|
`- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
|
|
3244
4041
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -3249,7 +4046,7 @@ async function runDoctor(options) {
|
|
|
3249
4046
|
}
|
|
3250
4047
|
}
|
|
3251
4048
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
3252
|
-
const workflowExists = await
|
|
4049
|
+
const workflowExists = await fs22.pathExists(workflowPath);
|
|
3253
4050
|
if (!workflowExists) {
|
|
3254
4051
|
flag(
|
|
3255
4052
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -3272,20 +4069,20 @@ async function runDoctor(options) {
|
|
|
3272
4069
|
"haus apply --write --force"
|
|
3273
4070
|
);
|
|
3274
4071
|
} else {
|
|
3275
|
-
const cachePath =
|
|
3276
|
-
const bundledPath =
|
|
4072
|
+
const cachePath = path28.join(getCacheDir(), "templates/agentic-workflow-standard.md");
|
|
4073
|
+
const bundledPath = path28.join(
|
|
3277
4074
|
packageRoot(),
|
|
3278
4075
|
"library",
|
|
3279
4076
|
"global",
|
|
3280
4077
|
"templates",
|
|
3281
4078
|
"agentic-workflow-standard.md"
|
|
3282
4079
|
);
|
|
3283
|
-
const templatePath = await
|
|
4080
|
+
const templatePath = await fs22.pathExists(cachePath) ? cachePath : bundledPath;
|
|
3284
4081
|
const templateContent = await readText(templatePath);
|
|
3285
4082
|
if (storedHashMatch && templateContent) {
|
|
3286
4083
|
const currentHash = hashText(normaliseLF(templateContent));
|
|
3287
4084
|
if (storedHashMatch[1] !== currentHash) {
|
|
3288
|
-
|
|
4085
|
+
suggest(
|
|
3289
4086
|
"- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
|
|
3290
4087
|
"The workflow standard is out of date",
|
|
3291
4088
|
"haus apply --write"
|
|
@@ -3300,7 +4097,7 @@ async function runDoctor(options) {
|
|
|
3300
4097
|
}
|
|
3301
4098
|
}
|
|
3302
4099
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
3303
|
-
const workflowConfigExists = await
|
|
4100
|
+
const workflowConfigExists = await fs22.pathExists(workflowConfigPath);
|
|
3304
4101
|
if (!workflowConfigExists) {
|
|
3305
4102
|
flag(
|
|
3306
4103
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -3308,10 +4105,10 @@ async function runDoctor(options) {
|
|
|
3308
4105
|
"haus apply --write"
|
|
3309
4106
|
);
|
|
3310
4107
|
} else {
|
|
3311
|
-
const cfg = await
|
|
4108
|
+
const cfg = await fs22.readFile(workflowConfigPath, "utf8");
|
|
3312
4109
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
3313
4110
|
if (unfilled > 0) {
|
|
3314
|
-
|
|
4111
|
+
suggest(
|
|
3315
4112
|
`- .haus-workflow/workflow-config.md: ${unfilled} field(s) still unfilled (run \`haus apply --refill-config\` to auto-fill detectable ones)`,
|
|
3316
4113
|
`${unfilled} workflow-config field(s) are still blank`,
|
|
3317
4114
|
"haus apply --refill-config"
|
|
@@ -3321,7 +4118,7 @@ async function runDoctor(options) {
|
|
|
3321
4118
|
}
|
|
3322
4119
|
}
|
|
3323
4120
|
if (workflowExists) {
|
|
3324
|
-
const prettierIgnore = await readText(
|
|
4121
|
+
const prettierIgnore = await readText(path28.join(root, ".prettierignore")) ?? "";
|
|
3325
4122
|
if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
|
|
3326
4123
|
flag(
|
|
3327
4124
|
"- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
|
|
@@ -3332,9 +4129,13 @@ async function runDoctor(options) {
|
|
|
3332
4129
|
ok("- .prettierignore: protects .haus-workflow/");
|
|
3333
4130
|
}
|
|
3334
4131
|
}
|
|
4132
|
+
for (const finding of await auditDecisionsLayout(root)) {
|
|
4133
|
+
if (finding.level === "ok") ok(finding.line);
|
|
4134
|
+
else suggest(finding.line, finding.sentence, finding.fix);
|
|
4135
|
+
}
|
|
3335
4136
|
const cacheAgeMs = await getCacheManifestAge();
|
|
3336
4137
|
if (cacheAgeMs === null) {
|
|
3337
|
-
|
|
4138
|
+
suggest(
|
|
3338
4139
|
"- CATALOG CACHE: absent (run `haus update` to populate)",
|
|
3339
4140
|
"The catalog cache hasn't been downloaded yet",
|
|
3340
4141
|
"haus update"
|
|
@@ -3342,7 +4143,7 @@ async function runDoctor(options) {
|
|
|
3342
4143
|
} else {
|
|
3343
4144
|
const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
|
|
3344
4145
|
if (cacheAgeDays >= 7) {
|
|
3345
|
-
|
|
4146
|
+
suggest(
|
|
3346
4147
|
`- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`,
|
|
3347
4148
|
`The catalog cache is ${cacheAgeDays} days old`,
|
|
3348
4149
|
"haus update"
|
|
@@ -3351,11 +4152,11 @@ async function runDoctor(options) {
|
|
|
3351
4152
|
ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
|
|
3352
4153
|
}
|
|
3353
4154
|
}
|
|
3354
|
-
const pkgJson = await readJson(
|
|
4155
|
+
const pkgJson = await readJson(path28.join(packageRoot(), "package.json"));
|
|
3355
4156
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
3356
4157
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
3357
4158
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
3358
|
-
|
|
4159
|
+
suggest(
|
|
3359
4160
|
`- CLI UPDATE: ${currentVersion} \u2192 ${npmStatus.latest} available (run: npm install -g ${NPM_PACKAGE_NAME})`,
|
|
3360
4161
|
`A newer haus (${npmStatus.latest}) is available`,
|
|
3361
4162
|
`npm install -g ${NPM_PACKAGE_NAME}`
|
|
@@ -3365,12 +4166,17 @@ async function runDoctor(options) {
|
|
|
3365
4166
|
} else {
|
|
3366
4167
|
ok(`- CLI: ${currentVersion} (version check unavailable)`);
|
|
3367
4168
|
}
|
|
4169
|
+
if (attention.length > 0) process.exitCode = 1;
|
|
3368
4170
|
if (attention.length === 0) {
|
|
3369
4171
|
log("\u2705 Your project is set up and healthy.");
|
|
3370
4172
|
} else {
|
|
3371
4173
|
log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
|
|
3372
4174
|
for (const a of attention) log(` \u2022 ${a.sentence} \u2014 fix: ${a.fix}`);
|
|
3373
4175
|
}
|
|
4176
|
+
if (suggestions.length > 0) {
|
|
4177
|
+
log(`\u{1F4A1} ${suggestions.length} suggestion(s):`);
|
|
4178
|
+
for (const s of suggestions) log(` \u2022 ${s.sentence} \u2014 try: ${s.fix}`);
|
|
4179
|
+
}
|
|
3374
4180
|
log("Haus Doctor");
|
|
3375
4181
|
for (const line2 of detail) {
|
|
3376
4182
|
if (line2.stream === "warn") warn(line2.text);
|
|
@@ -3502,6 +4308,61 @@ async function runExplainRecommendation(options) {
|
|
|
3502
4308
|
log(formatRecommendationHuman(normalized));
|
|
3503
4309
|
}
|
|
3504
4310
|
|
|
4311
|
+
// src/commands/fetch-refs.ts
|
|
4312
|
+
var REFS_CACHE_SUBDIR = "llms-cache";
|
|
4313
|
+
async function runFetchRefs(options) {
|
|
4314
|
+
const root = process.cwd();
|
|
4315
|
+
const cacheDir = hausPath(root, REFS_CACHE_SUBDIR);
|
|
4316
|
+
let allItems;
|
|
4317
|
+
try {
|
|
4318
|
+
allItems = await loadCatalog(root);
|
|
4319
|
+
} catch (err) {
|
|
4320
|
+
warn(`Could not load catalog: ${err instanceof Error ? err.message : String(err)}`);
|
|
4321
|
+
process.exitCode = 1;
|
|
4322
|
+
return;
|
|
4323
|
+
}
|
|
4324
|
+
let items = allItems;
|
|
4325
|
+
if (options.id) {
|
|
4326
|
+
items = allItems.filter((item) => item.id === options.id);
|
|
4327
|
+
if (items.length === 0) {
|
|
4328
|
+
warn(`No catalog item found with id "${options.id}".`);
|
|
4329
|
+
process.exitCode = 1;
|
|
4330
|
+
return;
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
const withRefs = items.filter((item) => (item.references ?? []).some(isLlmsTxtUrl));
|
|
4334
|
+
if (withRefs.length === 0) {
|
|
4335
|
+
if (options.json) {
|
|
4336
|
+
log(
|
|
4337
|
+
JSON.stringify(
|
|
4338
|
+
{ fetched: 0, unchanged: 0, failed: 0, failedUrls: [], cachedFiles: {} },
|
|
4339
|
+
null,
|
|
4340
|
+
2
|
|
4341
|
+
)
|
|
4342
|
+
);
|
|
4343
|
+
} else {
|
|
4344
|
+
log("No catalog items with llms.txt references found.");
|
|
4345
|
+
}
|
|
4346
|
+
return;
|
|
4347
|
+
}
|
|
4348
|
+
const summary = await fetchRefsForItems(withRefs, cacheDir);
|
|
4349
|
+
if (options.json) {
|
|
4350
|
+
log(JSON.stringify(summary, null, 2));
|
|
4351
|
+
return;
|
|
4352
|
+
}
|
|
4353
|
+
for (const [url, filePath] of Object.entries(summary.cachedFiles)) {
|
|
4354
|
+
log(`Cached: ${filePath} (${url})`);
|
|
4355
|
+
}
|
|
4356
|
+
if (summary.fetched > 0) log(`Fetched: ${summary.fetched} URL(s)`);
|
|
4357
|
+
if (summary.unchanged > 0) log(`Unchanged (etag match): ${summary.unchanged} URL(s)`);
|
|
4358
|
+
if (summary.failed > 0) {
|
|
4359
|
+
warn(`Failed to fetch ${summary.failed} URL(s): ${summary.failedUrls.join(", ")}`);
|
|
4360
|
+
}
|
|
4361
|
+
if (summary.fetched === 0 && summary.unchanged > 0 && summary.failed === 0) {
|
|
4362
|
+
log("All references up to date.");
|
|
4363
|
+
}
|
|
4364
|
+
}
|
|
4365
|
+
|
|
3505
4366
|
// src/commands/guard.ts
|
|
3506
4367
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
3507
4368
|
|
|
@@ -3538,7 +4399,15 @@ function stdin() {
|
|
|
3538
4399
|
}
|
|
3539
4400
|
}
|
|
3540
4401
|
function deny(reason) {
|
|
3541
|
-
log(
|
|
4402
|
+
log(
|
|
4403
|
+
JSON.stringify({
|
|
4404
|
+
hookSpecificOutput: {
|
|
4405
|
+
hookEventName: "PreToolUse",
|
|
4406
|
+
permissionDecision: "deny",
|
|
4407
|
+
permissionDecisionReason: reason
|
|
4408
|
+
}
|
|
4409
|
+
})
|
|
4410
|
+
);
|
|
3542
4411
|
}
|
|
3543
4412
|
async function runGuard(kind, _options) {
|
|
3544
4413
|
const raw = stdin();
|
|
@@ -3549,7 +4418,6 @@ async function runGuard(kind, _options) {
|
|
|
3549
4418
|
if (isRecord(parsed)) payload = parsed;
|
|
3550
4419
|
} catch {
|
|
3551
4420
|
deny("Malformed hook payload");
|
|
3552
|
-
process.exitCode = 1;
|
|
3553
4421
|
return;
|
|
3554
4422
|
}
|
|
3555
4423
|
}
|
|
@@ -3559,8 +4427,6 @@ async function runGuard(kind, _options) {
|
|
|
3559
4427
|
const reason2 = guardFileAccess(candidate);
|
|
3560
4428
|
if (reason2) {
|
|
3561
4429
|
deny(reason2);
|
|
3562
|
-
process.exitCode = 1;
|
|
3563
|
-
return;
|
|
3564
4430
|
}
|
|
3565
4431
|
return;
|
|
3566
4432
|
}
|
|
@@ -3568,13 +4434,12 @@ async function runGuard(kind, _options) {
|
|
|
3568
4434
|
const reason = guardBash(command);
|
|
3569
4435
|
if (reason) {
|
|
3570
4436
|
deny(reason);
|
|
3571
|
-
process.exitCode = 1;
|
|
3572
4437
|
}
|
|
3573
4438
|
}
|
|
3574
4439
|
|
|
3575
4440
|
// src/commands/init.ts
|
|
3576
|
-
import
|
|
3577
|
-
import
|
|
4441
|
+
import path29 from "node:path";
|
|
4442
|
+
import fs23 from "fs-extra";
|
|
3578
4443
|
|
|
3579
4444
|
// src/utils/prompts.ts
|
|
3580
4445
|
import { stdin as input, stdout as output } from "node:process";
|
|
@@ -3595,6 +4460,7 @@ async function confirm(question) {
|
|
|
3595
4460
|
}
|
|
3596
4461
|
|
|
3597
4462
|
// src/recommender/git-signal.ts
|
|
4463
|
+
init_exec();
|
|
3598
4464
|
async function readChangedFiles(root) {
|
|
3599
4465
|
if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
|
|
3600
4466
|
try {
|
|
@@ -3662,9 +4528,7 @@ function mergeRecommendationWarnings(context) {
|
|
|
3662
4528
|
// src/recommender/recommend.ts
|
|
3663
4529
|
async function recommend(root, context, opts = {}) {
|
|
3664
4530
|
const items = await loadCatalog(root);
|
|
3665
|
-
const sources =
|
|
3666
|
-
hausPath(root, "sources-report.json")
|
|
3667
|
-
) ?? {};
|
|
4531
|
+
const sources = buildSourcesReport(items);
|
|
3668
4532
|
const deep = await readJson(hausPath(root, "deep-context.json")) ?? {};
|
|
3669
4533
|
const scannerStacks = buildStackSet(context);
|
|
3670
4534
|
const scannerRoles = new Set(context.repoRoles.map((r) => r.toLowerCase()));
|
|
@@ -3682,7 +4546,7 @@ async function recommend(root, context, opts = {}) {
|
|
|
3682
4546
|
const depSet = scannerDeps;
|
|
3683
4547
|
const recommended = [];
|
|
3684
4548
|
const skipped = [];
|
|
3685
|
-
const sourceTrust = new Map(
|
|
4549
|
+
const sourceTrust = new Map(sources.items.map((x) => [x.source, x.status]));
|
|
3686
4550
|
const changedFiles = await readChangedFiles(root);
|
|
3687
4551
|
const skip = (id, code, message, signal) => {
|
|
3688
4552
|
skipped.push({ id, reason: message, skipReasons: [{ code, message, signal }] });
|
|
@@ -3690,8 +4554,13 @@ async function recommend(root, context, opts = {}) {
|
|
|
3690
4554
|
const roleSignal = (name) => scannerRoles.has(name.toLowerCase()) ? `role:${name}` : `deep:role:${name}`;
|
|
3691
4555
|
const stackSignal = (name) => scannerStacks.has(name.toLowerCase()) ? `tag:${name}` : `deep:tag:${name}`;
|
|
3692
4556
|
for (const item of items) {
|
|
3693
|
-
const
|
|
3694
|
-
if (
|
|
4557
|
+
const normSource = typeof item.source === "string" ? item.source.trim() : "";
|
|
4558
|
+
if (!normSource) {
|
|
4559
|
+
skip(item.id, "invalid-source", "Item source is missing or empty");
|
|
4560
|
+
continue;
|
|
4561
|
+
}
|
|
4562
|
+
const itemTags = item.tags.map((t) => t.toLowerCase());
|
|
4563
|
+
if (UNSUPPORTED.some((x) => itemTags.includes(x))) {
|
|
3695
4564
|
skip(item.id, "unsupported-policy", "Unsupported stack policy");
|
|
3696
4565
|
continue;
|
|
3697
4566
|
}
|
|
@@ -3699,7 +4568,7 @@ async function recommend(root, context, opts = {}) {
|
|
|
3699
4568
|
skip(item.id, "deprecated", "Catalog item is deprecated", "reviewStatus:deprecated");
|
|
3700
4569
|
continue;
|
|
3701
4570
|
}
|
|
3702
|
-
if (
|
|
4571
|
+
if (normSource === "curated") {
|
|
3703
4572
|
const rs = item.reviewStatus;
|
|
3704
4573
|
if (!rs || rs !== "approved") {
|
|
3705
4574
|
skip(
|
|
@@ -3720,17 +4589,19 @@ async function recommend(root, context, opts = {}) {
|
|
|
3720
4589
|
continue;
|
|
3721
4590
|
}
|
|
3722
4591
|
}
|
|
3723
|
-
|
|
4592
|
+
const sensitiveInId = SENSITIVE_ITEM_KEYWORDS.some((x) => item.id.toLowerCase().includes(x));
|
|
4593
|
+
const sensitiveInTags = SENSITIVE_ITEM_KEYWORDS.some((x) => itemTags.includes(x));
|
|
4594
|
+
if (sensitiveInId || sensitiveInTags) {
|
|
3724
4595
|
skip(item.id, "sensitive-policy", "Sensitive content policy block");
|
|
3725
4596
|
continue;
|
|
3726
4597
|
}
|
|
3727
|
-
const trust = sourceTrust.get(
|
|
4598
|
+
const trust = sourceTrust.get(normSource);
|
|
3728
4599
|
if (trust === "candidate" || trust === "rejected") {
|
|
3729
4600
|
skip(item.id, "source-trust", "Source trust policy block", `trust:${trust}`);
|
|
3730
4601
|
continue;
|
|
3731
4602
|
}
|
|
3732
|
-
if (
|
|
3733
|
-
skip(item.id, "source-approval", "Source not approved", `source:${
|
|
4603
|
+
if (normSource !== "haus" && trust !== "approved") {
|
|
4604
|
+
skip(item.id, "source-approval", "Source not approved", `source:${normSource}`);
|
|
3734
4605
|
continue;
|
|
3735
4606
|
}
|
|
3736
4607
|
if (item.id === "haus.nx21-monorepo-patterns" && !roleSet.has("nx-monorepo")) {
|
|
@@ -3755,15 +4626,6 @@ async function recommend(root, context, opts = {}) {
|
|
|
3755
4626
|
skip(item.id, "required-role-missing", "Required role missing: database", "role:database");
|
|
3756
4627
|
continue;
|
|
3757
4628
|
}
|
|
3758
|
-
if (item.id === "haus.ecc-typescript-reviewer" && (stackSet.has("react") || stackSet.has("react19") || stackSet.has("nextjs") || stackSet.has("next") || roleSet.has("react-app") || roleSet.has("next-app"))) {
|
|
3759
|
-
skip(
|
|
3760
|
-
item.id,
|
|
3761
|
-
"co-install-react-reviewer",
|
|
3762
|
-
"Skip typescript-reviewer on React/Next stacks (use react-reviewer)",
|
|
3763
|
-
"stack:react|nextjs|role:react-app|next-app"
|
|
3764
|
-
);
|
|
3765
|
-
continue;
|
|
3766
|
-
}
|
|
3767
4629
|
const isDefaultBaseline = item.default === true;
|
|
3768
4630
|
const reasons = [];
|
|
3769
4631
|
const push = (code, message, signal) => reasons.push({ code, message, signal });
|
|
@@ -3781,9 +4643,11 @@ async function recommend(root, context, opts = {}) {
|
|
|
3781
4643
|
`packageManager:${context.packageManager}`
|
|
3782
4644
|
);
|
|
3783
4645
|
}
|
|
3784
|
-
const configSignal = item.tags.find(
|
|
3785
|
-
|
|
3786
|
-
|
|
4646
|
+
const configSignal = item.tags.find((t) => {
|
|
4647
|
+
const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4648
|
+
const re = new RegExp(`(?:^|\\W)${escaped}(?:\\W|$)`, "i");
|
|
4649
|
+
return context.warnings.some((w) => re.test(w));
|
|
4650
|
+
});
|
|
3787
4651
|
if (configSignal) push("config-signal-match", "config signal match", `warning:${configSignal}`);
|
|
3788
4652
|
const idSegment = item.id.split(".").pop() ?? "";
|
|
3789
4653
|
const changedMatch = idSegment ? changedFiles.find((f) => f.includes(idSegment)) : void 0;
|
|
@@ -3916,6 +4780,13 @@ function applyCoInstallSuppression(recommended, skipped) {
|
|
|
3916
4780
|
code: "co-install-redis-official",
|
|
3917
4781
|
message: "Skip ecc-redis-patterns when official redis-connections skill is selected",
|
|
3918
4782
|
signal: "co-install:redis-connections"
|
|
4783
|
+
},
|
|
4784
|
+
{
|
|
4785
|
+
suppress: "haus.ecc-typescript-reviewer",
|
|
4786
|
+
whenAnyRecommended: ["haus.ecc-react-reviewer"],
|
|
4787
|
+
code: "co-install-react-reviewer",
|
|
4788
|
+
message: "Skip typescript-reviewer on React/Next stacks (use react-reviewer)",
|
|
4789
|
+
signal: "co-install:react-reviewer"
|
|
3919
4790
|
}
|
|
3920
4791
|
];
|
|
3921
4792
|
for (const rule of rules) {
|
|
@@ -4009,37 +4880,39 @@ function buildOptInEligible(items, skipped) {
|
|
|
4009
4880
|
|
|
4010
4881
|
// src/commands/setup-core.ts
|
|
4011
4882
|
async function runSetupCore(root, opts) {
|
|
4012
|
-
const { json, apply, dryRun, confirm: confirm2 } = opts;
|
|
4883
|
+
const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
|
|
4884
|
+
const say = quiet ? () => {
|
|
4885
|
+
} : log;
|
|
4013
4886
|
const scanResult = await scanProject(root);
|
|
4014
4887
|
if (json) {
|
|
4015
|
-
|
|
4888
|
+
say(JSON.stringify(scanResult, null, 2));
|
|
4016
4889
|
} else {
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4890
|
+
say("Haus scan complete");
|
|
4891
|
+
say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
|
|
4892
|
+
say(`Package manager: ${scanResult.packageManager}`);
|
|
4020
4893
|
}
|
|
4021
4894
|
const context = await readContextOrScan(root);
|
|
4022
4895
|
const recommendation = await recommend(root, context);
|
|
4023
4896
|
await writeJson(hausPath(root, "recommendation.json"), recommendation);
|
|
4024
4897
|
if (json) {
|
|
4025
|
-
|
|
4898
|
+
say(JSON.stringify(recommendation, null, 2));
|
|
4026
4899
|
} else {
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4900
|
+
say("Haus recommendation ready");
|
|
4901
|
+
say(`Recommended: ${recommendation.recommended.length}`);
|
|
4902
|
+
say(`Skipped: ${recommendation.skipped.length}`);
|
|
4030
4903
|
}
|
|
4031
4904
|
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
4032
4905
|
const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
|
|
4033
|
-
|
|
4034
|
-
for (const warning of warningLines)
|
|
4906
|
+
say(`Repo: ${context.repoName}`);
|
|
4907
|
+
for (const warning of warningLines) say(`- WARN: ${warning}`);
|
|
4035
4908
|
const hooksOk = hooks.ok;
|
|
4036
4909
|
if (hooks.skipped) {
|
|
4037
|
-
|
|
4910
|
+
say(`- HOOKS: (skipped) ${hooks.message}`);
|
|
4038
4911
|
} else if (!hooks.ok) {
|
|
4039
|
-
|
|
4040
|
-
process.exitCode = 1;
|
|
4912
|
+
say(`- HOOKS FAIL: ${hooks.message}`);
|
|
4913
|
+
if (!apply) process.exitCode = 1;
|
|
4041
4914
|
} else {
|
|
4042
|
-
|
|
4915
|
+
say(`- HOOKS OK: ${hooks.message}`);
|
|
4043
4916
|
}
|
|
4044
4917
|
const baseResult = {
|
|
4045
4918
|
root,
|
|
@@ -4054,32 +4927,32 @@ async function runSetupCore(root, opts) {
|
|
|
4054
4927
|
if (confirm2) {
|
|
4055
4928
|
const approved = await confirm2();
|
|
4056
4929
|
if (!approved) {
|
|
4057
|
-
|
|
4058
|
-
|
|
4930
|
+
say("Setup reviewed. No files written.");
|
|
4931
|
+
say("Next step: run `haus apply --write` when ready.");
|
|
4059
4932
|
return baseResult;
|
|
4060
4933
|
}
|
|
4061
4934
|
}
|
|
4062
4935
|
if (!dryRun && !process.env["HAUS_FIXTURE_CATALOG"]) {
|
|
4063
|
-
|
|
4936
|
+
say("Syncing remote catalog...");
|
|
4064
4937
|
const sync = await syncRemoteCatalog();
|
|
4065
4938
|
if (sync.newItems.length > 0) {
|
|
4066
|
-
|
|
4939
|
+
say(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
|
|
4067
4940
|
} else if (sync.refreshed.length > 0) {
|
|
4068
|
-
|
|
4941
|
+
say(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
|
|
4069
4942
|
}
|
|
4070
4943
|
}
|
|
4071
|
-
const files = await writeClaudeFiles(root, dryRun ?? false);
|
|
4072
|
-
|
|
4073
|
-
files.forEach((f) =>
|
|
4944
|
+
const files = await writeClaudeFiles(root, dryRun ?? false, void 0, { quiet: opts.quiet });
|
|
4945
|
+
say("Applied files:");
|
|
4946
|
+
files.forEach((f) => say(`- ${displayPath(root, f)}`));
|
|
4074
4947
|
const hooksAfter = await verifyProjectSettingsHooksContract(root);
|
|
4075
4948
|
const hooksOkAfter = hooksAfter.ok;
|
|
4076
4949
|
if (hooksAfter.skipped) {
|
|
4077
|
-
|
|
4950
|
+
say(`- HOOKS: (skipped) ${hooksAfter.message}`);
|
|
4078
4951
|
} else if (!hooksAfter.ok) {
|
|
4079
|
-
|
|
4952
|
+
say(`- HOOKS FAIL: ${hooksAfter.message}`);
|
|
4080
4953
|
process.exitCode = 1;
|
|
4081
4954
|
} else {
|
|
4082
|
-
|
|
4955
|
+
say(`- HOOKS OK: ${hooksAfter.message}`);
|
|
4083
4956
|
}
|
|
4084
4957
|
return { ...baseResult, hooksOk: hooksOkAfter, written: files };
|
|
4085
4958
|
}
|
|
@@ -4098,8 +4971,8 @@ async function runSetupProject(options) {
|
|
|
4098
4971
|
// src/commands/init.ts
|
|
4099
4972
|
async function runInit(options) {
|
|
4100
4973
|
const root = process.cwd();
|
|
4101
|
-
const hausDir =
|
|
4102
|
-
const alreadyInit = await
|
|
4974
|
+
const hausDir = path29.join(root, ".haus-workflow");
|
|
4975
|
+
const alreadyInit = await fs23.pathExists(hausDir);
|
|
4103
4976
|
if (alreadyInit) {
|
|
4104
4977
|
log("Haus AI already initialized in this project.");
|
|
4105
4978
|
log("Run `haus setup-project` to reconfigure.");
|
|
@@ -4111,8 +4984,8 @@ async function runInit(options) {
|
|
|
4111
4984
|
|
|
4112
4985
|
// src/install/apply.ts
|
|
4113
4986
|
import crypto2 from "node:crypto";
|
|
4114
|
-
import
|
|
4115
|
-
import
|
|
4987
|
+
import path30 from "node:path";
|
|
4988
|
+
import fs24 from "fs-extra";
|
|
4116
4989
|
|
|
4117
4990
|
// src/install/header.ts
|
|
4118
4991
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
@@ -4194,44 +5067,44 @@ ${content2}`;
|
|
|
4194
5067
|
// src/install/apply.ts
|
|
4195
5068
|
var SCHEMA_VERSION2 = "1";
|
|
4196
5069
|
function hashContent(content2) {
|
|
4197
|
-
return `sha256-${crypto2.createHash("sha256").update(content2).digest("hex")}`;
|
|
5070
|
+
return `sha256-${crypto2.createHash("sha256").update(normaliseLF(content2)).digest("hex")}`;
|
|
4198
5071
|
}
|
|
4199
5072
|
function sourceVersion() {
|
|
4200
5073
|
try {
|
|
4201
|
-
const pkgPath =
|
|
4202
|
-
const pkg = JSON.parse(
|
|
5074
|
+
const pkgPath = path30.join(packageRoot(), "package.json");
|
|
5075
|
+
const pkg = JSON.parse(fs24.readFileSync(pkgPath, "utf8"));
|
|
4203
5076
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
4204
5077
|
} catch {
|
|
4205
5078
|
return "haus@0.0.0";
|
|
4206
5079
|
}
|
|
4207
5080
|
}
|
|
4208
5081
|
function globalSrcDir() {
|
|
4209
|
-
return
|
|
5082
|
+
return path30.join(packageRoot(), "library", "global");
|
|
4210
5083
|
}
|
|
4211
5084
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
4212
5085
|
const entries = [];
|
|
4213
|
-
const skillsDir =
|
|
4214
|
-
if (
|
|
4215
|
-
for (const skillName of
|
|
4216
|
-
const skillFile =
|
|
4217
|
-
if (
|
|
5086
|
+
const skillsDir = path30.join(srcDir, "skills");
|
|
5087
|
+
if (fs24.pathExistsSync(skillsDir)) {
|
|
5088
|
+
for (const skillName of fs24.readdirSync(skillsDir)) {
|
|
5089
|
+
const skillFile = path30.join(skillsDir, skillName, "SKILL.md");
|
|
5090
|
+
if (fs24.pathExistsSync(skillFile)) {
|
|
4218
5091
|
entries.push({
|
|
4219
5092
|
stableId: `skill.${skillName}`,
|
|
4220
|
-
srcRelPath:
|
|
4221
|
-
destPath:
|
|
5093
|
+
srcRelPath: path30.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
5094
|
+
destPath: path30.join(claudeDir, "skills", skillName, "SKILL.md")
|
|
4222
5095
|
});
|
|
4223
5096
|
}
|
|
4224
5097
|
}
|
|
4225
5098
|
}
|
|
4226
|
-
const commandsDir =
|
|
4227
|
-
if (
|
|
4228
|
-
for (const fileName of
|
|
5099
|
+
const commandsDir = path30.join(srcDir, "commands");
|
|
5100
|
+
if (fs24.pathExistsSync(commandsDir)) {
|
|
5101
|
+
for (const fileName of fs24.readdirSync(commandsDir)) {
|
|
4229
5102
|
if (!fileName.endsWith(".md")) continue;
|
|
4230
5103
|
const commandName = fileName.slice(0, -".md".length);
|
|
4231
5104
|
entries.push({
|
|
4232
5105
|
stableId: `command.${commandName}`,
|
|
4233
|
-
srcRelPath:
|
|
4234
|
-
destPath:
|
|
5106
|
+
srcRelPath: path30.join("library", "global", "commands", fileName),
|
|
5107
|
+
destPath: path30.join(claudeDir, "commands", fileName)
|
|
4235
5108
|
});
|
|
4236
5109
|
}
|
|
4237
5110
|
}
|
|
@@ -4249,13 +5122,15 @@ async function applyInstall(options = {}) {
|
|
|
4249
5122
|
created: [],
|
|
4250
5123
|
updated: [],
|
|
4251
5124
|
skipped: [],
|
|
5125
|
+
userOwned: [],
|
|
5126
|
+
userEdited: [],
|
|
4252
5127
|
deleted: [],
|
|
4253
5128
|
hookIds: [],
|
|
4254
5129
|
drift: false
|
|
4255
5130
|
};
|
|
4256
5131
|
const manifestFiles = [];
|
|
4257
5132
|
for (const entry of sourceFiles) {
|
|
4258
|
-
const srcPath =
|
|
5133
|
+
const srcPath = path30.join(packageRoot(), entry.srcRelPath);
|
|
4259
5134
|
const rawContent = await readText(srcPath);
|
|
4260
5135
|
if (rawContent === void 0) {
|
|
4261
5136
|
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
@@ -4275,7 +5150,7 @@ async function applyInstall(options = {}) {
|
|
|
4275
5150
|
}
|
|
4276
5151
|
continue;
|
|
4277
5152
|
}
|
|
4278
|
-
const destExists =
|
|
5153
|
+
const destExists = fs24.pathExistsSync(entry.destPath);
|
|
4279
5154
|
if (destExists) {
|
|
4280
5155
|
const currentContent = await readText(entry.destPath);
|
|
4281
5156
|
if (currentContent !== void 0) {
|
|
@@ -4283,11 +5158,13 @@ async function applyInstall(options = {}) {
|
|
|
4283
5158
|
if (!hasHeader) {
|
|
4284
5159
|
warn(`Refusing to overwrite user-owned file: ${entry.destPath}`);
|
|
4285
5160
|
result.skipped.push(entry.destPath);
|
|
5161
|
+
result.userOwned.push(entry.destPath);
|
|
4286
5162
|
continue;
|
|
4287
5163
|
}
|
|
4288
5164
|
if (existing && hashContent(currentContent) !== existing.hash && !force) {
|
|
4289
5165
|
warn(`User edited haus file (skipping): ${entry.destPath} \u2014 use --force to overwrite`);
|
|
4290
5166
|
result.skipped.push(entry.destPath);
|
|
5167
|
+
result.userEdited.push(entry.destPath);
|
|
4291
5168
|
manifestFiles.push(existing);
|
|
4292
5169
|
continue;
|
|
4293
5170
|
}
|
|
@@ -4311,25 +5188,29 @@ async function applyInstall(options = {}) {
|
|
|
4311
5188
|
schemaVersion: SCHEMA_VERSION2
|
|
4312
5189
|
});
|
|
4313
5190
|
}
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
5191
|
+
let mergedSettings;
|
|
5192
|
+
if (!check) {
|
|
5193
|
+
const fragmentPath = path30.join(srcDir, "settings-fragments", "hooks.json");
|
|
5194
|
+
const fragments = await loadHooksFragment(fragmentPath);
|
|
5195
|
+
const settings = await readSettings();
|
|
5196
|
+
const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
|
|
5197
|
+
const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
|
|
5198
|
+
const { settings: allowedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
|
|
5199
|
+
const merged = mergeAskRules(allowedSettings, buildAskRules());
|
|
5200
|
+
mergedSettings = merged.settings;
|
|
5201
|
+
result.hookIds = addedIds;
|
|
5202
|
+
}
|
|
4322
5203
|
if (!check && existingManifest) {
|
|
4323
5204
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
4324
5205
|
for (const entry of existingManifest.files) {
|
|
4325
5206
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
4326
|
-
if (!
|
|
5207
|
+
if (!fs24.pathExistsSync(entry.destPath)) continue;
|
|
4327
5208
|
const content2 = await readText(entry.destPath);
|
|
4328
5209
|
if (!content2) continue;
|
|
4329
5210
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
4330
5211
|
const currentHash = hashContent(content2);
|
|
4331
5212
|
if (hasHeader && currentHash === entry.hash) {
|
|
4332
|
-
if (!dryRun) await
|
|
5213
|
+
if (!dryRun) await fs24.remove(entry.destPath);
|
|
4333
5214
|
result.deleted.push(entry.destPath);
|
|
4334
5215
|
} else {
|
|
4335
5216
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -4337,10 +5218,10 @@ async function applyInstall(options = {}) {
|
|
|
4337
5218
|
}
|
|
4338
5219
|
}
|
|
4339
5220
|
}
|
|
4340
|
-
if (!dryRun && !check) {
|
|
5221
|
+
if (!dryRun && !check && mergedSettings) {
|
|
4341
5222
|
await writeSettings(mergedSettings);
|
|
4342
5223
|
const manifest = buildManifest(source, manifestFiles, [
|
|
4343
|
-
.../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...
|
|
5224
|
+
.../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...result.hookIds])
|
|
4344
5225
|
]);
|
|
4345
5226
|
await writeManifest(manifest);
|
|
4346
5227
|
}
|
|
@@ -4360,9 +5241,10 @@ function printApplyResult(result, dryRun) {
|
|
|
4360
5241
|
log(`${prefix}Deleted (orphaned):`);
|
|
4361
5242
|
result.deleted.forEach((p) => log(` x ${p}`));
|
|
4362
5243
|
}
|
|
4363
|
-
|
|
5244
|
+
const silentlySkipped = result.skipped.filter((p) => !result.userOwned.includes(p));
|
|
5245
|
+
if (silentlySkipped.length) {
|
|
4364
5246
|
log(`${prefix}Skipped:`);
|
|
4365
|
-
|
|
5247
|
+
silentlySkipped.forEach((p) => log(` - ${p}`));
|
|
4366
5248
|
}
|
|
4367
5249
|
if (result.hookIds.length) {
|
|
4368
5250
|
log(`${prefix}Hooks added: ${result.hookIds.join(", ")}`);
|
|
@@ -4410,7 +5292,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
|
|
|
4410
5292
|
}
|
|
4411
5293
|
return lines;
|
|
4412
5294
|
}
|
|
4413
|
-
function
|
|
5295
|
+
function commandExists2(bin) {
|
|
4414
5296
|
try {
|
|
4415
5297
|
const r = execaSync("sh", ["-c", 'command -v -- "$1"', "probe", bin], { reject: false });
|
|
4416
5298
|
return r.exitCode === 0;
|
|
@@ -4419,7 +5301,7 @@ function commandExists(bin) {
|
|
|
4419
5301
|
}
|
|
4420
5302
|
}
|
|
4421
5303
|
function printCompanionToolSuggestions(deps = {}) {
|
|
4422
|
-
const isInstalled = deps.isInstalled ??
|
|
5304
|
+
const isInstalled = deps.isInstalled ?? commandExists2;
|
|
4423
5305
|
const emit2 = deps.log ?? log;
|
|
4424
5306
|
const lines = buildCompanionSuggestions(COMPANION_TOOLS, isInstalled);
|
|
4425
5307
|
for (const line2 of lines) emit2(line2);
|
|
@@ -4481,7 +5363,7 @@ async function runRecommend(options) {
|
|
|
4481
5363
|
`Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
|
|
4482
5364
|
);
|
|
4483
5365
|
}
|
|
4484
|
-
for (const w of result.warnings
|
|
5366
|
+
for (const w of result.warnings) log(`- WARN: ${w}`);
|
|
4485
5367
|
}
|
|
4486
5368
|
|
|
4487
5369
|
// src/commands/refresh.ts
|
|
@@ -4497,8 +5379,8 @@ async function runRefresh() {
|
|
|
4497
5379
|
}
|
|
4498
5380
|
|
|
4499
5381
|
// src/install/scaffold.ts
|
|
4500
|
-
import
|
|
4501
|
-
import
|
|
5382
|
+
import path31 from "node:path";
|
|
5383
|
+
import fs25 from "fs-extra";
|
|
4502
5384
|
async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
4503
5385
|
const scaffolded = [];
|
|
4504
5386
|
const skipped = [];
|
|
@@ -4511,27 +5393,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
4511
5393
|
skipped.push(name);
|
|
4512
5394
|
if (outcome === "skipped-exists") skippedExisting.push(name);
|
|
4513
5395
|
};
|
|
4514
|
-
const realRoot = await
|
|
5396
|
+
const realRoot = await fs25.realpath(catalogRoot).catch(() => null);
|
|
4515
5397
|
for (const item of items) {
|
|
4516
5398
|
if (item.type !== "config") continue;
|
|
4517
|
-
const sourcePath =
|
|
4518
|
-
const rel =
|
|
4519
|
-
const escapes = rel === "" ||
|
|
5399
|
+
const sourcePath = path31.resolve(catalogRoot, item.path);
|
|
5400
|
+
const rel = path31.relative(catalogRoot, sourcePath);
|
|
5401
|
+
const escapes = rel === "" || path31.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
|
|
4520
5402
|
if (escapes) {
|
|
4521
5403
|
warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
|
|
4522
5404
|
continue;
|
|
4523
5405
|
}
|
|
4524
|
-
const realSource = await
|
|
5406
|
+
const realSource = await fs25.realpath(sourcePath).catch(() => null);
|
|
4525
5407
|
if (!realRoot || !realSource) {
|
|
4526
5408
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
4527
5409
|
continue;
|
|
4528
5410
|
}
|
|
4529
|
-
const realRel =
|
|
4530
|
-
if (realRel !== "" && (
|
|
5411
|
+
const realRel = path31.relative(realRoot, realSource);
|
|
5412
|
+
if (realRel !== "" && (path31.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
|
|
4531
5413
|
warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
|
|
4532
5414
|
continue;
|
|
4533
5415
|
}
|
|
4534
|
-
const stat = await
|
|
5416
|
+
const stat = await fs25.lstat(sourcePath).catch(() => null);
|
|
4535
5417
|
if (!stat) {
|
|
4536
5418
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
4537
5419
|
continue;
|
|
@@ -4541,14 +5423,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
4541
5423
|
continue;
|
|
4542
5424
|
}
|
|
4543
5425
|
if (stat.isFile()) {
|
|
4544
|
-
const filename =
|
|
4545
|
-
const dest =
|
|
5426
|
+
const filename = path31.basename(sourcePath);
|
|
5427
|
+
const dest = path31.join(projectRoot, filename);
|
|
4546
5428
|
record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
|
|
4547
5429
|
} else if (stat.isDirectory()) {
|
|
4548
|
-
const entries = await
|
|
5430
|
+
const entries = await fs25.readdir(sourcePath);
|
|
4549
5431
|
for (const entry of entries) {
|
|
4550
|
-
const src =
|
|
4551
|
-
const dest =
|
|
5432
|
+
const src = path31.join(sourcePath, entry);
|
|
5433
|
+
const dest = path31.join(projectRoot, entry);
|
|
4552
5434
|
record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
|
|
4553
5435
|
}
|
|
4554
5436
|
}
|
|
@@ -4556,42 +5438,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
4556
5438
|
return { scaffolded, skipped, skippedExisting };
|
|
4557
5439
|
}
|
|
4558
5440
|
async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
4559
|
-
const destDisplay =
|
|
4560
|
-
const srcStat = await
|
|
5441
|
+
const destDisplay = path31.relative(projectRoot, dest) || path31.basename(dest);
|
|
5442
|
+
const srcStat = await fs25.lstat(src).catch(() => null);
|
|
4561
5443
|
if (!srcStat) {
|
|
4562
|
-
warn(`Skipping ${
|
|
5444
|
+
warn(`Skipping ${path31.basename(src)}: source not found`);
|
|
4563
5445
|
return "skipped-refused";
|
|
4564
5446
|
}
|
|
4565
5447
|
if (srcStat.isSymbolicLink()) {
|
|
4566
|
-
warn(`Skipping ${
|
|
5448
|
+
warn(`Skipping ${path31.basename(src)}: source is a symlink`);
|
|
4567
5449
|
return "skipped-refused";
|
|
4568
5450
|
}
|
|
4569
|
-
const exists = await
|
|
5451
|
+
const exists = await fs25.pathExists(dest);
|
|
4570
5452
|
if (exists && !opts.force) {
|
|
4571
5453
|
warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
|
|
4572
5454
|
return "skipped-exists";
|
|
4573
5455
|
}
|
|
4574
5456
|
if (opts.dryRun) {
|
|
4575
|
-
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${
|
|
5457
|
+
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path31.basename(dest)} (${itemId})`);
|
|
4576
5458
|
return "scaffolded";
|
|
4577
5459
|
}
|
|
4578
5460
|
if (exists && opts.force) {
|
|
4579
|
-
await
|
|
5461
|
+
await fs25.remove(dest);
|
|
4580
5462
|
}
|
|
4581
|
-
await
|
|
4582
|
-
await
|
|
5463
|
+
await fs25.ensureDir(path31.dirname(dest));
|
|
5464
|
+
await fs25.copy(src, dest, {
|
|
4583
5465
|
overwrite: true,
|
|
4584
5466
|
dereference: false,
|
|
4585
5467
|
// A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
|
|
4586
5468
|
filter: (s) => {
|
|
4587
5469
|
try {
|
|
4588
|
-
return !
|
|
5470
|
+
return !fs25.lstatSync(s).isSymbolicLink();
|
|
4589
5471
|
} catch {
|
|
4590
5472
|
return false;
|
|
4591
5473
|
}
|
|
4592
5474
|
}
|
|
4593
5475
|
});
|
|
4594
|
-
log(`${exists ? "Overwrote" : "Created"} ${
|
|
5476
|
+
log(`${exists ? "Overwrote" : "Created"} ${path31.basename(dest)} (${itemId})`);
|
|
4595
5477
|
return "scaffolded";
|
|
4596
5478
|
}
|
|
4597
5479
|
|
|
@@ -4625,13 +5507,12 @@ async function runScaffold(ids, options) {
|
|
|
4625
5507
|
log(`\u2713 Scaffolded: ${result.scaffolded.join(", ")}`);
|
|
4626
5508
|
}
|
|
4627
5509
|
}
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
}
|
|
5510
|
+
const refused = result.skipped.filter((name) => !result.skippedExisting.includes(name));
|
|
5511
|
+
if (refused.length > 0) {
|
|
5512
|
+
log(`Skipped (source unavailable): ${refused.join(", ")}`);
|
|
5513
|
+
}
|
|
5514
|
+
if (result.skippedExisting.length > 0) {
|
|
5515
|
+
log(`Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`);
|
|
4635
5516
|
}
|
|
4636
5517
|
}
|
|
4637
5518
|
|
|
@@ -4645,11 +5526,12 @@ async function runScan(options) {
|
|
|
4645
5526
|
log("Haus scan complete");
|
|
4646
5527
|
log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
|
|
4647
5528
|
log(`Package manager: ${result.packageManager}`);
|
|
5529
|
+
for (const w of result.warnings) log(`- WARN: ${w}`);
|
|
4648
5530
|
}
|
|
4649
5531
|
|
|
4650
5532
|
// src/commands/undo.ts
|
|
4651
|
-
import
|
|
4652
|
-
import
|
|
5533
|
+
import path32 from "node:path";
|
|
5534
|
+
import fs26 from "fs-extra";
|
|
4653
5535
|
|
|
4654
5536
|
// src/claude/managed-paths.ts
|
|
4655
5537
|
var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
|
|
@@ -4666,74 +5548,77 @@ async function collectManagedPaths(root) {
|
|
|
4666
5548
|
const lock = await readJson(hausPath(root, "haus.lock.json"));
|
|
4667
5549
|
for (const row of lock ?? []) {
|
|
4668
5550
|
for (const rel of row.paths ?? []) {
|
|
4669
|
-
paths.add(
|
|
5551
|
+
paths.add(path32.resolve(root, rel));
|
|
4670
5552
|
}
|
|
4671
5553
|
}
|
|
4672
5554
|
const existing = [];
|
|
4673
5555
|
for (const abs of paths) {
|
|
4674
|
-
if (await
|
|
5556
|
+
if (await fs26.pathExists(abs)) existing.push(abs);
|
|
4675
5557
|
}
|
|
4676
5558
|
return existing;
|
|
4677
5559
|
}
|
|
4678
5560
|
async function settingsHasHausContent(root) {
|
|
4679
5561
|
const settingsPath = claudePath(root, "settings.json");
|
|
4680
|
-
if (!await
|
|
5562
|
+
if (!await fs26.pathExists(settingsPath)) return false;
|
|
4681
5563
|
const settings = await readProjectSettings(root);
|
|
4682
5564
|
return settings._haus != null;
|
|
4683
5565
|
}
|
|
4684
5566
|
async function claudeMdHasHausBlock(root) {
|
|
4685
|
-
const filePath =
|
|
4686
|
-
if (!await
|
|
4687
|
-
const text = await
|
|
5567
|
+
const filePath = path32.join(root, "CLAUDE.md");
|
|
5568
|
+
if (!await fs26.pathExists(filePath)) return false;
|
|
5569
|
+
const text = await fs26.readFile(filePath, "utf8");
|
|
4688
5570
|
return text.includes(BLOCK_BEGIN);
|
|
4689
5571
|
}
|
|
4690
5572
|
async function stripProjectSettings(root) {
|
|
4691
5573
|
const settingsPath = claudePath(root, "settings.json");
|
|
4692
|
-
if (!await
|
|
5574
|
+
if (!await fs26.pathExists(settingsPath)) return false;
|
|
4693
5575
|
let settings = await readProjectSettings(root);
|
|
4694
5576
|
settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
4695
5577
|
const hasContent = Object.keys(settings).length > 0;
|
|
4696
5578
|
if (hasContent) {
|
|
4697
5579
|
await writeProjectSettings(root, settings);
|
|
4698
|
-
log(`Stripped haus rules from ${
|
|
5580
|
+
log(`Stripped haus rules from ${path32.relative(root, settingsPath)} (user settings preserved).`);
|
|
4699
5581
|
return true;
|
|
4700
5582
|
}
|
|
4701
|
-
await
|
|
4702
|
-
log(`Removed ${
|
|
5583
|
+
await fs26.remove(settingsPath);
|
|
5584
|
+
log(`Removed ${path32.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
4703
5585
|
return true;
|
|
4704
5586
|
}
|
|
4705
5587
|
async function stripRootClaudeMd(root) {
|
|
4706
|
-
const filePath =
|
|
4707
|
-
if (!await
|
|
4708
|
-
const prev = await
|
|
5588
|
+
const filePath = path32.join(root, "CLAUDE.md");
|
|
5589
|
+
if (!await fs26.pathExists(filePath)) return false;
|
|
5590
|
+
const prev = await fs26.readFile(filePath, "utf8");
|
|
4709
5591
|
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
4710
5592
|
const next = stripHausBlock(prev);
|
|
4711
5593
|
if (next.length === 0) {
|
|
4712
|
-
await
|
|
5594
|
+
await fs26.remove(filePath);
|
|
4713
5595
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
4714
5596
|
} else {
|
|
4715
|
-
await
|
|
5597
|
+
await fs26.writeFile(filePath, next, "utf8");
|
|
4716
5598
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
4717
5599
|
}
|
|
4718
5600
|
return true;
|
|
4719
5601
|
}
|
|
4720
5602
|
async function pruneDirIfEmpty(dir) {
|
|
4721
|
-
if (!await
|
|
4722
|
-
|
|
4723
|
-
|
|
5603
|
+
if (!await fs26.pathExists(dir)) return;
|
|
5604
|
+
try {
|
|
5605
|
+
const entries = await fs26.readdir(dir);
|
|
5606
|
+
if (entries.length === 0) await fs26.remove(dir);
|
|
5607
|
+
} catch {
|
|
5608
|
+
}
|
|
4724
5609
|
}
|
|
4725
5610
|
async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
4726
5611
|
if (managedAbsPaths.length === 0) return;
|
|
4727
5612
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
4728
5613
|
const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
|
|
4729
5614
|
for (const abs of managedAbsPaths) {
|
|
4730
|
-
if (!await
|
|
4731
|
-
const rel =
|
|
4732
|
-
const backupPath =
|
|
4733
|
-
await
|
|
4734
|
-
await
|
|
5615
|
+
if (!await fs26.pathExists(abs)) continue;
|
|
5616
|
+
const rel = path32.relative(root, abs);
|
|
5617
|
+
const backupPath = path32.join(backupRoot, rel);
|
|
5618
|
+
await fs26.ensureDir(path32.dirname(backupPath));
|
|
5619
|
+
await fs26.copy(abs, backupPath);
|
|
4735
5620
|
}
|
|
4736
|
-
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${
|
|
5621
|
+
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path32.relative(root, backupRoot)}.`);
|
|
4737
5622
|
}
|
|
4738
5623
|
async function runUndo(options) {
|
|
4739
5624
|
const root = process.cwd();
|
|
@@ -4744,7 +5629,7 @@ async function runUndo(options) {
|
|
|
4744
5629
|
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
4745
5630
|
return;
|
|
4746
5631
|
}
|
|
4747
|
-
const relTargets = managed.map((p) =>
|
|
5632
|
+
const relTargets = managed.map((p) => path32.relative(root, p));
|
|
4748
5633
|
const summaryParts = [...relTargets];
|
|
4749
5634
|
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
4750
5635
|
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
@@ -4761,9 +5646,9 @@ User-owned .claude/ files will be preserved.`
|
|
|
4761
5646
|
}
|
|
4762
5647
|
await backupManagedFilesBeforeUndo(root, managed);
|
|
4763
5648
|
for (const abs of managed) {
|
|
4764
|
-
if (!await
|
|
4765
|
-
await
|
|
4766
|
-
log(`Removed ${
|
|
5649
|
+
if (!await fs26.pathExists(abs)) continue;
|
|
5650
|
+
await fs26.remove(abs);
|
|
5651
|
+
log(`Removed ${path32.relative(root, abs)}`);
|
|
4767
5652
|
}
|
|
4768
5653
|
if (stripSettings) await stripProjectSettings(root);
|
|
4769
5654
|
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
@@ -4774,8 +5659,8 @@ User-owned .claude/ files will be preserved.`
|
|
|
4774
5659
|
|
|
4775
5660
|
// src/install/uninstall.ts
|
|
4776
5661
|
import crypto3 from "node:crypto";
|
|
4777
|
-
import
|
|
4778
|
-
import
|
|
5662
|
+
import path33 from "node:path";
|
|
5663
|
+
import fs27 from "fs-extra";
|
|
4779
5664
|
async function runUninstall(options = {}) {
|
|
4780
5665
|
const { force = false } = options;
|
|
4781
5666
|
const manifest = await readManifest();
|
|
@@ -4785,7 +5670,7 @@ async function runUninstall(options = {}) {
|
|
|
4785
5670
|
return result;
|
|
4786
5671
|
}
|
|
4787
5672
|
for (const entry of manifest.files) {
|
|
4788
|
-
const exists =
|
|
5673
|
+
const exists = fs27.pathExistsSync(entry.destPath);
|
|
4789
5674
|
if (!exists) continue;
|
|
4790
5675
|
const content2 = await readText(entry.destPath);
|
|
4791
5676
|
if (content2 === void 0) continue;
|
|
@@ -4803,22 +5688,25 @@ async function runUninstall(options = {}) {
|
|
|
4803
5688
|
result.skipped.push(entry.destPath);
|
|
4804
5689
|
continue;
|
|
4805
5690
|
}
|
|
4806
|
-
await
|
|
4807
|
-
await pruneEmptyDir(
|
|
5691
|
+
await fs27.remove(entry.destPath);
|
|
5692
|
+
await pruneEmptyDir(path33.dirname(entry.destPath));
|
|
4808
5693
|
result.deleted.push(entry.destPath);
|
|
4809
5694
|
}
|
|
4810
5695
|
const settings = await readSettings();
|
|
4811
5696
|
const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
4812
5697
|
await writeSettings(stripped);
|
|
4813
5698
|
result.hooksStripped = true;
|
|
4814
|
-
const hausDir =
|
|
5699
|
+
const hausDir = path33.join(globalClaudeDir(), "haus");
|
|
4815
5700
|
const manifestPath2 = hausManifestPath();
|
|
4816
|
-
if (
|
|
4817
|
-
await
|
|
5701
|
+
if (fs27.pathExistsSync(manifestPath2)) {
|
|
5702
|
+
await fs27.remove(manifestPath2);
|
|
4818
5703
|
}
|
|
4819
|
-
if (
|
|
4820
|
-
|
|
4821
|
-
|
|
5704
|
+
if (fs27.pathExistsSync(hausDir)) {
|
|
5705
|
+
try {
|
|
5706
|
+
const remaining = await fs27.readdir(hausDir);
|
|
5707
|
+
if (remaining.length === 0) await fs27.remove(hausDir);
|
|
5708
|
+
} catch {
|
|
5709
|
+
}
|
|
4822
5710
|
}
|
|
4823
5711
|
return result;
|
|
4824
5712
|
}
|
|
@@ -4849,12 +5737,12 @@ async function runUninstallCommand(options) {
|
|
|
4849
5737
|
}
|
|
4850
5738
|
|
|
4851
5739
|
// src/commands/update.ts
|
|
4852
|
-
import
|
|
5740
|
+
import path35 from "node:path";
|
|
4853
5741
|
|
|
4854
5742
|
// src/claude/refresh-project.ts
|
|
4855
|
-
import
|
|
5743
|
+
import fs28 from "fs-extra";
|
|
4856
5744
|
async function isHausProject(root) {
|
|
4857
|
-
if (await
|
|
5745
|
+
if (await fs28.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
4858
5746
|
const result = await readJsonDetailed(claudePath(root, "settings.json"));
|
|
4859
5747
|
if (result.status === "ok") return result.value._haus != null;
|
|
4860
5748
|
return false;
|
|
@@ -4889,7 +5777,7 @@ function summarizeLockDiff(before, after) {
|
|
|
4889
5777
|
|
|
4890
5778
|
// src/update/lockfile.ts
|
|
4891
5779
|
import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
|
|
4892
|
-
import
|
|
5780
|
+
import path34 from "node:path";
|
|
4893
5781
|
async function checkLock(root) {
|
|
4894
5782
|
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
4895
5783
|
const hasValidVersions = lock.every(
|
|
@@ -4920,7 +5808,7 @@ async function applyLock(root) {
|
|
|
4920
5808
|
try {
|
|
4921
5809
|
const backupDir = hausPath(root, "backups");
|
|
4922
5810
|
await mkdir(backupDir, { recursive: true });
|
|
4923
|
-
await copyFile(lockPath,
|
|
5811
|
+
await copyFile(lockPath, path34.join(backupDir, `haus.lock.${Date.now()}.json`));
|
|
4924
5812
|
} catch {
|
|
4925
5813
|
}
|
|
4926
5814
|
const enriched = await Promise.all(
|
|
@@ -4942,7 +5830,7 @@ function diffLock(before, after) {
|
|
|
4942
5830
|
}
|
|
4943
5831
|
async function hasLocalOverrides(root) {
|
|
4944
5832
|
try {
|
|
4945
|
-
await readFile2(
|
|
5833
|
+
await readFile2(path34.join(root, ".claude", "settings.json"), "utf8");
|
|
4946
5834
|
return true;
|
|
4947
5835
|
} catch {
|
|
4948
5836
|
return false;
|
|
@@ -4954,7 +5842,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
|
|
|
4954
5842
|
async function runUpdate(options) {
|
|
4955
5843
|
const root = process.cwd();
|
|
4956
5844
|
if (options.check) {
|
|
4957
|
-
const pkgJson2 = await readJson(
|
|
5845
|
+
const pkgJson2 = await readJson(path35.join(packageRoot(), "package.json"));
|
|
4958
5846
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
4959
5847
|
const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
|
|
4960
5848
|
checkLock(root),
|
|
@@ -4980,11 +5868,10 @@ async function runUpdate(options) {
|
|
|
4980
5868
|
2
|
|
4981
5869
|
)
|
|
4982
5870
|
);
|
|
4983
|
-
if (!status.ok) process.exitCode = 1;
|
|
4984
|
-
if (status.driftCount > 0) process.exitCode = 1;
|
|
5871
|
+
if (status.count > 0 && !status.ok) process.exitCode = 1;
|
|
4985
5872
|
return;
|
|
4986
5873
|
}
|
|
4987
|
-
const pkgJson = await readJson(
|
|
5874
|
+
const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
|
|
4988
5875
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
4989
5876
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
4990
5877
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -5042,9 +5929,9 @@ async function refreshGlobalInstall() {
|
|
|
5042
5929
|
} else {
|
|
5043
5930
|
log("~/.claude already up to date.");
|
|
5044
5931
|
}
|
|
5045
|
-
if (result.
|
|
5932
|
+
if (result.userEdited.length > 0) {
|
|
5046
5933
|
log(
|
|
5047
|
-
`Preserved ${result.
|
|
5934
|
+
`Preserved ${result.userEdited.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
|
|
5048
5935
|
);
|
|
5049
5936
|
}
|
|
5050
5937
|
} catch (err) {
|
|
@@ -5061,16 +5948,15 @@ async function detectGlobalInstallDrift() {
|
|
|
5061
5948
|
}
|
|
5062
5949
|
|
|
5063
5950
|
// src/commands/validate-catalog.ts
|
|
5064
|
-
import
|
|
5951
|
+
import path37 from "node:path";
|
|
5065
5952
|
|
|
5066
5953
|
// src/catalog/validate-core.ts
|
|
5067
|
-
import
|
|
5068
|
-
import
|
|
5954
|
+
import fs29 from "node:fs";
|
|
5955
|
+
import path36 from "node:path";
|
|
5069
5956
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
5070
|
-
var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
|
|
5071
5957
|
function isSafeCatalogPath2(itemPath) {
|
|
5072
|
-
if (!itemPath ||
|
|
5073
|
-
const normalized =
|
|
5958
|
+
if (!itemPath || path36.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
|
|
5959
|
+
const normalized = path36.normalize(itemPath);
|
|
5074
5960
|
return !normalized.startsWith("..") && !normalized.includes("/..");
|
|
5075
5961
|
}
|
|
5076
5962
|
function auditForbiddenStacks(items) {
|
|
@@ -5121,10 +6007,8 @@ function auditManifestStructure(manifestVersion, items) {
|
|
|
5121
6007
|
const failures = [];
|
|
5122
6008
|
const seenIds = /* @__PURE__ */ new Map();
|
|
5123
6009
|
const seenPaths = /* @__PURE__ */ new Map();
|
|
5124
|
-
if (typeof manifestVersion !== "string" || !
|
|
5125
|
-
failures.push(
|
|
5126
|
-
'manifest.json: top-level "version" is not valid semver (expected X.Y.Z or X.Y.Z-pre)'
|
|
5127
|
-
);
|
|
6010
|
+
if (typeof manifestVersion !== "string" || !ITEM_SEMVER_RE.test(manifestVersion)) {
|
|
6011
|
+
failures.push('manifest.json: top-level "version" is not valid semver (expected X.Y.Z)');
|
|
5128
6012
|
}
|
|
5129
6013
|
for (let i = 0; i < items.length; i++) {
|
|
5130
6014
|
const item = items[i];
|
|
@@ -5181,10 +6065,19 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
5181
6065
|
}
|
|
5182
6066
|
return failures;
|
|
5183
6067
|
}
|
|
6068
|
+
function readForAudit(absPath, label, failures) {
|
|
6069
|
+
try {
|
|
6070
|
+
return fs29.readFileSync(absPath, "utf8");
|
|
6071
|
+
} catch (err) {
|
|
6072
|
+
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
6073
|
+
return null;
|
|
6074
|
+
}
|
|
6075
|
+
}
|
|
5184
6076
|
function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
5185
|
-
const rel =
|
|
5186
|
-
const text = fs22.readFileSync(absPath, "utf8");
|
|
6077
|
+
const rel = path36.relative(manifestDir, absPath);
|
|
5187
6078
|
const failures = [];
|
|
6079
|
+
const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
|
|
6080
|
+
if (text === null) return failures;
|
|
5188
6081
|
const lines = text.split(/\r?\n/);
|
|
5189
6082
|
for (let i = 0; i < lines.length; i++) {
|
|
5190
6083
|
const line2 = lines[i] ?? "";
|
|
@@ -5203,44 +6096,47 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
5203
6096
|
const failures = [];
|
|
5204
6097
|
for (const item of items) {
|
|
5205
6098
|
if (!item.path || !isSafeCatalogPath2(item.path)) continue;
|
|
5206
|
-
const absPath =
|
|
6099
|
+
const absPath = path36.join(manifestDir, item.path);
|
|
5207
6100
|
if (item.type === "skill") {
|
|
5208
|
-
const skillMd =
|
|
5209
|
-
if (!
|
|
5210
|
-
failures.push(`${item.id}: missing ${
|
|
6101
|
+
const skillMd = path36.join(absPath, "SKILL.md");
|
|
6102
|
+
if (!fs29.existsSync(skillMd)) {
|
|
6103
|
+
failures.push(`${item.id}: missing ${path36.relative(manifestDir, skillMd)}`);
|
|
5211
6104
|
continue;
|
|
5212
6105
|
}
|
|
5213
|
-
const text =
|
|
6106
|
+
const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
|
|
6107
|
+
if (text === null) continue;
|
|
5214
6108
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
|
|
5215
6109
|
failures.push(
|
|
5216
|
-
...auditForbiddenTagsInText(text, `${item.id}: ${
|
|
6110
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path36.relative(manifestDir, skillMd)}`)
|
|
5217
6111
|
);
|
|
5218
6112
|
} else if (item.type === "agent") {
|
|
5219
|
-
if (!
|
|
6113
|
+
if (!fs29.existsSync(absPath)) {
|
|
5220
6114
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
5221
6115
|
continue;
|
|
5222
6116
|
}
|
|
5223
|
-
const
|
|
5224
|
-
const
|
|
6117
|
+
const rel = path36.relative(manifestDir, absPath);
|
|
6118
|
+
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
6119
|
+
if (text === null) continue;
|
|
5225
6120
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
5226
6121
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
5227
6122
|
} else if (item.type === "template") {
|
|
5228
|
-
if (!
|
|
6123
|
+
if (!fs29.existsSync(absPath)) {
|
|
5229
6124
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
5230
6125
|
continue;
|
|
5231
6126
|
}
|
|
5232
6127
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
5233
6128
|
} else if (item.type === "command") {
|
|
5234
|
-
if (!
|
|
6129
|
+
if (!fs29.existsSync(absPath)) {
|
|
5235
6130
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
5236
6131
|
continue;
|
|
5237
6132
|
}
|
|
5238
|
-
const
|
|
5239
|
-
const
|
|
6133
|
+
const rel = path36.relative(manifestDir, absPath);
|
|
6134
|
+
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
6135
|
+
if (text === null) continue;
|
|
5240
6136
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
5241
6137
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
5242
6138
|
} else if (item.type === "config") {
|
|
5243
|
-
if (!
|
|
6139
|
+
if (!fs29.existsSync(absPath)) {
|
|
5244
6140
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
5245
6141
|
}
|
|
5246
6142
|
}
|
|
@@ -5253,10 +6149,19 @@ var DIR_ITEM_TYPE = {
|
|
|
5253
6149
|
templates: "template",
|
|
5254
6150
|
commands: "command"
|
|
5255
6151
|
};
|
|
5256
|
-
function walkMd(dir, fn) {
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
6152
|
+
function walkMd(dir, fn, failures) {
|
|
6153
|
+
let entries;
|
|
6154
|
+
try {
|
|
6155
|
+
entries = fs29.readdirSync(dir, { withFileTypes: true });
|
|
6156
|
+
} catch (err) {
|
|
6157
|
+
failures.push(
|
|
6158
|
+
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
6159
|
+
);
|
|
6160
|
+
return;
|
|
6161
|
+
}
|
|
6162
|
+
for (const entry of entries) {
|
|
6163
|
+
const full = path36.join(dir, entry.name);
|
|
6164
|
+
if (entry.isDirectory()) walkMd(full, fn, failures);
|
|
5260
6165
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
5261
6166
|
}
|
|
5262
6167
|
}
|
|
@@ -5264,26 +6169,31 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
5264
6169
|
const failures = [];
|
|
5265
6170
|
const pathSourceMap = buildItemPathSourceMap(items);
|
|
5266
6171
|
for (const dir of ["skills", "agents", "templates", "commands"]) {
|
|
5267
|
-
const abs =
|
|
5268
|
-
if (!
|
|
6172
|
+
const abs = path36.join(manifestDir, dir);
|
|
6173
|
+
if (!fs29.existsSync(abs)) continue;
|
|
5269
6174
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
5270
|
-
walkMd(
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
const
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
6175
|
+
walkMd(
|
|
6176
|
+
abs,
|
|
6177
|
+
(file) => {
|
|
6178
|
+
const rel = path36.relative(manifestDir, file);
|
|
6179
|
+
const text = readForAudit(file, rel, failures);
|
|
6180
|
+
if (text === null) return;
|
|
6181
|
+
const norm = rel.replace(/\\/g, "/");
|
|
6182
|
+
const source = resolveMarkdownItemSource(norm, pathSourceMap);
|
|
6183
|
+
const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
|
|
6184
|
+
const lines = text.split(/\r?\n/);
|
|
6185
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6186
|
+
const line2 = lines[i] ?? "";
|
|
6187
|
+
if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
|
|
6188
|
+
failures.push(`${rel}:${i + 1}: risky install pattern`);
|
|
6189
|
+
if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
|
|
6190
|
+
failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
|
|
6191
|
+
}
|
|
5283
6192
|
}
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
6193
|
+
if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
|
|
6194
|
+
},
|
|
6195
|
+
failures
|
|
6196
|
+
);
|
|
5287
6197
|
}
|
|
5288
6198
|
return failures;
|
|
5289
6199
|
}
|
|
@@ -5306,8 +6216,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
5306
6216
|
process.exitCode = 1;
|
|
5307
6217
|
return;
|
|
5308
6218
|
}
|
|
5309
|
-
const abs =
|
|
5310
|
-
const manifestDir =
|
|
6219
|
+
const abs = path37.resolve(process.cwd(), manifestPath2);
|
|
6220
|
+
const manifestDir = path37.dirname(abs);
|
|
5311
6221
|
const data = await readJson(abs);
|
|
5312
6222
|
if (!data?.items) {
|
|
5313
6223
|
error(`Could not read catalog manifest at ${abs}`);
|
|
@@ -5325,7 +6235,7 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
5325
6235
|
|
|
5326
6236
|
// src/commands/workspace.ts
|
|
5327
6237
|
import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
|
|
5328
|
-
import
|
|
6238
|
+
import path44 from "node:path";
|
|
5329
6239
|
|
|
5330
6240
|
// src/commands/workspace/aggregate.ts
|
|
5331
6241
|
async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
|
|
@@ -5379,7 +6289,7 @@ ${summaries.map(
|
|
|
5379
6289
|
}
|
|
5380
6290
|
|
|
5381
6291
|
// src/commands/workspace/config.ts
|
|
5382
|
-
import
|
|
6292
|
+
import path38 from "node:path";
|
|
5383
6293
|
import YAML from "yaml";
|
|
5384
6294
|
var WORKSPACE_FILE = "haus.workspace.yaml";
|
|
5385
6295
|
function parseWorkspaceConfig(text) {
|
|
@@ -5402,11 +6312,11 @@ function parseWorkspaceConfig(text) {
|
|
|
5402
6312
|
};
|
|
5403
6313
|
}
|
|
5404
6314
|
async function readWorkspaceConfig(workspaceRoot) {
|
|
5405
|
-
return parseWorkspaceConfig(await readText(
|
|
6315
|
+
return parseWorkspaceConfig(await readText(path38.join(workspaceRoot, WORKSPACE_FILE)));
|
|
5406
6316
|
}
|
|
5407
6317
|
|
|
5408
6318
|
// src/commands/workspace/discover.ts
|
|
5409
|
-
import
|
|
6319
|
+
import path39 from "node:path";
|
|
5410
6320
|
import fg4 from "fast-glob";
|
|
5411
6321
|
import YAML2 from "yaml";
|
|
5412
6322
|
var DEFAULT_MAX_DEPTH = 3;
|
|
@@ -5429,13 +6339,16 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
5429
6339
|
onlyFiles: false,
|
|
5430
6340
|
deep: maxDepth,
|
|
5431
6341
|
followSymbolicLinks: false,
|
|
6342
|
+
// Discovery walks arbitrary directories under the workspace root; an
|
|
6343
|
+
// unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
|
|
6344
|
+
suppressErrors: true,
|
|
5432
6345
|
ignore: IGNORE
|
|
5433
6346
|
});
|
|
5434
6347
|
const gitDirs = /* @__PURE__ */ new Set();
|
|
5435
6348
|
const manifestDirs = /* @__PURE__ */ new Set();
|
|
5436
6349
|
for (const match of matches) {
|
|
5437
|
-
const base =
|
|
5438
|
-
const dir =
|
|
6350
|
+
const base = path39.posix.basename(match);
|
|
6351
|
+
const dir = path39.posix.dirname(match);
|
|
5439
6352
|
const owner = dir === "." ? "." : dir;
|
|
5440
6353
|
if (base === ".git") gitDirs.add(owner);
|
|
5441
6354
|
else manifestDirs.add(owner);
|
|
@@ -5451,9 +6364,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
5451
6364
|
}
|
|
5452
6365
|
repoRoots.sort((a, b) => a.localeCompare(b));
|
|
5453
6366
|
return mapWithConcurrency(repoRoots, async (relDir) => {
|
|
5454
|
-
const absDir =
|
|
5455
|
-
const pkg = await readJson(
|
|
5456
|
-
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name :
|
|
6367
|
+
const absDir = path39.resolve(workspaceRoot, relDir);
|
|
6368
|
+
const pkg = await readJson(path39.join(absDir, "package.json"));
|
|
6369
|
+
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path39.basename(relDir === "." ? workspaceRoot : absDir);
|
|
5457
6370
|
let role = "auto";
|
|
5458
6371
|
try {
|
|
5459
6372
|
const scan = await scanProject(absDir);
|
|
@@ -5496,7 +6409,7 @@ function renderWorkspaceYaml(config) {
|
|
|
5496
6409
|
});
|
|
5497
6410
|
}
|
|
5498
6411
|
async function runDiscover(workspaceRoot, opts = {}) {
|
|
5499
|
-
const yamlPath =
|
|
6412
|
+
const yamlPath = path39.join(workspaceRoot, "haus.workspace.yaml");
|
|
5500
6413
|
const existingText = await readText(yamlPath);
|
|
5501
6414
|
const existing = parseWorkspaceConfig(existingText);
|
|
5502
6415
|
if (existingText && !existing) {
|
|
@@ -5514,34 +6427,37 @@ async function runDiscover(workspaceRoot, opts = {}) {
|
|
|
5514
6427
|
}
|
|
5515
6428
|
const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
|
|
5516
6429
|
const yamlText = renderWorkspaceYaml(merged);
|
|
5517
|
-
if (opts.json) {
|
|
5518
|
-
log(JSON.stringify({ discovered, config: merged }, null, 2));
|
|
5519
|
-
}
|
|
5520
6430
|
if (opts.write) {
|
|
5521
6431
|
await writeText(yamlPath, yamlText);
|
|
5522
|
-
|
|
6432
|
+
if (opts.json) {
|
|
6433
|
+
log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
|
|
6434
|
+
} else {
|
|
6435
|
+
log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
|
|
6436
|
+
}
|
|
5523
6437
|
return;
|
|
5524
6438
|
}
|
|
5525
|
-
if (
|
|
5526
|
-
log(
|
|
5527
|
-
|
|
6439
|
+
if (opts.json) {
|
|
6440
|
+
log(JSON.stringify({ discovered, config: merged }, null, 2));
|
|
6441
|
+
return;
|
|
5528
6442
|
}
|
|
6443
|
+
log("Proposed haus.workspace.yaml (run with --write to persist):\n");
|
|
6444
|
+
log(yamlText);
|
|
5529
6445
|
}
|
|
5530
6446
|
|
|
5531
6447
|
// src/commands/workspace/doctor.ts
|
|
5532
6448
|
import { existsSync as existsSync3 } from "node:fs";
|
|
5533
|
-
import
|
|
6449
|
+
import path41 from "node:path";
|
|
5534
6450
|
|
|
5535
6451
|
// src/commands/workspace/manifest.ts
|
|
5536
6452
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
5537
|
-
import
|
|
6453
|
+
import path40 from "node:path";
|
|
5538
6454
|
var MANIFEST_FILE = "workspace.manifest.json";
|
|
5539
6455
|
function manifestPath(workspaceRoot) {
|
|
5540
6456
|
return hausPath(workspaceRoot, MANIFEST_FILE);
|
|
5541
6457
|
}
|
|
5542
6458
|
function hausVersion() {
|
|
5543
6459
|
try {
|
|
5544
|
-
const pkg = JSON.parse(readFileSync3(
|
|
6460
|
+
const pkg = JSON.parse(readFileSync3(path40.join(packageRoot(), "package.json"), "utf8"));
|
|
5545
6461
|
return pkg.version ?? "0.0.0";
|
|
5546
6462
|
} catch {
|
|
5547
6463
|
return "0.0.0";
|
|
@@ -5607,7 +6523,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
5607
6523
|
}
|
|
5608
6524
|
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
5609
6525
|
for (const repo of config.repos) {
|
|
5610
|
-
const repoRoot =
|
|
6526
|
+
const repoRoot = path41.resolve(workspaceRoot, repo.path);
|
|
5611
6527
|
const entry = manifestByName.get(repo.name);
|
|
5612
6528
|
if (!entry) {
|
|
5613
6529
|
flag({
|
|
@@ -5681,11 +6597,11 @@ function emit(args) {
|
|
|
5681
6597
|
|
|
5682
6598
|
// src/commands/workspace/setup.ts
|
|
5683
6599
|
import { existsSync as existsSync4, statSync } from "node:fs";
|
|
5684
|
-
import
|
|
6600
|
+
import path43 from "node:path";
|
|
5685
6601
|
|
|
5686
6602
|
// src/claude/write-workspace-claude-md.ts
|
|
5687
|
-
import
|
|
5688
|
-
import
|
|
6603
|
+
import path42 from "node:path";
|
|
6604
|
+
import fs30 from "fs-extra";
|
|
5689
6605
|
function buildWorkspaceImportBlock(client, members) {
|
|
5690
6606
|
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
5691
6607
|
const body = [
|
|
@@ -5703,25 +6619,27 @@ ${BLOCK_END}`;
|
|
|
5703
6619
|
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
5704
6620
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
5705
6621
|
const dryRun = opts.dryRun ?? false;
|
|
5706
|
-
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") :
|
|
5707
|
-
const
|
|
6622
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path42.join(workspaceRoot, "CLAUDE.md");
|
|
6623
|
+
const say = opts.quiet ? () => {
|
|
6624
|
+
} : log;
|
|
6625
|
+
const prev = await fs30.pathExists(filePath) ? await fs30.readFile(filePath, "utf8") : "";
|
|
5708
6626
|
const next = opts.collision ? `${block}
|
|
5709
6627
|
` : injectHausBlock(prev, block);
|
|
5710
6628
|
const printable = displayPath(workspaceRoot, filePath);
|
|
5711
6629
|
if (dryRun) {
|
|
5712
6630
|
if (!prev) {
|
|
5713
|
-
|
|
6631
|
+
say(createUnifiedDiff(printable, "", next));
|
|
5714
6632
|
} else if (hasTextChanged(prev, next)) {
|
|
5715
|
-
|
|
6633
|
+
say(createUnifiedDiff(printable, prev, next));
|
|
5716
6634
|
} else {
|
|
5717
|
-
|
|
6635
|
+
say(`${printable}: unchanged`);
|
|
5718
6636
|
}
|
|
5719
6637
|
return filePath;
|
|
5720
6638
|
}
|
|
5721
6639
|
if (hasTextChanged(prev, next) && prev.length > 0) {
|
|
5722
6640
|
const diffText = createUnifiedDiff(printable, prev, next);
|
|
5723
6641
|
const summary = summarizeDiff(diffText);
|
|
5724
|
-
|
|
6642
|
+
say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
5725
6643
|
}
|
|
5726
6644
|
await writeText(filePath, next);
|
|
5727
6645
|
return filePath;
|
|
@@ -5729,20 +6647,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
5729
6647
|
|
|
5730
6648
|
// src/commands/workspace/setup.ts
|
|
5731
6649
|
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
5732
|
-
let dir =
|
|
6650
|
+
let dir = path43.resolve(start);
|
|
5733
6651
|
for (; ; ) {
|
|
5734
|
-
if (existsSync4(
|
|
5735
|
-
const parent =
|
|
5736
|
-
if (parent === dir) return
|
|
6652
|
+
if (existsSync4(path43.join(dir, WORKSPACE_FILE))) return dir;
|
|
6653
|
+
const parent = path43.dirname(dir);
|
|
6654
|
+
if (parent === dir) return path43.resolve(start);
|
|
5737
6655
|
dir = parent;
|
|
5738
6656
|
}
|
|
5739
6657
|
}
|
|
5740
6658
|
function isRootRepo(workspaceRoot, repoPath) {
|
|
5741
|
-
return
|
|
6659
|
+
return path43.resolve(workspaceRoot, repoPath) === path43.resolve(workspaceRoot);
|
|
5742
6660
|
}
|
|
5743
6661
|
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
5744
6662
|
const apply = options.write ?? false;
|
|
5745
|
-
const configText = await readText(
|
|
6663
|
+
const configText = await readText(path43.join(workspaceRoot, WORKSPACE_FILE));
|
|
5746
6664
|
if (!configText) {
|
|
5747
6665
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
5748
6666
|
process.exitCode = 1;
|
|
@@ -5754,22 +6672,34 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5754
6672
|
process.exitCode = 1;
|
|
5755
6673
|
return { workspaceRoot, statuses: [], written: [] };
|
|
5756
6674
|
}
|
|
6675
|
+
const say = options.json ? () => {
|
|
6676
|
+
} : log;
|
|
5757
6677
|
const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : void 0;
|
|
6678
|
+
if (onlySet) {
|
|
6679
|
+
const unknown = [...onlySet].filter((name) => !config.repos.some((r) => r.name === name));
|
|
6680
|
+
if (unknown.length > 0) {
|
|
6681
|
+
error(`--only matched no configured repo(s): ${unknown.join(", ")}`);
|
|
6682
|
+
process.exitCode = 1;
|
|
6683
|
+
return { workspaceRoot, statuses: [], written: [] };
|
|
6684
|
+
}
|
|
6685
|
+
}
|
|
5758
6686
|
const repos = onlySet ? config.repos.filter((r) => onlySet.has(r.name)) : config.repos;
|
|
5759
6687
|
const statuses = [];
|
|
5760
6688
|
const aggregateInputs = [];
|
|
5761
6689
|
for (const repo of repos) {
|
|
5762
|
-
const repoRoot =
|
|
5763
|
-
|
|
6690
|
+
const repoRoot = path43.resolve(workspaceRoot, repo.path);
|
|
6691
|
+
say(`
|
|
5764
6692
|
\u2192 ${repo.name} (${repo.path})`);
|
|
5765
6693
|
try {
|
|
5766
6694
|
if (!existsSync4(repoRoot) || !statSync(repoRoot).isDirectory()) {
|
|
5767
6695
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
5768
6696
|
}
|
|
5769
6697
|
const res = await runSetupCore(repoRoot, {
|
|
5770
|
-
json: options.json,
|
|
5771
6698
|
apply,
|
|
5772
|
-
dryRun: options.dryRun
|
|
6699
|
+
dryRun: options.dryRun,
|
|
6700
|
+
// Under --json, mute the inner pipeline entirely; we serialize the
|
|
6701
|
+
// aggregated result below. Otherwise let it print its normal human output.
|
|
6702
|
+
quiet: options.json
|
|
5773
6703
|
});
|
|
5774
6704
|
statuses.push({
|
|
5775
6705
|
name: repo.name,
|
|
@@ -5809,9 +6739,10 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5809
6739
|
client: config.client,
|
|
5810
6740
|
members: config.repos.map((r) => ({ name: r.name, path: r.path })),
|
|
5811
6741
|
collision,
|
|
5812
|
-
dryRun: options.dryRun
|
|
6742
|
+
dryRun: options.dryRun,
|
|
6743
|
+
quiet: options.json
|
|
5813
6744
|
});
|
|
5814
|
-
written.push(docPath);
|
|
6745
|
+
if (!options.dryRun) written.push(docPath);
|
|
5815
6746
|
}
|
|
5816
6747
|
if (apply && !options.dryRun) {
|
|
5817
6748
|
const statusByName = new Map(statuses.map((s) => [s.name, s]));
|
|
@@ -5827,7 +6758,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5827
6758
|
const status = statusByName.get(repo.name);
|
|
5828
6759
|
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
5829
6760
|
if (status?.status === "ok") {
|
|
5830
|
-
const lock = await checkLock(
|
|
6761
|
+
const lock = await checkLock(path43.resolve(workspaceRoot, repo.path));
|
|
5831
6762
|
manifestRepos.push({
|
|
5832
6763
|
name: repo.name,
|
|
5833
6764
|
path: repo.path,
|
|
@@ -5876,9 +6807,17 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5876
6807
|
}
|
|
5877
6808
|
const ok = statuses.filter((s) => s.status === "ok").length;
|
|
5878
6809
|
const failed = statuses.length - ok;
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
6810
|
+
const result = { workspaceRoot, statuses, written };
|
|
6811
|
+
if (options.json) {
|
|
6812
|
+
log(JSON.stringify(result, null, 2));
|
|
6813
|
+
} else if (!apply) {
|
|
6814
|
+
say(`
|
|
6815
|
+
Workspace preview complete: ${ok} ok, ${failed} failed. Re-run with --write to apply.`);
|
|
6816
|
+
} else {
|
|
6817
|
+
say(`
|
|
6818
|
+
Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} file(s).`);
|
|
6819
|
+
}
|
|
6820
|
+
return result;
|
|
5882
6821
|
}
|
|
5883
6822
|
|
|
5884
6823
|
// src/commands/workspace.ts
|
|
@@ -5891,7 +6830,9 @@ function normalizeOnly(only) {
|
|
|
5891
6830
|
function normalizeMaxDepth(maxDepth) {
|
|
5892
6831
|
if (maxDepth === void 0) return void 0;
|
|
5893
6832
|
const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
|
|
5894
|
-
|
|
6833
|
+
if (Number.isFinite(n) && n > 0) return Math.floor(n);
|
|
6834
|
+
warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
|
|
6835
|
+
return void 0;
|
|
5895
6836
|
}
|
|
5896
6837
|
async function initWorkspace() {
|
|
5897
6838
|
await writeText(
|
|
@@ -5907,7 +6848,7 @@ relationships: []
|
|
|
5907
6848
|
log("Workspace initialized.");
|
|
5908
6849
|
}
|
|
5909
6850
|
async function scanWorkspace(workspaceRoot, opts) {
|
|
5910
|
-
const configText = await readText(
|
|
6851
|
+
const configText = await readText(path44.join(workspaceRoot, WORKSPACE_FILE));
|
|
5911
6852
|
if (!configText) {
|
|
5912
6853
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
5913
6854
|
process.exitCode = 1;
|
|
@@ -5927,20 +6868,31 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
5927
6868
|
return;
|
|
5928
6869
|
}
|
|
5929
6870
|
const inputs = [];
|
|
6871
|
+
const failures = [];
|
|
5930
6872
|
for (const repo of config.repos) {
|
|
5931
|
-
const repoRoot =
|
|
5932
|
-
|
|
5933
|
-
|
|
6873
|
+
const repoRoot = path44.resolve(workspaceRoot, repo.path);
|
|
6874
|
+
try {
|
|
6875
|
+
if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
6876
|
+
throw new Error("path is not a directory");
|
|
6877
|
+
}
|
|
6878
|
+
const result = await scanProject(repoRoot);
|
|
6879
|
+
inputs.push({ name: repo.name, path: repo.path, context: result });
|
|
6880
|
+
} catch (err) {
|
|
6881
|
+
failures.push(
|
|
6882
|
+
`${repo.name} (${repo.path}): ${err instanceof Error ? err.message : String(err)}`
|
|
6883
|
+
);
|
|
5934
6884
|
}
|
|
5935
|
-
const result = await scanProject(repoRoot);
|
|
5936
|
-
inputs.push({ name: repo.name, path: repo.path, context: result });
|
|
5937
6885
|
}
|
|
5938
6886
|
const written = await writeWorkspaceArtifacts(workspaceRoot, inputs, config.relationships);
|
|
5939
6887
|
if (opts.json) {
|
|
5940
|
-
log(JSON.stringify({ written }, null, 2));
|
|
6888
|
+
log(JSON.stringify({ written, scanned: inputs.length, failed: failures }, null, 2));
|
|
5941
6889
|
} else {
|
|
5942
|
-
log(
|
|
6890
|
+
log(
|
|
6891
|
+
`Workspace scan complete. Scanned ${inputs.length} repo(s); wrote ${written.length} artifact(s) under .haus-workflow/.`
|
|
6892
|
+
);
|
|
6893
|
+
for (const f of failures) warn(`- skipped ${f}`);
|
|
5943
6894
|
}
|
|
6895
|
+
if (failures.length > 0) process.exitCode = 1;
|
|
5944
6896
|
}
|
|
5945
6897
|
async function runWorkspace(action, options = {}) {
|
|
5946
6898
|
if (action === "init") {
|
|
@@ -5978,7 +6930,7 @@ async function runWorkspace(action, options = {}) {
|
|
|
5978
6930
|
// src/cli.ts
|
|
5979
6931
|
function cliVersion() {
|
|
5980
6932
|
try {
|
|
5981
|
-
const pkgPath =
|
|
6933
|
+
const pkgPath = path45.join(packageRoot(), "package.json");
|
|
5982
6934
|
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
5983
6935
|
return pkg.version ?? "0.0.0";
|
|
5984
6936
|
} catch {
|
|
@@ -5988,7 +6940,7 @@ function cliVersion() {
|
|
|
5988
6940
|
var program = new Command();
|
|
5989
6941
|
function validateRuntimeNodeVersion() {
|
|
5990
6942
|
try {
|
|
5991
|
-
const pkgPath =
|
|
6943
|
+
const pkgPath = path45.join(packageRoot(), "package.json");
|
|
5992
6944
|
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
5993
6945
|
const requiredRange = pkg.engines?.node;
|
|
5994
6946
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
@@ -6009,6 +6961,7 @@ program.command("recommend").option("--json").option(
|
|
|
6009
6961
|
).action(runRecommend);
|
|
6010
6962
|
program.command("setup-project").option("--json").action(runSetupProject);
|
|
6011
6963
|
program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
|
|
6964
|
+
program.command("fetch-refs").description("Fetch and cache llms.txt content from catalog item references").option("--all", "Fetch for all catalog items (default)").option("--id <id>", "Fetch for a single catalog item by id").option("--json", "Emit machine-readable JSON summary to stdout").action(runFetchRefs);
|
|
6012
6965
|
program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
|
|
6013
6966
|
"--ids <ids...>",
|
|
6014
6967
|
"Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
|
|
@@ -6037,6 +6990,17 @@ program.command("uninstall").option("--force").action(runUninstallCommand);
|
|
|
6037
6990
|
var guard = program.command("guard");
|
|
6038
6991
|
guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
|
|
6039
6992
|
guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
|
|
6993
|
+
function registerDecisionsCommand(name) {
|
|
6994
|
+
const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
|
|
6995
|
+
cmd.command("check").option("--staged", "Check staged changes only").option("--range <range>", "Git revision range (e.g. origin/main..HEAD)").action((opts) => runDecisions("check", void 0, opts));
|
|
6996
|
+
cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option("--title <title>", "Draft title").action(
|
|
6997
|
+
(opts) => runDecisions("suggest", void 0, opts)
|
|
6998
|
+
);
|
|
6999
|
+
cmd.command("next-number").action(() => runDecisions("next-number"));
|
|
7000
|
+
cmd.command("validate").argument("[path]", "Decision markdown file").action((filePath) => runDecisions("validate", filePath));
|
|
7001
|
+
}
|
|
7002
|
+
registerDecisionsCommand("decisions");
|
|
7003
|
+
registerDecisionsCommand("adr");
|
|
6040
7004
|
var workspace = program.command("workspace");
|
|
6041
7005
|
workspace.command("init").action(() => runWorkspace("init"));
|
|
6042
7006
|
workspace.command("discover").description("Auto-find member repos and write/merge haus.workspace.yaml").option("--write", "Persist haus.workspace.yaml (default previews only)").option("--json", "Output the discovered repos and proposed config as JSON").option("--max-depth <n>", "Max directory depth to traverse (default 3)").option("--client <name>", "Set the workspace client name").action((opts) => runWorkspace("discover", opts));
|