@haus-tech/haus-workflow 0.31.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/cli.js +1329 -478
- package/library/catalog/decisions-triggers.json +26 -0
- package/library/catalog/manifest.json +69 -12
- package/library/global/settings-fragments/hooks.json +7 -0
- package/package.json +6 -6
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);
|
|
@@ -543,7 +651,7 @@ function parseManifest(json) {
|
|
|
543
651
|
|
|
544
652
|
// src/catalog/remote-catalog.ts
|
|
545
653
|
function getCacheDir() {
|
|
546
|
-
return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ??
|
|
654
|
+
return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path3.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
|
|
547
655
|
}
|
|
548
656
|
var cachedCatalogRef;
|
|
549
657
|
var cachedBlobPaths;
|
|
@@ -606,13 +714,13 @@ async function writeTextIfChanged(dest, text) {
|
|
|
606
714
|
await fs2.writeFile(dest, text, "utf8");
|
|
607
715
|
return "updated";
|
|
608
716
|
}
|
|
609
|
-
await fs2.ensureDir(
|
|
717
|
+
await fs2.ensureDir(path3.dirname(dest));
|
|
610
718
|
await fs2.writeFile(dest, text, "utf8");
|
|
611
719
|
return "created";
|
|
612
720
|
}
|
|
613
721
|
var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
|
|
614
722
|
async function readWorkflowTemplate(opts = {}) {
|
|
615
|
-
const dest =
|
|
723
|
+
const dest = path3.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
|
|
616
724
|
const base = await remoteBase();
|
|
617
725
|
const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
|
|
618
726
|
if (text === null) {
|
|
@@ -627,14 +735,14 @@ async function readWorkflowTemplate(opts = {}) {
|
|
|
627
735
|
return text;
|
|
628
736
|
}
|
|
629
737
|
function isSafeCatalogPath(itemPath) {
|
|
630
|
-
if (!itemPath ||
|
|
631
|
-
const normalized =
|
|
738
|
+
if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
|
|
739
|
+
const normalized = path3.normalize(itemPath);
|
|
632
740
|
return !normalized.startsWith("..") && !normalized.includes("/..");
|
|
633
741
|
}
|
|
634
742
|
function isSafeRelativeFilePath(rel) {
|
|
635
743
|
if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
|
|
636
|
-
if (
|
|
637
|
-
const normalized =
|
|
744
|
+
if (path3.isAbsolute(rel)) return false;
|
|
745
|
+
const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
|
|
638
746
|
return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
|
|
639
747
|
}
|
|
640
748
|
function githubApiHeaders() {
|
|
@@ -656,8 +764,8 @@ function sanitizeRelativeFilePaths(files, label) {
|
|
|
656
764
|
}
|
|
657
765
|
function safeJoin(base, itemPath) {
|
|
658
766
|
if (!isSafeCatalogPath(itemPath)) return null;
|
|
659
|
-
const resolved =
|
|
660
|
-
return resolved.startsWith(base +
|
|
767
|
+
const resolved = path3.resolve(base, itemPath);
|
|
768
|
+
return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
|
|
661
769
|
}
|
|
662
770
|
var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
|
|
663
771
|
function isMarkdownPath(rel) {
|
|
@@ -665,13 +773,19 @@ function isMarkdownPath(rel) {
|
|
|
665
773
|
}
|
|
666
774
|
async function listFilesRecursive(dir, base = dir) {
|
|
667
775
|
const out = [];
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
776
|
+
let entries;
|
|
777
|
+
try {
|
|
778
|
+
if (!await fs2.pathExists(dir)) return out;
|
|
779
|
+
entries = await fs2.readdir(dir, { withFileTypes: true });
|
|
780
|
+
} catch {
|
|
781
|
+
return out;
|
|
782
|
+
}
|
|
783
|
+
for (const entry of entries) {
|
|
784
|
+
const full = path3.join(dir, entry.name);
|
|
671
785
|
if (entry.isDirectory()) {
|
|
672
786
|
out.push(...await listFilesRecursive(full, base));
|
|
673
787
|
} else if (entry.isFile()) {
|
|
674
|
-
out.push(
|
|
788
|
+
out.push(path3.relative(base, full).replace(/\\/g, "/"));
|
|
675
789
|
}
|
|
676
790
|
}
|
|
677
791
|
return out.sort();
|
|
@@ -776,7 +890,7 @@ async function directoryMatchesFetched(destDir, fetched) {
|
|
|
776
890
|
if (!relSet.has(rel)) return false;
|
|
777
891
|
}
|
|
778
892
|
for (const file of fetched) {
|
|
779
|
-
const dest =
|
|
893
|
+
const dest = path3.join(destDir, file.rel);
|
|
780
894
|
if (!await fs2.pathExists(dest)) return false;
|
|
781
895
|
if (file.kind === "text") {
|
|
782
896
|
const local = await fs2.readFile(dest, "utf8");
|
|
@@ -794,8 +908,8 @@ async function writeFetchedDirectory(destDir, fetched) {
|
|
|
794
908
|
}
|
|
795
909
|
await fs2.ensureDir(destDir);
|
|
796
910
|
for (const file of fetched) {
|
|
797
|
-
const dest =
|
|
798
|
-
await fs2.ensureDir(
|
|
911
|
+
const dest = path3.join(destDir, file.rel);
|
|
912
|
+
await fs2.ensureDir(path3.dirname(dest));
|
|
799
913
|
if (file.kind === "text") {
|
|
800
914
|
await fs2.writeFile(dest, file.body, "utf8");
|
|
801
915
|
} else {
|
|
@@ -939,7 +1053,7 @@ async function syncOneItem(item, base) {
|
|
|
939
1053
|
return "failed";
|
|
940
1054
|
}
|
|
941
1055
|
const text = await fetchText(`${base}/${item.path}`);
|
|
942
|
-
if (
|
|
1056
|
+
if (text === null) {
|
|
943
1057
|
warn(`Failed to fetch content for ${item.id}`);
|
|
944
1058
|
return "failed";
|
|
945
1059
|
}
|
|
@@ -967,7 +1081,7 @@ async function syncRemoteCatalog() {
|
|
|
967
1081
|
try {
|
|
968
1082
|
await fs2.ensureDir(cacheDir);
|
|
969
1083
|
await fs2.writeFile(
|
|
970
|
-
|
|
1084
|
+
path3.join(cacheDir, "manifest.json"),
|
|
971
1085
|
`${JSON.stringify({ version, items }, null, 2)}
|
|
972
1086
|
`,
|
|
973
1087
|
"utf8"
|
|
@@ -1035,66 +1149,13 @@ async function fetchLatestCatalogTag() {
|
|
|
1035
1149
|
}
|
|
1036
1150
|
async function getCacheManifestAge() {
|
|
1037
1151
|
try {
|
|
1038
|
-
const stat = await fs2.stat(
|
|
1152
|
+
const stat = await fs2.stat(path3.join(getCacheDir(), "manifest.json"));
|
|
1039
1153
|
return Date.now() - stat.mtimeMs;
|
|
1040
1154
|
} catch {
|
|
1041
1155
|
return null;
|
|
1042
1156
|
}
|
|
1043
1157
|
}
|
|
1044
1158
|
|
|
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
1159
|
// src/catalog/load-catalog.ts
|
|
1099
1160
|
async function loadCatalogContext(root) {
|
|
1100
1161
|
const envPath = process.env["HAUS_FIXTURE_CATALOG"];
|
|
@@ -1137,6 +1198,10 @@ function catalogItemContentPath(contentRoot, item) {
|
|
|
1137
1198
|
return path4.join(contentRoot, item.path);
|
|
1138
1199
|
}
|
|
1139
1200
|
|
|
1201
|
+
// src/claude/write-claude-files.ts
|
|
1202
|
+
import path14 from "node:path";
|
|
1203
|
+
import fs14 from "fs-extra";
|
|
1204
|
+
|
|
1140
1205
|
// src/update/hash-installed.ts
|
|
1141
1206
|
import path5 from "node:path";
|
|
1142
1207
|
import fg2 from "fast-glob";
|
|
@@ -1660,8 +1725,8 @@ function buildDenyRules() {
|
|
|
1660
1725
|
for (const command of DENY_COMMANDS) {
|
|
1661
1726
|
rules.push(`Bash(${command}:*)`);
|
|
1662
1727
|
}
|
|
1663
|
-
for (const
|
|
1664
|
-
const pattern = DENY_DIRS.has(
|
|
1728
|
+
for (const path46 of DENY_PATHS) {
|
|
1729
|
+
const pattern = DENY_DIRS.has(path46) ? `${path46}/**` : path46;
|
|
1665
1730
|
for (const tool of FILE_TOOLS) {
|
|
1666
1731
|
rules.push(`${tool}(${pattern})`);
|
|
1667
1732
|
}
|
|
@@ -1684,6 +1749,13 @@ var PROJECT_HOOK_FRAGMENTS = [
|
|
|
1684
1749
|
event: "PreToolUse",
|
|
1685
1750
|
matcher: "Bash",
|
|
1686
1751
|
command: "haus guard bash --from-hook"
|
|
1752
|
+
},
|
|
1753
|
+
{
|
|
1754
|
+
id: "haus.decisions-suggest",
|
|
1755
|
+
gate: "keep",
|
|
1756
|
+
event: "Stop",
|
|
1757
|
+
matcher: "*",
|
|
1758
|
+
command: "haus decisions suggest --from-hook"
|
|
1687
1759
|
}
|
|
1688
1760
|
];
|
|
1689
1761
|
async function readProjectSettings(root) {
|
|
@@ -1777,6 +1849,12 @@ var CANONICAL_HOOKS = {
|
|
|
1777
1849
|
matcher: "Bash",
|
|
1778
1850
|
hooks: [{ type: "command", command: "haus guard bash --from-hook" }]
|
|
1779
1851
|
}
|
|
1852
|
+
],
|
|
1853
|
+
Stop: [
|
|
1854
|
+
{
|
|
1855
|
+
matcher: "*",
|
|
1856
|
+
hooks: [{ type: "command", command: "haus decisions suggest --from-hook" }]
|
|
1857
|
+
}
|
|
1780
1858
|
]
|
|
1781
1859
|
}
|
|
1782
1860
|
};
|
|
@@ -1798,9 +1876,11 @@ function collectHookCommands(settings) {
|
|
|
1798
1876
|
}
|
|
1799
1877
|
function hausHookContractSatisfied(project, canonical) {
|
|
1800
1878
|
const present = new Set(collectHookCommands(project));
|
|
1801
|
-
for (const
|
|
1802
|
-
for (const
|
|
1803
|
-
|
|
1879
|
+
for (const blocks of Object.values(canonical.hooks)) {
|
|
1880
|
+
for (const block of blocks ?? []) {
|
|
1881
|
+
for (const h of block.hooks) {
|
|
1882
|
+
if (!present.has(h.command)) return false;
|
|
1883
|
+
}
|
|
1804
1884
|
}
|
|
1805
1885
|
}
|
|
1806
1886
|
const denySet = new Set(project.permissions?.deny ?? []);
|
|
@@ -1851,9 +1931,62 @@ async function verifyProjectSettingsHooksContract(root) {
|
|
|
1851
1931
|
return { ok: true, message: "settings.json carries required haus hook contract." };
|
|
1852
1932
|
}
|
|
1853
1933
|
|
|
1854
|
-
// src/claude/write-
|
|
1934
|
+
// src/claude/write-decisions-seed.ts
|
|
1855
1935
|
import path9 from "node:path";
|
|
1856
1936
|
import fs8 from "fs-extra";
|
|
1937
|
+
|
|
1938
|
+
// library/catalog/decisions-triggers.json
|
|
1939
|
+
var decisions_triggers_default = {
|
|
1940
|
+
decisionsDir: "docs/decisions",
|
|
1941
|
+
legacyDecisionsDir: "docs/adr",
|
|
1942
|
+
pathGlobs: [
|
|
1943
|
+
"**/package.json",
|
|
1944
|
+
"**/pnpm-lock.yaml",
|
|
1945
|
+
"**/yarn.lock",
|
|
1946
|
+
"**/package-lock.json",
|
|
1947
|
+
"**/migrations/**",
|
|
1948
|
+
"**/prisma/schema.prisma",
|
|
1949
|
+
"**/.github/workflows/**",
|
|
1950
|
+
"**/Dockerfile*",
|
|
1951
|
+
"**/docker-compose*.yml",
|
|
1952
|
+
"**/terraform/**",
|
|
1953
|
+
"**/openapi*.yaml",
|
|
1954
|
+
"**/schema.graphql"
|
|
1955
|
+
],
|
|
1956
|
+
securityPathGlobs: ["**/.env.example", "**/auth/**", "**/middleware/**"],
|
|
1957
|
+
pathRegex: ["(^|/)src/.*/auth/", "(^|/)middleware/", "\\.env\\.example$"],
|
|
1958
|
+
minFilesChanged: 8,
|
|
1959
|
+
minLinesChanged: 200,
|
|
1960
|
+
exemptGlobs: ["docs/**", "tests/**", "**/*.md", ".haus-workflow/**"],
|
|
1961
|
+
requiredSections: ["## Context", "## Decision", "## Consequences"],
|
|
1962
|
+
recommendedSections: ["## Motivation (why)", "## Alternatives considered"],
|
|
1963
|
+
decisionFilePattern: "^\\d{4}-.+\\.md$"
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
// src/decisions/triggers.ts
|
|
1967
|
+
var DECISIONS_TRIGGERS = decisions_triggers_default;
|
|
1968
|
+
var DECISION_FILE_RE = new RegExp(DECISIONS_TRIGGERS.decisionFilePattern);
|
|
1969
|
+
|
|
1970
|
+
// src/claude/write-decisions-seed.ts
|
|
1971
|
+
var SEED_README = `# Architecture Decision Records (ADRs)
|
|
1972
|
+
|
|
1973
|
+
Why we chose X \u2014 short, dated, write-once decision log for humans and agents.
|
|
1974
|
+
|
|
1975
|
+
| ADR | Title | Why (one line) | Status |
|
|
1976
|
+
| --- | ----- | -------------- | ------ |
|
|
1977
|
+
`;
|
|
1978
|
+
async function writeDecisionsSeed(root, dryRun) {
|
|
1979
|
+
const decisionsDir = path9.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
1980
|
+
const readmePath = path9.join(decisionsDir, "README.md");
|
|
1981
|
+
if (await fs8.pathExists(readmePath)) return null;
|
|
1982
|
+
if (!dryRun) await fs8.ensureDir(decisionsDir);
|
|
1983
|
+
await writeManagedText(root, readmePath, SEED_README, dryRun);
|
|
1984
|
+
return readmePath;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// src/claude/write-prettierignore.ts
|
|
1988
|
+
import path10 from "node:path";
|
|
1989
|
+
import fs9 from "fs-extra";
|
|
1857
1990
|
var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
|
|
1858
1991
|
var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
|
|
1859
1992
|
var IGNORED_PATHS = [".haus-workflow/"];
|
|
@@ -1909,20 +2042,21 @@ ${block}
|
|
|
1909
2042
|
`;
|
|
1910
2043
|
}
|
|
1911
2044
|
async function writePrettierIgnore(root, dryRun) {
|
|
1912
|
-
const filePath =
|
|
1913
|
-
const prev = await
|
|
2045
|
+
const filePath = path10.join(root, ".prettierignore");
|
|
2046
|
+
const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
|
|
1914
2047
|
const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
|
|
1915
2048
|
await writeManagedText(root, filePath, next, dryRun);
|
|
1916
2049
|
return filePath;
|
|
1917
2050
|
}
|
|
1918
2051
|
|
|
1919
2052
|
// src/claude/write-root-claude-md.ts
|
|
1920
|
-
import
|
|
1921
|
-
import
|
|
2053
|
+
import path11 from "node:path";
|
|
2054
|
+
import fs10 from "fs-extra";
|
|
1922
2055
|
var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
|
|
1923
2056
|
var BLOCK_END = "<!-- HAUS:END haus-imports -->";
|
|
1924
2057
|
var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
|
|
1925
|
-
@.haus-workflow/workflow-config.md
|
|
2058
|
+
@.haus-workflow/workflow-config.md
|
|
2059
|
+
@docs/decisions/README.md`;
|
|
1926
2060
|
function buildImportBlock() {
|
|
1927
2061
|
return `${BLOCK_BEGIN}
|
|
1928
2062
|
${IMPORT_CONTENT}
|
|
@@ -1983,21 +2117,21 @@ ${block}
|
|
|
1983
2117
|
`;
|
|
1984
2118
|
}
|
|
1985
2119
|
async function writeRootClaudeMd(root, dryRun) {
|
|
1986
|
-
const filePath =
|
|
2120
|
+
const filePath = path11.join(root, "CLAUDE.md");
|
|
1987
2121
|
const block = buildImportBlock();
|
|
1988
|
-
const prev = await
|
|
2122
|
+
const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
|
|
1989
2123
|
const next = injectHausBlock(prev, block);
|
|
1990
2124
|
await writeManagedText(root, filePath, next, dryRun);
|
|
1991
2125
|
return filePath;
|
|
1992
2126
|
}
|
|
1993
2127
|
|
|
1994
2128
|
// src/claude/write-workflow-config.ts
|
|
1995
|
-
import
|
|
1996
|
-
import
|
|
2129
|
+
import path13 from "node:path";
|
|
2130
|
+
import fs12 from "fs-extra";
|
|
1997
2131
|
|
|
1998
2132
|
// src/claude/derive-workflow-config.ts
|
|
1999
|
-
import
|
|
2000
|
-
import
|
|
2133
|
+
import path12 from "node:path";
|
|
2134
|
+
import fs11 from "fs-extra";
|
|
2001
2135
|
function binCmd(pm, bin, args) {
|
|
2002
2136
|
const tail = args ? ` ${args}` : "";
|
|
2003
2137
|
if (pm === "yarn") return `yarn ${bin}${tail}`;
|
|
@@ -2006,7 +2140,7 @@ function binCmd(pm, bin, args) {
|
|
|
2006
2140
|
}
|
|
2007
2141
|
async function deriveWorkflowConfig(root, ctx) {
|
|
2008
2142
|
const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
|
|
2009
|
-
const pkg = await readJson(
|
|
2143
|
+
const pkg = await readJson(path12.join(root, "package.json"));
|
|
2010
2144
|
const scripts = pkg?.scripts ?? {};
|
|
2011
2145
|
const deps = new Set(ctx.dependencies);
|
|
2012
2146
|
const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
|
|
@@ -2016,7 +2150,7 @@ async function deriveWorkflowConfig(root, ctx) {
|
|
|
2016
2150
|
return null;
|
|
2017
2151
|
};
|
|
2018
2152
|
const hasDep = (name) => deps.has(name);
|
|
2019
|
-
const exists = (rel) =>
|
|
2153
|
+
const exists = (rel) => fs11.pathExistsSync(path12.join(root, rel));
|
|
2020
2154
|
const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
|
|
2021
2155
|
const hasCypress = hasDep("cypress");
|
|
2022
2156
|
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 +2218,7 @@ var FALLBACK_CONTEXT = {
|
|
|
2084
2218
|
async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
2085
2219
|
const destPath = hausPath(root, "workflow-config.md");
|
|
2086
2220
|
const printable = displayPath(root, destPath);
|
|
2087
|
-
const exists = await
|
|
2221
|
+
const exists = await fs12.pathExists(destPath);
|
|
2088
2222
|
if (exists && !opts.refill) {
|
|
2089
2223
|
if (dryRun) log(printable + ": exists (project-owned, skipping)");
|
|
2090
2224
|
return null;
|
|
@@ -2092,11 +2226,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
2092
2226
|
const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
|
|
2093
2227
|
...FALLBACK_CONTEXT,
|
|
2094
2228
|
root,
|
|
2095
|
-
repoName:
|
|
2229
|
+
repoName: path13.basename(root)
|
|
2096
2230
|
};
|
|
2097
2231
|
const values = await deriveWorkflowConfig(root, ctx);
|
|
2098
2232
|
if (exists) {
|
|
2099
|
-
const current = await
|
|
2233
|
+
const current = await fs12.readFile(destPath, "utf8");
|
|
2100
2234
|
const refilled = refillContent(current, values);
|
|
2101
2235
|
if (refilled === current) {
|
|
2102
2236
|
if (dryRun) log(printable + ": no blank fields to refill");
|
|
@@ -2118,7 +2252,7 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
|
|
|
2118
2252
|
}
|
|
2119
2253
|
|
|
2120
2254
|
// src/claude/write-workflow.ts
|
|
2121
|
-
import
|
|
2255
|
+
import fs13 from "fs-extra";
|
|
2122
2256
|
var STABLE_ID = "template.workflow";
|
|
2123
2257
|
function makeWorkflowHeader(pkgVersion, contentHash) {
|
|
2124
2258
|
return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
|
|
@@ -2137,8 +2271,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
|
|
|
2137
2271
|
${templateContent}`;
|
|
2138
2272
|
const destPath = hausPath(root, "WORKFLOW.md");
|
|
2139
2273
|
const printable = displayPath(root, destPath);
|
|
2140
|
-
if (await
|
|
2141
|
-
const existing = await
|
|
2274
|
+
if (await fs13.pathExists(destPath)) {
|
|
2275
|
+
const existing = await fs13.readFile(destPath, "utf8");
|
|
2142
2276
|
const firstLine = existing.split("\n")[0] ?? "";
|
|
2143
2277
|
const parsed = parseHausManagedHeader(firstLine);
|
|
2144
2278
|
if (!parsed) {
|
|
@@ -2172,7 +2306,7 @@ ${templateContent}`;
|
|
|
2172
2306
|
}
|
|
2173
2307
|
}
|
|
2174
2308
|
if (dryRun) {
|
|
2175
|
-
const prev = await
|
|
2309
|
+
const prev = await fs13.pathExists(destPath) ? await fs13.readFile(destPath, "utf8") : "";
|
|
2176
2310
|
if (!prev) {
|
|
2177
2311
|
log(createUnifiedDiff(printable, "", next));
|
|
2178
2312
|
} else {
|
|
@@ -2195,6 +2329,8 @@ function targetDirForType(type) {
|
|
|
2195
2329
|
return null;
|
|
2196
2330
|
}
|
|
2197
2331
|
async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
2332
|
+
const say = opts.quiet ? () => {
|
|
2333
|
+
} : log;
|
|
2198
2334
|
const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
|
|
2199
2335
|
recommended: [],
|
|
2200
2336
|
skipped: [],
|
|
@@ -2205,13 +2341,14 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2205
2341
|
estimatedTokenReductionPct: 0
|
|
2206
2342
|
};
|
|
2207
2343
|
const pkgRoot = packageRoot();
|
|
2208
|
-
const hausVersion2 = (await readJson(
|
|
2344
|
+
const hausVersion2 = (await readJson(path14.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
|
|
2209
2345
|
const coreFiles = [
|
|
2210
2346
|
claudePath(root, "settings.json"),
|
|
2211
2347
|
claudePath(root, "rules", "haus.md"),
|
|
2212
2348
|
claudePath(root, "commands", "haus-doctor.md")
|
|
2213
2349
|
];
|
|
2214
2350
|
const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
|
|
2351
|
+
const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
|
|
2215
2352
|
const workflowPath = await writeWorkflow(root, hausVersion2, dryRun, opts.force);
|
|
2216
2353
|
const workflowConfigPath = await writeWorkflowConfig(root, dryRun, {
|
|
2217
2354
|
refill: opts.refillConfig
|
|
@@ -2219,6 +2356,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2219
2356
|
const prettierIgnorePath = await writePrettierIgnore(root, dryRun);
|
|
2220
2357
|
const p6Files = [
|
|
2221
2358
|
rootClaudeMdPath,
|
|
2359
|
+
...decisionsSeedPath ? [decisionsSeedPath] : [],
|
|
2222
2360
|
...workflowPath ? [workflowPath] : [],
|
|
2223
2361
|
...workflowConfigPath ? [workflowConfigPath] : [],
|
|
2224
2362
|
prettierIgnorePath
|
|
@@ -2238,16 +2376,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2238
2376
|
dryRun
|
|
2239
2377
|
);
|
|
2240
2378
|
const legacyReviewPath = claudePath(root, "commands", "haus-review.md");
|
|
2241
|
-
if (await
|
|
2242
|
-
const content2 = await
|
|
2379
|
+
if (await fs14.pathExists(legacyReviewPath)) {
|
|
2380
|
+
const content2 = await fs14.readFile(legacyReviewPath, "utf8");
|
|
2243
2381
|
const stub = 'Run `haus context --task "code review"` then review diff.';
|
|
2244
2382
|
if (content2 === stub || content2 === `${stub}
|
|
2245
2383
|
` || content2 === `${stub}\r
|
|
2246
2384
|
`) {
|
|
2247
2385
|
if (dryRun) {
|
|
2248
|
-
|
|
2386
|
+
say(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
|
|
2249
2387
|
} else {
|
|
2250
|
-
await
|
|
2388
|
+
await fs14.remove(legacyReviewPath);
|
|
2251
2389
|
}
|
|
2252
2390
|
}
|
|
2253
2391
|
}
|
|
@@ -2275,16 +2413,16 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2275
2413
|
dryRun
|
|
2276
2414
|
);
|
|
2277
2415
|
const legacySecurityPath = claudePath(root, "rules", "security.md");
|
|
2278
|
-
if (await
|
|
2279
|
-
const content2 = await
|
|
2416
|
+
if (await fs14.pathExists(legacySecurityPath)) {
|
|
2417
|
+
const content2 = await fs14.readFile(legacySecurityPath, "utf8");
|
|
2280
2418
|
const stub = "- Never read secrets.\n- Block dangerous shell commands.";
|
|
2281
2419
|
if (content2 === stub || content2 === `${stub}
|
|
2282
2420
|
` || content2 === `${stub}\r
|
|
2283
2421
|
`) {
|
|
2284
2422
|
if (dryRun) {
|
|
2285
|
-
|
|
2423
|
+
say(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
|
|
2286
2424
|
} else {
|
|
2287
|
-
await
|
|
2425
|
+
await fs14.remove(legacySecurityPath);
|
|
2288
2426
|
}
|
|
2289
2427
|
}
|
|
2290
2428
|
}
|
|
@@ -2299,11 +2437,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2299
2437
|
];
|
|
2300
2438
|
for (const rel of LEGACY_PRUNED_ARTIFACTS) {
|
|
2301
2439
|
const legacyPath = hausPath(root, rel);
|
|
2302
|
-
if (await
|
|
2440
|
+
if (await fs14.pathExists(legacyPath)) {
|
|
2303
2441
|
if (dryRun) {
|
|
2304
|
-
|
|
2442
|
+
say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
|
|
2305
2443
|
} else {
|
|
2306
|
-
await
|
|
2444
|
+
await fs14.remove(legacyPath);
|
|
2307
2445
|
}
|
|
2308
2446
|
}
|
|
2309
2447
|
}
|
|
@@ -2345,11 +2483,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2345
2483
|
);
|
|
2346
2484
|
continue;
|
|
2347
2485
|
}
|
|
2348
|
-
const destination = claudePath(root, target,
|
|
2349
|
-
if (await
|
|
2486
|
+
const destination = claudePath(root, target, path14.basename(sourcePath));
|
|
2487
|
+
if (await fs14.pathExists(sourcePath)) {
|
|
2350
2488
|
if (dryRun) {
|
|
2351
|
-
const exists = await
|
|
2352
|
-
|
|
2489
|
+
const exists = await fs14.pathExists(destination);
|
|
2490
|
+
say(
|
|
2353
2491
|
`${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
|
|
2354
2492
|
);
|
|
2355
2493
|
} else if (item.type === "skill") {
|
|
@@ -2358,17 +2496,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2358
2496
|
dryRun: false
|
|
2359
2497
|
});
|
|
2360
2498
|
} else {
|
|
2361
|
-
await
|
|
2362
|
-
await
|
|
2499
|
+
await fs14.ensureDir(path14.dirname(destination));
|
|
2500
|
+
await fs14.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
2363
2501
|
}
|
|
2364
2502
|
files.push(destination);
|
|
2365
|
-
const relPaths = [
|
|
2503
|
+
const relPaths = [path14.relative(root, destination)];
|
|
2366
2504
|
if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
|
|
2367
2505
|
const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
|
|
2368
2506
|
if (sharedRel) {
|
|
2369
2507
|
superpowersSharedInstalled = true;
|
|
2370
2508
|
relPaths.push(sharedRel);
|
|
2371
|
-
files.push(
|
|
2509
|
+
files.push(path14.join(root, sharedRel));
|
|
2372
2510
|
}
|
|
2373
2511
|
}
|
|
2374
2512
|
const current = installedPathsByItem.get(item.id) ?? [];
|
|
@@ -2385,7 +2523,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2385
2523
|
`${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
|
|
2386
2524
|
);
|
|
2387
2525
|
}
|
|
2388
|
-
await cleanupStaleCatalogItems(root, manifestById, dryRun);
|
|
2526
|
+
await cleanupStaleCatalogItems(root, manifestById, dryRun, opts.quiet);
|
|
2389
2527
|
if (dryRun) return [...new Set(files)];
|
|
2390
2528
|
const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
|
|
2391
2529
|
const prevLock = await readJson(hausPath(root, "haus.lock.json"));
|
|
@@ -2422,7 +2560,9 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
|
|
|
2422
2560
|
await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
|
|
2423
2561
|
return [...new Set(files)];
|
|
2424
2562
|
}
|
|
2425
|
-
async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
|
|
2563
|
+
async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
|
|
2564
|
+
const say = quiet ? () => {
|
|
2565
|
+
} : log;
|
|
2426
2566
|
const prevLock = await readJson(hausPath(root, "haus.lock.json"));
|
|
2427
2567
|
if (!prevLock?.length) return;
|
|
2428
2568
|
for (const entry of prevLock) {
|
|
@@ -2435,7 +2575,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
|
|
|
2435
2575
|
if (relPaths.length === 0) continue;
|
|
2436
2576
|
const existing = [];
|
|
2437
2577
|
for (const rel of relPaths) {
|
|
2438
|
-
if (await
|
|
2578
|
+
if (await fs14.pathExists(path14.join(root, rel))) existing.push(rel);
|
|
2439
2579
|
}
|
|
2440
2580
|
if (existing.length === 0) continue;
|
|
2441
2581
|
const pruneReason = deprecated ? "deprecated" : "stale";
|
|
@@ -2453,18 +2593,115 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun) {
|
|
|
2453
2593
|
continue;
|
|
2454
2594
|
}
|
|
2455
2595
|
for (const rel of existing) {
|
|
2456
|
-
const abs =
|
|
2596
|
+
const abs = path14.join(root, rel);
|
|
2457
2597
|
if (dryRun) {
|
|
2458
|
-
|
|
2598
|
+
say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
2459
2599
|
continue;
|
|
2460
2600
|
}
|
|
2461
|
-
await
|
|
2462
|
-
await pruneEmptyDir(
|
|
2463
|
-
|
|
2601
|
+
await fs14.remove(abs);
|
|
2602
|
+
await pruneEmptyDir(path14.dirname(abs));
|
|
2603
|
+
say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
|
|
2464
2604
|
}
|
|
2465
2605
|
}
|
|
2466
2606
|
}
|
|
2467
2607
|
|
|
2608
|
+
// src/refs/fetch-refs.ts
|
|
2609
|
+
import path16 from "node:path";
|
|
2610
|
+
import fs16 from "fs-extra";
|
|
2611
|
+
|
|
2612
|
+
// src/refs/cache-meta.ts
|
|
2613
|
+
import path15 from "node:path";
|
|
2614
|
+
import fs15 from "fs-extra";
|
|
2615
|
+
var META_FILENAME = "cache-meta.json";
|
|
2616
|
+
async function readCacheMeta(cacheDir) {
|
|
2617
|
+
const metaPath = path15.join(cacheDir, META_FILENAME);
|
|
2618
|
+
try {
|
|
2619
|
+
const raw = await fs15.readFile(metaPath, "utf8");
|
|
2620
|
+
return JSON.parse(raw);
|
|
2621
|
+
} catch {
|
|
2622
|
+
return {};
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
async function writeCacheMeta(cacheDir, meta) {
|
|
2626
|
+
await fs15.ensureDir(cacheDir);
|
|
2627
|
+
const metaPath = path15.join(cacheDir, META_FILENAME);
|
|
2628
|
+
await fs15.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
|
|
2629
|
+
}
|
|
2630
|
+
|
|
2631
|
+
// src/refs/fetch-refs.ts
|
|
2632
|
+
function urlToSlug(url) {
|
|
2633
|
+
const u = new URL(url);
|
|
2634
|
+
return (u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
2635
|
+
}
|
|
2636
|
+
function isLlmsTxtUrl(ref) {
|
|
2637
|
+
try {
|
|
2638
|
+
const u = new URL(ref);
|
|
2639
|
+
return (u.protocol === "https:" || u.protocol === "http:") && u.pathname.endsWith("/llms.txt");
|
|
2640
|
+
} catch {
|
|
2641
|
+
return false;
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
async function fetchSingleRef(url, cacheDir, meta) {
|
|
2645
|
+
const existing = meta[url];
|
|
2646
|
+
const headers = {};
|
|
2647
|
+
const safeFile = existing?.file && existing.file === path16.basename(existing.file);
|
|
2648
|
+
const cachedFileExists = safeFile && await fs16.pathExists(path16.join(cacheDir, existing.file));
|
|
2649
|
+
if (cachedFileExists) {
|
|
2650
|
+
if (existing?.etag) headers["If-None-Match"] = existing.etag;
|
|
2651
|
+
if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
|
|
2652
|
+
}
|
|
2653
|
+
try {
|
|
2654
|
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(1e4) });
|
|
2655
|
+
if (res.status === 304) return { result: "unchanged" };
|
|
2656
|
+
if (!res.ok) return { result: "failed" };
|
|
2657
|
+
const text = await res.text();
|
|
2658
|
+
const file = `${urlToSlug(url)}.md`;
|
|
2659
|
+
await fs16.ensureDir(cacheDir);
|
|
2660
|
+
await fs16.writeFile(path16.join(cacheDir, file), text, "utf8");
|
|
2661
|
+
meta[url] = {
|
|
2662
|
+
url,
|
|
2663
|
+
etag: res.headers.get("etag") ?? void 0,
|
|
2664
|
+
lastModified: res.headers.get("last-modified") ?? void 0,
|
|
2665
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2666
|
+
file
|
|
2667
|
+
};
|
|
2668
|
+
return { result: "fetched", file };
|
|
2669
|
+
} catch {
|
|
2670
|
+
return { result: "failed" };
|
|
2671
|
+
}
|
|
2672
|
+
}
|
|
2673
|
+
async function fetchRefsForItems(items, cacheDir) {
|
|
2674
|
+
const urls = [...new Set(items.flatMap((item) => (item.references ?? []).filter(isLlmsTxtUrl)))];
|
|
2675
|
+
const summary = {
|
|
2676
|
+
fetched: 0,
|
|
2677
|
+
unchanged: 0,
|
|
2678
|
+
failed: 0,
|
|
2679
|
+
failedUrls: [],
|
|
2680
|
+
cachedFiles: {}
|
|
2681
|
+
};
|
|
2682
|
+
if (urls.length === 0) return summary;
|
|
2683
|
+
const meta = await readCacheMeta(cacheDir);
|
|
2684
|
+
await Promise.all(
|
|
2685
|
+
urls.map(async (url) => {
|
|
2686
|
+
const outcome = await fetchSingleRef(url, cacheDir, meta);
|
|
2687
|
+
if (outcome.result === "fetched") {
|
|
2688
|
+
summary.fetched++;
|
|
2689
|
+
summary.cachedFiles[url] = path16.join(cacheDir, outcome.file);
|
|
2690
|
+
} else if (outcome.result === "unchanged") {
|
|
2691
|
+
summary.unchanged++;
|
|
2692
|
+
if (meta[url]?.file) summary.cachedFiles[url] = path16.join(cacheDir, meta[url].file);
|
|
2693
|
+
} else {
|
|
2694
|
+
summary.failed++;
|
|
2695
|
+
summary.failedUrls.push(url);
|
|
2696
|
+
}
|
|
2697
|
+
})
|
|
2698
|
+
);
|
|
2699
|
+
if (summary.fetched > 0) {
|
|
2700
|
+
await writeCacheMeta(cacheDir, meta);
|
|
2701
|
+
}
|
|
2702
|
+
return summary;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2468
2705
|
// src/utils/args.ts
|
|
2469
2706
|
function parseIdList(value) {
|
|
2470
2707
|
if (!value) return [];
|
|
@@ -2474,7 +2711,7 @@ function parseIdList(value) {
|
|
|
2474
2711
|
|
|
2475
2712
|
// src/commands/apply.ts
|
|
2476
2713
|
async function cacheHasItems() {
|
|
2477
|
-
const data = await readJson(
|
|
2714
|
+
const data = await readJson(path17.join(getCacheDir(), "manifest.json"));
|
|
2478
2715
|
return Array.isArray(data?.items) && data.items.length > 0;
|
|
2479
2716
|
}
|
|
2480
2717
|
function installableRecommendedItems(recommended) {
|
|
@@ -2566,6 +2803,23 @@ async function runApply(options) {
|
|
|
2566
2803
|
refillConfig: options.refillConfig,
|
|
2567
2804
|
force: options.force
|
|
2568
2805
|
});
|
|
2806
|
+
if (!isDryRun) {
|
|
2807
|
+
try {
|
|
2808
|
+
const cacheDir = hausPath(root, "llms-cache");
|
|
2809
|
+
const allItems = await loadCatalog(root);
|
|
2810
|
+
const summary = await fetchRefsForItems(allItems, cacheDir);
|
|
2811
|
+
if (summary.fetched > 0)
|
|
2812
|
+
log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
|
|
2813
|
+
if (summary.failed > 0)
|
|
2814
|
+
warn(
|
|
2815
|
+
`Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
|
|
2816
|
+
);
|
|
2817
|
+
} catch (err) {
|
|
2818
|
+
warn(
|
|
2819
|
+
`Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
|
|
2820
|
+
);
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2569
2823
|
if (isDryRun) {
|
|
2570
2824
|
log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
|
|
2571
2825
|
} else {
|
|
@@ -2579,7 +2833,7 @@ function auditForbiddenTags(items) {
|
|
|
2579
2833
|
const failures = [];
|
|
2580
2834
|
const forbidden = new Set(FORBIDDEN_TAGS.map((w) => w.toLowerCase()));
|
|
2581
2835
|
for (const item of items) {
|
|
2582
|
-
for (const tag of item.tags) {
|
|
2836
|
+
for (const tag of Array.isArray(item.tags) ? item.tags : []) {
|
|
2583
2837
|
if (forbidden.has(tag.toLowerCase())) failures.push(`${item.id} has unsupported tag ${tag}`);
|
|
2584
2838
|
}
|
|
2585
2839
|
for (const token of item.id.toLowerCase().split(/[^a-z0-9+]+/)) {
|
|
@@ -2600,84 +2854,509 @@ async function runCatalogAudit() {
|
|
|
2600
2854
|
}
|
|
2601
2855
|
|
|
2602
2856
|
// src/commands/clone.ts
|
|
2857
|
+
init_exec();
|
|
2603
2858
|
import { existsSync as existsSync2 } from "node:fs";
|
|
2604
|
-
import
|
|
2859
|
+
import path18 from "node:path";
|
|
2860
|
+
var GIT_LOCATION_VARS = [
|
|
2861
|
+
"GIT_DIR",
|
|
2862
|
+
"GIT_WORK_TREE",
|
|
2863
|
+
"GIT_INDEX_FILE",
|
|
2864
|
+
"GIT_COMMON_DIR",
|
|
2865
|
+
"GIT_OBJECT_DIRECTORY",
|
|
2866
|
+
"GIT_NAMESPACE",
|
|
2867
|
+
"GIT_PREFIX"
|
|
2868
|
+
];
|
|
2869
|
+
function cloneEnv() {
|
|
2870
|
+
const env = { ...process.env };
|
|
2871
|
+
for (const key of GIT_LOCATION_VARS) delete env[key];
|
|
2872
|
+
return env;
|
|
2873
|
+
}
|
|
2874
|
+
function repoNameFromUrl(url) {
|
|
2875
|
+
const trimmed = url.trim().replace(/\.git$/, "").replace(/\/+$/, "");
|
|
2876
|
+
const tail = trimmed.split(/[/:]/).pop() ?? "";
|
|
2877
|
+
return tail || "repo";
|
|
2878
|
+
}
|
|
2879
|
+
async function runClone(url, opts = {}) {
|
|
2880
|
+
if (!url || !url.trim()) {
|
|
2881
|
+
error("A git URL is required: `haus clone <url> [dir]`.");
|
|
2882
|
+
process.exitCode = 1;
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
const target = path18.resolve(opts.dir?.trim() || repoNameFromUrl(url));
|
|
2886
|
+
if (existsSync2(target)) {
|
|
2887
|
+
log(`\u2022 ${path18.basename(target)} already present at ${target} \u2014 skipped`);
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
if (opts.dryRun) {
|
|
2891
|
+
log(`would clone ${url} \u2192 ${target}`);
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
const res = await runGit(["clone", "--", url, target], { env: cloneEnv(), extendEnv: false });
|
|
2895
|
+
if (res.exitCode !== 0) {
|
|
2896
|
+
error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
|
|
2897
|
+
process.exitCode = 1;
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
log(`\u2713 cloned ${url} \u2192 ${target}`);
|
|
2901
|
+
}
|
|
2605
2902
|
|
|
2606
|
-
// src/
|
|
2607
|
-
import
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2903
|
+
// src/commands/decisions.ts
|
|
2904
|
+
import path22 from "node:path";
|
|
2905
|
+
|
|
2906
|
+
// src/decisions/check.ts
|
|
2907
|
+
import path21 from "node:path";
|
|
2908
|
+
import fs19 from "fs-extra";
|
|
2909
|
+
|
|
2910
|
+
// src/decisions/diff.ts
|
|
2911
|
+
init_exec();
|
|
2912
|
+
async function collectDiffStats(root, opts) {
|
|
2913
|
+
const gitArgs = opts.staged ? ["diff", "--cached", "--name-only"] : ["diff", "--name-only", opts.range ?? "HEAD"];
|
|
2914
|
+
const names = await runGit(gitArgs, { cwd: root });
|
|
2915
|
+
const files = names.stdout.split("\n").map((f) => f.trim()).filter(Boolean);
|
|
2916
|
+
const numstatArgs = opts.staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat", opts.range ?? "HEAD"];
|
|
2917
|
+
const numstat = await runGit(numstatArgs, { cwd: root });
|
|
2918
|
+
let linesAdded = 0;
|
|
2919
|
+
let linesRemoved = 0;
|
|
2920
|
+
for (const line2 of numstat.stdout.split("\n")) {
|
|
2921
|
+
const parts = line2.trim().split(/\s+/);
|
|
2922
|
+
if (parts.length < 3) continue;
|
|
2923
|
+
const added = parts[0] === "-" ? 0 : Number(parts[0]);
|
|
2924
|
+
const removed = parts[1] === "-" ? 0 : Number(parts[1]);
|
|
2925
|
+
if (!Number.isNaN(added)) linesAdded += added;
|
|
2926
|
+
if (!Number.isNaN(removed)) linesRemoved += removed;
|
|
2927
|
+
}
|
|
2928
|
+
return { files, linesAdded, linesRemoved };
|
|
2929
|
+
}
|
|
2930
|
+
|
|
2931
|
+
// src/decisions/match.ts
|
|
2932
|
+
function segmentToRegex(segment) {
|
|
2933
|
+
if (segment === "**") return ".*";
|
|
2934
|
+
return segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
|
2935
|
+
}
|
|
2936
|
+
function globToRegExp(pattern) {
|
|
2937
|
+
const pat = pattern.replace(/\\/g, "/");
|
|
2938
|
+
if (pat.startsWith("**/") && pat.endsWith("/**")) {
|
|
2939
|
+
const middle = pat.slice(3, -3);
|
|
2940
|
+
const midRe = middle.split("/").map(segmentToRegex).join("/");
|
|
2941
|
+
return new RegExp(`(^|/)${midRe}(/|$)`);
|
|
2942
|
+
}
|
|
2943
|
+
if (pat.startsWith("**/")) {
|
|
2944
|
+
const rest = pat.slice(3).split("/").map(segmentToRegex).join("/");
|
|
2945
|
+
return new RegExp(`^(?:.*/)?${rest}$`);
|
|
2946
|
+
}
|
|
2947
|
+
const reSource = pat.split("/").map(segmentToRegex).join("/");
|
|
2948
|
+
return new RegExp(`^${reSource}$`);
|
|
2949
|
+
}
|
|
2950
|
+
function matchesPathGlob(filePath, pattern) {
|
|
2951
|
+
const norm = filePath.replace(/\\/g, "/");
|
|
2952
|
+
return globToRegExp(pattern).test(norm);
|
|
2953
|
+
}
|
|
2954
|
+
function matchesAnyGlob(filePath, patterns) {
|
|
2955
|
+
return patterns.some((p) => matchesPathGlob(filePath, p));
|
|
2956
|
+
}
|
|
2957
|
+
function matchesAnyRegex(filePath, patterns) {
|
|
2958
|
+
const norm = filePath.replace(/\\/g, "/");
|
|
2959
|
+
return patterns.some((p) => new RegExp(p).test(norm));
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// src/decisions/paths.ts
|
|
2963
|
+
import path19 from "node:path";
|
|
2964
|
+
import fs17 from "fs-extra";
|
|
2965
|
+
async function resolveDecisionsDir(root) {
|
|
2966
|
+
const primary = path19.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
2967
|
+
if (await fs17.pathExists(primary)) return primary;
|
|
2968
|
+
const legacy = path19.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
2969
|
+
if (await fs17.pathExists(legacy)) return legacy;
|
|
2970
|
+
return primary;
|
|
2971
|
+
}
|
|
2972
|
+
function relativeDecisionPath(decisionsDir, root) {
|
|
2973
|
+
return path19.relative(root, decisionsDir).replace(/\\/g, "/");
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
// src/decisions/validate.ts
|
|
2977
|
+
import path20 from "node:path";
|
|
2978
|
+
import fs18 from "fs-extra";
|
|
2979
|
+
function validateDecisionContent(content2) {
|
|
2980
|
+
const errors = [];
|
|
2981
|
+
for (const section of DECISIONS_TRIGGERS.requiredSections) {
|
|
2982
|
+
if (!content2.includes(section)) {
|
|
2983
|
+
errors.push(`missing required section: ${section}`);
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
if (!/^#\s+ADR-\d{4}:/m.test(content2)) {
|
|
2987
|
+
errors.push('heading must match "# ADR-NNNN: Title"');
|
|
2988
|
+
}
|
|
2989
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
2990
|
+
}
|
|
2991
|
+
function indexReferencesNumber(readme, number) {
|
|
2992
|
+
const padded = number.padStart(4, "0");
|
|
2993
|
+
return readme.includes(`[${padded}]`) || readme.includes(`(${padded}-`) || readme.includes(`ADR-${padded}`);
|
|
2994
|
+
}
|
|
2995
|
+
async function validateDecisionFile(filePath) {
|
|
2996
|
+
const content2 = await readText(filePath) ?? "";
|
|
2997
|
+
return validateDecisionContent(content2);
|
|
2998
|
+
}
|
|
2999
|
+
function isDecisionFileName(name) {
|
|
3000
|
+
return DECISION_FILE_RE.test(name);
|
|
3001
|
+
}
|
|
3002
|
+
function decisionNumberFromFile(name) {
|
|
3003
|
+
const match = /^(\d{4})-/.exec(name);
|
|
3004
|
+
return match?.[1] ?? null;
|
|
3005
|
+
}
|
|
3006
|
+
async function validateIndexSync(decisionsDir, decisionFiles) {
|
|
3007
|
+
const readmePath = path20.join(decisionsDir, "README.md");
|
|
3008
|
+
if (!await fs18.pathExists(readmePath)) {
|
|
3009
|
+
return ["docs/decisions/README.md is missing"];
|
|
3010
|
+
}
|
|
3011
|
+
const readme = await readText(readmePath) ?? "";
|
|
3012
|
+
const errors = [];
|
|
3013
|
+
for (const file of decisionFiles) {
|
|
3014
|
+
const num = decisionNumberFromFile(path20.basename(file));
|
|
3015
|
+
if (num && !indexReferencesNumber(readme, num)) {
|
|
3016
|
+
errors.push(`README.md missing index row for ADR-${num}`);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
return errors;
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
// src/decisions/check.ts
|
|
3023
|
+
function isExempt(file) {
|
|
3024
|
+
return matchesAnyGlob(file, DECISIONS_TRIGGERS.exemptGlobs);
|
|
3025
|
+
}
|
|
3026
|
+
function countTopLevelDirs(files) {
|
|
3027
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
3028
|
+
for (const f of files) {
|
|
3029
|
+
const parts = f.split("/");
|
|
3030
|
+
if (parts.length > 1) dirs.add(parts[0]);
|
|
3031
|
+
}
|
|
3032
|
+
return dirs.size;
|
|
3033
|
+
}
|
|
3034
|
+
function isDecisionTriggered(stats) {
|
|
3035
|
+
const relevant = stats.files.filter((f) => !isExempt(f));
|
|
3036
|
+
if (relevant.length === 0) return false;
|
|
3037
|
+
for (const file of relevant) {
|
|
3038
|
+
if (matchesAnyGlob(file, DECISIONS_TRIGGERS.pathGlobs)) return true;
|
|
3039
|
+
if (matchesAnyRegex(file, DECISIONS_TRIGGERS.pathRegex)) return true;
|
|
3040
|
+
}
|
|
3041
|
+
const totalLines = stats.linesAdded + stats.linesRemoved;
|
|
3042
|
+
if (relevant.length >= DECISIONS_TRIGGERS.minFilesChanged && countTopLevelDirs(relevant) >= 2) {
|
|
3043
|
+
return true;
|
|
3044
|
+
}
|
|
3045
|
+
if (totalLines >= DECISIONS_TRIGGERS.minLinesChanged && relevant.some((f) => !f.startsWith("docs/"))) {
|
|
3046
|
+
return true;
|
|
3047
|
+
}
|
|
3048
|
+
return false;
|
|
3049
|
+
}
|
|
3050
|
+
function isSecurityTriggered(stats) {
|
|
3051
|
+
const relevant = stats.files.filter((f) => !isExempt(f));
|
|
3052
|
+
const security = DECISIONS_TRIGGERS.securityPathGlobs ?? [];
|
|
3053
|
+
return relevant.some(
|
|
3054
|
+
(f) => matchesAnyGlob(f, security) || matchesAnyRegex(f, DECISIONS_TRIGGERS.pathRegex)
|
|
3055
|
+
);
|
|
3056
|
+
}
|
|
3057
|
+
async function readGateMode(root) {
|
|
3058
|
+
const cfg = await readJson(hausPath(root, "adr-gate.json"));
|
|
3059
|
+
return cfg?.mode === "off" ? "off" : "on";
|
|
3060
|
+
}
|
|
3061
|
+
function hasSkipToken(text) {
|
|
3062
|
+
return text.includes("[adr-skip]");
|
|
3063
|
+
}
|
|
3064
|
+
async function checkSatisfaction(input2) {
|
|
3065
|
+
const errors = [];
|
|
3066
|
+
const decisionsPrefix = `${input2.decisionsRel}/`;
|
|
3067
|
+
const newDecisionFiles = input2.changedFiles.filter(
|
|
3068
|
+
(f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path21.basename(f))
|
|
3069
|
+
);
|
|
3070
|
+
if (newDecisionFiles.length === 0) {
|
|
3071
|
+
errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
|
|
3072
|
+
return errors;
|
|
3073
|
+
}
|
|
3074
|
+
const decisionsDir = path21.join(input2.root, input2.decisionsRel);
|
|
3075
|
+
for (const rel of newDecisionFiles) {
|
|
3076
|
+
const abs = path21.join(input2.root, rel);
|
|
3077
|
+
const content2 = await readText(abs) ?? "";
|
|
3078
|
+
const valid = validateDecisionContent(content2);
|
|
3079
|
+
if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
|
|
3080
|
+
}
|
|
3081
|
+
const indexErrors = await validateIndexSync(
|
|
3082
|
+
decisionsDir,
|
|
3083
|
+
newDecisionFiles.map((f) => path21.basename(f))
|
|
3084
|
+
);
|
|
3085
|
+
errors.push(...indexErrors);
|
|
3086
|
+
return errors;
|
|
3087
|
+
}
|
|
3088
|
+
async function runDecisionsCheck(root, opts = {}) {
|
|
3089
|
+
const reasons = [];
|
|
3090
|
+
if (await readGateMode(root) === "off") {
|
|
3091
|
+
return {
|
|
3092
|
+
triggered: false,
|
|
3093
|
+
satisfied: true,
|
|
3094
|
+
reasons: ["gate disabled via .haus-workflow/adr-gate.json"],
|
|
3095
|
+
securityTriggered: false
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
const stats = await collectDiffStats(root, opts);
|
|
3099
|
+
const triggered = isDecisionTriggered(stats);
|
|
3100
|
+
const securityTriggered = isSecurityTriggered(stats);
|
|
3101
|
+
if (!triggered) {
|
|
2615
3102
|
return {
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
3103
|
+
triggered: false,
|
|
3104
|
+
satisfied: true,
|
|
3105
|
+
reasons: ["no decision-worthy changes"],
|
|
3106
|
+
securityTriggered
|
|
3107
|
+
};
|
|
3108
|
+
}
|
|
3109
|
+
const skipText = `${opts.prBody ?? ""}
|
|
3110
|
+
${opts.commitMessages ?? ""}`;
|
|
3111
|
+
if (hasSkipToken(skipText) && !securityTriggered) {
|
|
3112
|
+
reasons.push("[adr-skip] present \u2014 gate waived (non-security change)");
|
|
3113
|
+
return { triggered: true, satisfied: true, reasons, securityTriggered };
|
|
3114
|
+
}
|
|
3115
|
+
if (hasSkipToken(skipText) && securityTriggered) {
|
|
3116
|
+
reasons.push("[adr-skip] ignored for security/auth path changes");
|
|
3117
|
+
}
|
|
3118
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3119
|
+
const decisionsRel = relativeDecisionPath(decisionsDir, root);
|
|
3120
|
+
const legacyRel = DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr";
|
|
3121
|
+
let satisfactionErrors = await checkSatisfaction({
|
|
3122
|
+
root,
|
|
3123
|
+
decisionsRel,
|
|
3124
|
+
changedFiles: stats.files
|
|
3125
|
+
});
|
|
3126
|
+
if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
|
|
3127
|
+
const legacyExists = await fs19.pathExists(path21.join(root, legacyRel));
|
|
3128
|
+
if (legacyExists && !await fs19.pathExists(decisionsDir)) {
|
|
3129
|
+
satisfactionErrors = await checkSatisfaction({
|
|
3130
|
+
root,
|
|
3131
|
+
decisionsRel: legacyRel,
|
|
3132
|
+
changedFiles: stats.files
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
if (satisfactionErrors.length === 0) {
|
|
3137
|
+
return {
|
|
3138
|
+
triggered: true,
|
|
3139
|
+
satisfied: true,
|
|
3140
|
+
reasons: ["decision record present"],
|
|
3141
|
+
securityTriggered
|
|
2621
3142
|
};
|
|
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
3143
|
}
|
|
3144
|
+
reasons.push(...satisfactionErrors);
|
|
3145
|
+
return { triggered: true, satisfied: false, reasons, securityTriggered };
|
|
2626
3146
|
}
|
|
2627
|
-
async function
|
|
2628
|
-
|
|
3147
|
+
async function listDecisionNumbers(decisionsDir) {
|
|
3148
|
+
if (!await fs19.pathExists(decisionsDir)) return [];
|
|
3149
|
+
const entries = await fs19.readdir(decisionsDir);
|
|
3150
|
+
return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
|
|
2629
3151
|
}
|
|
2630
3152
|
|
|
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;
|
|
3153
|
+
// src/decisions/next-number.ts
|
|
3154
|
+
async function nextDecisionNumber(root) {
|
|
3155
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3156
|
+
const numbers = await listDecisionNumbers(decisionsDir);
|
|
3157
|
+
const max = numbers.length === 0 ? 0 : Math.max(...numbers);
|
|
3158
|
+
return String(max + 1).padStart(4, "0");
|
|
2645
3159
|
}
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
3160
|
+
|
|
3161
|
+
// src/decisions/suggest.ts
|
|
3162
|
+
function draftTemplate(number, title) {
|
|
3163
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3164
|
+
return `# ADR-${number}: ${title}
|
|
3165
|
+
|
|
3166
|
+
- **Status:** Proposed
|
|
3167
|
+
- **Date:** ${today}
|
|
3168
|
+
- **Decided by:** _(pending approval)_ (draft by haus decisions suggest)
|
|
3169
|
+
- **Affects:** _(paths/components)_
|
|
3170
|
+
- **Related:** _(PR/issue link)_
|
|
3171
|
+
|
|
3172
|
+
## Context
|
|
3173
|
+
|
|
3174
|
+
_(What problem or situation triggered this decision?)_
|
|
3175
|
+
|
|
3176
|
+
## Decision
|
|
3177
|
+
|
|
3178
|
+
_(One clear sentence: what we chose.)_
|
|
3179
|
+
|
|
3180
|
+
## Motivation (why)
|
|
3181
|
+
|
|
3182
|
+
_(Why this over alternatives \u2014 the line agents read for "why".)_
|
|
3183
|
+
|
|
3184
|
+
## Alternatives considered
|
|
3185
|
+
|
|
3186
|
+
- _(Alternative)_ \u2014 rejected because _(reason)_.
|
|
3187
|
+
|
|
3188
|
+
## Consequences
|
|
3189
|
+
|
|
3190
|
+
- _(Positive and negative follow-ons.)_
|
|
3191
|
+
`;
|
|
2650
3192
|
}
|
|
2651
|
-
async function
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
3193
|
+
async function runDecisionsSuggest(root, opts = {}) {
|
|
3194
|
+
const stats = await collectDiffStats(root, { staged: true });
|
|
3195
|
+
const stagedCheck = await runDecisionsCheck(root, { staged: true });
|
|
3196
|
+
const unstagedStats = await collectDiffStats(root, {});
|
|
3197
|
+
const triggered = isDecisionTriggered(stats) || isDecisionTriggered(unstagedStats);
|
|
3198
|
+
if (!triggered) return;
|
|
3199
|
+
if (stagedCheck.triggered && stagedCheck.satisfied) return;
|
|
3200
|
+
const number = await nextDecisionNumber(root);
|
|
3201
|
+
const title = opts.title ?? "Decision from current changes";
|
|
3202
|
+
const draft = draftTemplate(number, title);
|
|
3203
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3204
|
+
const rel = relativeDecisionPath(decisionsDir, root);
|
|
3205
|
+
const file = `${rel}/${number}-${title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "decision"}.md`;
|
|
3206
|
+
const payload = {
|
|
3207
|
+
action: "propose_decision",
|
|
3208
|
+
message: "Decision-worthy change detected. Draft an ADR from this template and ask the user to approve before writing.",
|
|
3209
|
+
file,
|
|
3210
|
+
draft,
|
|
3211
|
+
indexHint: `Add a row to ${rel}/README.md for ADR-${number}.`
|
|
3212
|
+
};
|
|
3213
|
+
if (opts.fromHook) {
|
|
3214
|
+
process.stdout.write(`${JSON.stringify(payload)}
|
|
3215
|
+
`);
|
|
2655
3216
|
return;
|
|
2656
3217
|
}
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
3218
|
+
process.stdout.write(`${draft}
|
|
3219
|
+
`);
|
|
3220
|
+
}
|
|
3221
|
+
|
|
3222
|
+
// src/commands/decisions.ts
|
|
3223
|
+
async function runDecisions(sub, arg, options = {}) {
|
|
3224
|
+
const root = process.cwd();
|
|
3225
|
+
if (sub === "check") {
|
|
3226
|
+
const commitMessages = options.range != null ? (await Promise.resolve().then(() => (init_exec(), exec_exports)).then(
|
|
3227
|
+
(m) => m.runGit(["log", "--format=%B", options.range], { cwd: root })
|
|
3228
|
+
)).stdout : "";
|
|
3229
|
+
const result = await runDecisionsCheck(root, {
|
|
3230
|
+
staged: options.staged,
|
|
3231
|
+
range: options.range,
|
|
3232
|
+
prBody: options.prBody ?? process.env.PR_BODY,
|
|
3233
|
+
commitMessages
|
|
3234
|
+
});
|
|
3235
|
+
if (!result.triggered) {
|
|
3236
|
+
log(result.reasons[0] ?? "no decision-worthy changes");
|
|
3237
|
+
return;
|
|
3238
|
+
}
|
|
3239
|
+
if (result.satisfied) {
|
|
3240
|
+
if (result.reasons.some((r) => r.includes("[adr-skip]"))) {
|
|
3241
|
+
warn(`::warning::${result.reasons.join("; ")}`);
|
|
3242
|
+
}
|
|
3243
|
+
log(`decisions gate: satisfied (${result.reasons.join("; ")})`);
|
|
3244
|
+
return;
|
|
3245
|
+
}
|
|
3246
|
+
for (const reason of result.reasons) warn(reason);
|
|
3247
|
+
error(
|
|
3248
|
+
"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."
|
|
3249
|
+
);
|
|
3250
|
+
process.exitCode = 1;
|
|
2660
3251
|
return;
|
|
2661
3252
|
}
|
|
2662
|
-
if (
|
|
2663
|
-
|
|
3253
|
+
if (sub === "suggest") {
|
|
3254
|
+
await runDecisionsSuggest(root, { fromHook: options.fromHook, title: options.title });
|
|
2664
3255
|
return;
|
|
2665
3256
|
}
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
error(`clone failed for ${url}: ${(res.stderr || res.stdout).trim()}`);
|
|
2669
|
-
process.exitCode = 1;
|
|
3257
|
+
if (sub === "next-number") {
|
|
3258
|
+
log(await nextDecisionNumber(root));
|
|
2670
3259
|
return;
|
|
2671
3260
|
}
|
|
2672
|
-
|
|
3261
|
+
if (sub === "validate") {
|
|
3262
|
+
const target = arg ? path22.resolve(root, arg) : void 0;
|
|
3263
|
+
if (!target) {
|
|
3264
|
+
error("usage: haus decisions validate <path-to-decision.md>");
|
|
3265
|
+
process.exitCode = 1;
|
|
3266
|
+
return;
|
|
3267
|
+
}
|
|
3268
|
+
const result = await validateDecisionFile(target);
|
|
3269
|
+
if (!result.ok) {
|
|
3270
|
+
for (const e of result.errors) error(e);
|
|
3271
|
+
process.exitCode = 1;
|
|
3272
|
+
return;
|
|
3273
|
+
}
|
|
3274
|
+
log("valid");
|
|
3275
|
+
}
|
|
2673
3276
|
}
|
|
2674
3277
|
|
|
2675
3278
|
// src/commands/doctor.ts
|
|
2676
|
-
import
|
|
2677
|
-
import
|
|
3279
|
+
import path28 from "node:path";
|
|
3280
|
+
import fs22 from "fs-extra";
|
|
3281
|
+
|
|
3282
|
+
// src/decisions/doctor.ts
|
|
3283
|
+
import path23 from "node:path";
|
|
3284
|
+
import fs20 from "fs-extra";
|
|
3285
|
+
async function auditDecisionsLayout(root) {
|
|
3286
|
+
const findings = [];
|
|
3287
|
+
const primary = path23.join(root, DECISIONS_TRIGGERS.decisionsDir);
|
|
3288
|
+
const legacy = path23.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
|
|
3289
|
+
const hasPrimary = await fs20.pathExists(primary);
|
|
3290
|
+
const hasLegacy = await fs20.pathExists(legacy);
|
|
3291
|
+
if (!hasPrimary && hasLegacy) {
|
|
3292
|
+
findings.push({
|
|
3293
|
+
level: "advisory",
|
|
3294
|
+
line: `- DECISIONS: legacy ${DECISIONS_TRIGGERS.legacyDecisionsDir}/ present \u2014 migrate to docs/decisions/`,
|
|
3295
|
+
sentence: "Decision records still live under the legacy docs/adr/ path",
|
|
3296
|
+
fix: `git mv ${DECISIONS_TRIGGERS.legacyDecisionsDir} ${DECISIONS_TRIGGERS.decisionsDir}`
|
|
3297
|
+
});
|
|
3298
|
+
return findings;
|
|
3299
|
+
}
|
|
3300
|
+
if (!hasPrimary) return findings;
|
|
3301
|
+
const decisionsDir = await resolveDecisionsDir(root);
|
|
3302
|
+
const rel = relativeDecisionPath(decisionsDir, root);
|
|
3303
|
+
const readmePath = path23.join(decisionsDir, "README.md");
|
|
3304
|
+
if (!await fs20.pathExists(readmePath)) {
|
|
3305
|
+
findings.push({
|
|
3306
|
+
level: "advisory",
|
|
3307
|
+
line: `- DECISIONS: missing ${rel}/README.md index`,
|
|
3308
|
+
sentence: "No decision index for agents to load at session start",
|
|
3309
|
+
fix: `create ${rel}/README.md and @import it from CLAUDE.md`
|
|
3310
|
+
});
|
|
3311
|
+
return findings;
|
|
3312
|
+
}
|
|
3313
|
+
const readme = await readText(readmePath);
|
|
3314
|
+
const entries = (await fs20.readdir(decisionsDir)).filter(
|
|
3315
|
+
(f) => f.endsWith(".md") && f !== "README.md"
|
|
3316
|
+
);
|
|
3317
|
+
for (const file of entries) {
|
|
3318
|
+
const num = decisionNumberFromFile(file);
|
|
3319
|
+
if (num && readme && !readme.includes(`[${num}]`) && !readme.includes(`(${num}-`)) {
|
|
3320
|
+
findings.push({
|
|
3321
|
+
level: "advisory",
|
|
3322
|
+
line: `- DECISIONS: ${file} missing from ${rel}/README.md`,
|
|
3323
|
+
sentence: `ADR-${num} is not listed in the decision index`,
|
|
3324
|
+
fix: `add a row for ADR-${num} to ${rel}/README.md`
|
|
3325
|
+
});
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
const claude = await readText(path23.join(root, "CLAUDE.md"));
|
|
3329
|
+
if (claude && !claude.includes(`@${rel}/README.md`)) {
|
|
3330
|
+
findings.push({
|
|
3331
|
+
level: "advisory",
|
|
3332
|
+
line: `- DECISIONS: CLAUDE.md does not @import @${rel}/README.md`,
|
|
3333
|
+
sentence: "Agents may not load the decision index at session start",
|
|
3334
|
+
fix: `add @${rel}/README.md to CLAUDE.md`
|
|
3335
|
+
});
|
|
3336
|
+
}
|
|
3337
|
+
const numbers = await listDecisionNumbers(decisionsDir);
|
|
3338
|
+
const dupes = numbers.filter((n, i) => numbers.indexOf(n) !== i);
|
|
3339
|
+
if (dupes.length > 0) {
|
|
3340
|
+
findings.push({
|
|
3341
|
+
level: "advisory",
|
|
3342
|
+
line: `- DECISIONS: duplicate numbers: ${[...new Set(dupes)].join(", ")}`,
|
|
3343
|
+
sentence: "Multiple decision files share the same number",
|
|
3344
|
+
fix: "renumber one file and update README.md"
|
|
3345
|
+
});
|
|
3346
|
+
}
|
|
3347
|
+
if (findings.length === 0) {
|
|
3348
|
+
findings.push({
|
|
3349
|
+
level: "ok",
|
|
3350
|
+
line: `- DECISIONS OK: ${rel}/ (${entries.length} record(s))`,
|
|
3351
|
+
sentence: "",
|
|
3352
|
+
fix: ""
|
|
3353
|
+
});
|
|
3354
|
+
}
|
|
3355
|
+
return findings;
|
|
3356
|
+
}
|
|
2678
3357
|
|
|
2679
3358
|
// src/scanner/scan-project.ts
|
|
2680
|
-
import
|
|
3359
|
+
import path27 from "node:path";
|
|
2681
3360
|
|
|
2682
3361
|
// src/utils/audit-checks.ts
|
|
2683
3362
|
function isRecord(v) {
|
|
@@ -2704,8 +3383,8 @@ function compareVersions(a, b) {
|
|
|
2704
3383
|
}
|
|
2705
3384
|
|
|
2706
3385
|
// src/scanner/detect-package-manager.ts
|
|
2707
|
-
import
|
|
2708
|
-
import
|
|
3386
|
+
import path24 from "node:path";
|
|
3387
|
+
import fs21 from "fs-extra";
|
|
2709
3388
|
function detectPackageManager(root, packageManagerField) {
|
|
2710
3389
|
const field = String(packageManagerField ?? "").trim();
|
|
2711
3390
|
if (field.startsWith("yarn@")) {
|
|
@@ -2723,9 +3402,9 @@ function detectPackageManager(root, packageManagerField) {
|
|
|
2723
3402
|
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
2724
3403
|
return "unknown";
|
|
2725
3404
|
}
|
|
2726
|
-
if (
|
|
2727
|
-
if (
|
|
2728
|
-
if (
|
|
3405
|
+
if (fs21.existsSync(path24.join(root, "yarn.lock"))) return "yarn";
|
|
3406
|
+
if (fs21.existsSync(path24.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
3407
|
+
if (fs21.existsSync(path24.join(root, "package-lock.json"))) return "npm";
|
|
2729
3408
|
return "unknown";
|
|
2730
3409
|
}
|
|
2731
3410
|
|
|
@@ -2927,7 +3606,7 @@ function runDetection(ctx, rules = STACK_RULES) {
|
|
|
2927
3606
|
}
|
|
2928
3607
|
|
|
2929
3608
|
// src/scanner/detection.ts
|
|
2930
|
-
import
|
|
3609
|
+
import path25 from "node:path";
|
|
2931
3610
|
var UNSUPPORTED_MARKERS = {
|
|
2932
3611
|
"requirements.txt": "python",
|
|
2933
3612
|
"pyproject.toml": "python",
|
|
@@ -2981,14 +3660,14 @@ function finalizeRoles(registryRoles, deps, files) {
|
|
|
2981
3660
|
function collectUnsupportedSignals(files) {
|
|
2982
3661
|
return [
|
|
2983
3662
|
...new Set(
|
|
2984
|
-
files.map((f) => UNSUPPORTED_MARKERS[
|
|
3663
|
+
files.map((f) => UNSUPPORTED_MARKERS[path25.basename(f)]).filter((s) => Boolean(s))
|
|
2985
3664
|
)
|
|
2986
3665
|
].sort();
|
|
2987
3666
|
}
|
|
2988
3667
|
|
|
2989
3668
|
// src/scanner/render.ts
|
|
2990
3669
|
import { readFile } from "node:fs/promises";
|
|
2991
|
-
import
|
|
3670
|
+
import path26 from "node:path";
|
|
2992
3671
|
async function buildContentBlob(root, files) {
|
|
2993
3672
|
const candidates = files.filter(
|
|
2994
3673
|
(f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
|
|
@@ -2996,7 +3675,7 @@ async function buildContentBlob(root, files) {
|
|
|
2996
3675
|
const slice = candidates.slice(0, 300);
|
|
2997
3676
|
const parts = await mapWithConcurrency(slice, async (rel) => {
|
|
2998
3677
|
try {
|
|
2999
|
-
return await readFile(
|
|
3678
|
+
return await readFile(path26.join(root, rel), "utf8");
|
|
3000
3679
|
} catch {
|
|
3001
3680
|
return "";
|
|
3002
3681
|
}
|
|
@@ -3083,8 +3762,8 @@ var SAFE_FILES = [
|
|
|
3083
3762
|
"Gemfile"
|
|
3084
3763
|
];
|
|
3085
3764
|
async function scanProject(root) {
|
|
3086
|
-
const pkg = await readJson(
|
|
3087
|
-
const composer = await readJson(
|
|
3765
|
+
const pkg = await readJson(path27.join(root, "package.json"));
|
|
3766
|
+
const composer = await readJson(path27.join(root, "composer.json"));
|
|
3088
3767
|
const files = await listFiles(root, SAFE_FILES);
|
|
3089
3768
|
const safeFiles = files.filter((f) => !blocked(f));
|
|
3090
3769
|
const deps = dependencySet(pkg, composer);
|
|
@@ -3106,14 +3785,12 @@ async function scanProject(root) {
|
|
|
3106
3785
|
crossRepoHints.push("Containerized services detected");
|
|
3107
3786
|
if (safeFiles.some((f) => f.includes("turbo.json") || f.includes("nx.json")))
|
|
3108
3787
|
crossRepoHints.push("Monorepo orchestration detected");
|
|
3109
|
-
if (safeFiles.some((f) => f.includes("wp-content/uploads")))
|
|
3110
|
-
securityRisks.push("Uploads directory present");
|
|
3111
3788
|
const unsupportedSignals = collectUnsupportedSignals(safeFiles);
|
|
3112
3789
|
const detectionStatus = computeDetectionStatus(roles, stacks, unsupportedSignals);
|
|
3113
3790
|
const context = {
|
|
3114
3791
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3115
3792
|
root,
|
|
3116
|
-
repoName: String(pkg?.name ??
|
|
3793
|
+
repoName: String(pkg?.name ?? path27.basename(root)),
|
|
3117
3794
|
packageManager,
|
|
3118
3795
|
repoRoles: roles,
|
|
3119
3796
|
detectedStacks: stacks,
|
|
@@ -3184,11 +3861,16 @@ async function runDoctor(options) {
|
|
|
3184
3861
|
);
|
|
3185
3862
|
const detail = [];
|
|
3186
3863
|
const attention = [];
|
|
3864
|
+
const suggestions = [];
|
|
3187
3865
|
const ok = (text) => detail.push({ stream: "log", text });
|
|
3188
3866
|
const flag = (text, sentence, fix) => {
|
|
3189
3867
|
detail.push({ stream: "warn", text });
|
|
3190
3868
|
attention.push({ sentence, fix });
|
|
3191
3869
|
};
|
|
3870
|
+
const suggest = (text, sentence, fix) => {
|
|
3871
|
+
detail.push({ stream: "warn", text });
|
|
3872
|
+
suggestions.push({ sentence, fix });
|
|
3873
|
+
};
|
|
3192
3874
|
ok(`Repo: ${context.repoName}`);
|
|
3193
3875
|
ok(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
|
|
3194
3876
|
ok(`Package manager: ${context.packageManager}`);
|
|
@@ -3199,7 +3881,11 @@ async function runDoctor(options) {
|
|
|
3199
3881
|
}
|
|
3200
3882
|
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
3201
3883
|
if (hooks.skipped) {
|
|
3202
|
-
|
|
3884
|
+
flag(
|
|
3885
|
+
`- HOOKS: (skipped) ${hooks.message}`,
|
|
3886
|
+
"No Claude Code settings.json \u2014 haus security hooks are not installed",
|
|
3887
|
+
"haus apply --write"
|
|
3888
|
+
);
|
|
3203
3889
|
} else if (!hooks.ok) {
|
|
3204
3890
|
flag(
|
|
3205
3891
|
`- HOOKS FAIL: ${hooks.message}`,
|
|
@@ -3210,7 +3896,7 @@ async function runDoctor(options) {
|
|
|
3210
3896
|
} else {
|
|
3211
3897
|
ok(`- HOOKS OK: ${hooks.message}`);
|
|
3212
3898
|
}
|
|
3213
|
-
const rootClaudeMdPath =
|
|
3899
|
+
const rootClaudeMdPath = path28.join(root, "CLAUDE.md");
|
|
3214
3900
|
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
3215
3901
|
if (!rootClaudeMdContent) {
|
|
3216
3902
|
flag(
|
|
@@ -3238,7 +3924,7 @@ async function runDoctor(options) {
|
|
|
3238
3924
|
const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
|
|
3239
3925
|
const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
|
|
3240
3926
|
for (const target of importTargets) {
|
|
3241
|
-
if (!await
|
|
3927
|
+
if (!await fs22.pathExists(hausPath(root, target))) {
|
|
3242
3928
|
flag(
|
|
3243
3929
|
`- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
|
|
3244
3930
|
`A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
|
|
@@ -3249,7 +3935,7 @@ async function runDoctor(options) {
|
|
|
3249
3935
|
}
|
|
3250
3936
|
}
|
|
3251
3937
|
const workflowPath = hausPath(root, "WORKFLOW.md");
|
|
3252
|
-
const workflowExists = await
|
|
3938
|
+
const workflowExists = await fs22.pathExists(workflowPath);
|
|
3253
3939
|
if (!workflowExists) {
|
|
3254
3940
|
flag(
|
|
3255
3941
|
"- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
|
|
@@ -3272,20 +3958,20 @@ async function runDoctor(options) {
|
|
|
3272
3958
|
"haus apply --write --force"
|
|
3273
3959
|
);
|
|
3274
3960
|
} else {
|
|
3275
|
-
const cachePath =
|
|
3276
|
-
const bundledPath =
|
|
3961
|
+
const cachePath = path28.join(getCacheDir(), "templates/agentic-workflow-standard.md");
|
|
3962
|
+
const bundledPath = path28.join(
|
|
3277
3963
|
packageRoot(),
|
|
3278
3964
|
"library",
|
|
3279
3965
|
"global",
|
|
3280
3966
|
"templates",
|
|
3281
3967
|
"agentic-workflow-standard.md"
|
|
3282
3968
|
);
|
|
3283
|
-
const templatePath = await
|
|
3969
|
+
const templatePath = await fs22.pathExists(cachePath) ? cachePath : bundledPath;
|
|
3284
3970
|
const templateContent = await readText(templatePath);
|
|
3285
3971
|
if (storedHashMatch && templateContent) {
|
|
3286
3972
|
const currentHash = hashText(normaliseLF(templateContent));
|
|
3287
3973
|
if (storedHashMatch[1] !== currentHash) {
|
|
3288
|
-
|
|
3974
|
+
suggest(
|
|
3289
3975
|
"- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
|
|
3290
3976
|
"The workflow standard is out of date",
|
|
3291
3977
|
"haus apply --write"
|
|
@@ -3300,7 +3986,7 @@ async function runDoctor(options) {
|
|
|
3300
3986
|
}
|
|
3301
3987
|
}
|
|
3302
3988
|
const workflowConfigPath = hausPath(root, "workflow-config.md");
|
|
3303
|
-
const workflowConfigExists = await
|
|
3989
|
+
const workflowConfigExists = await fs22.pathExists(workflowConfigPath);
|
|
3304
3990
|
if (!workflowConfigExists) {
|
|
3305
3991
|
flag(
|
|
3306
3992
|
"- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
|
|
@@ -3308,10 +3994,10 @@ async function runDoctor(options) {
|
|
|
3308
3994
|
"haus apply --write"
|
|
3309
3995
|
);
|
|
3310
3996
|
} else {
|
|
3311
|
-
const cfg = await
|
|
3997
|
+
const cfg = await fs22.readFile(workflowConfigPath, "utf8");
|
|
3312
3998
|
const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
|
|
3313
3999
|
if (unfilled > 0) {
|
|
3314
|
-
|
|
4000
|
+
suggest(
|
|
3315
4001
|
`- .haus-workflow/workflow-config.md: ${unfilled} field(s) still unfilled (run \`haus apply --refill-config\` to auto-fill detectable ones)`,
|
|
3316
4002
|
`${unfilled} workflow-config field(s) are still blank`,
|
|
3317
4003
|
"haus apply --refill-config"
|
|
@@ -3321,7 +4007,7 @@ async function runDoctor(options) {
|
|
|
3321
4007
|
}
|
|
3322
4008
|
}
|
|
3323
4009
|
if (workflowExists) {
|
|
3324
|
-
const prettierIgnore = await readText(
|
|
4010
|
+
const prettierIgnore = await readText(path28.join(root, ".prettierignore")) ?? "";
|
|
3325
4011
|
if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
|
|
3326
4012
|
flag(
|
|
3327
4013
|
"- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
|
|
@@ -3332,9 +4018,13 @@ async function runDoctor(options) {
|
|
|
3332
4018
|
ok("- .prettierignore: protects .haus-workflow/");
|
|
3333
4019
|
}
|
|
3334
4020
|
}
|
|
4021
|
+
for (const finding of await auditDecisionsLayout(root)) {
|
|
4022
|
+
if (finding.level === "ok") ok(finding.line);
|
|
4023
|
+
else suggest(finding.line, finding.sentence, finding.fix);
|
|
4024
|
+
}
|
|
3335
4025
|
const cacheAgeMs = await getCacheManifestAge();
|
|
3336
4026
|
if (cacheAgeMs === null) {
|
|
3337
|
-
|
|
4027
|
+
suggest(
|
|
3338
4028
|
"- CATALOG CACHE: absent (run `haus update` to populate)",
|
|
3339
4029
|
"The catalog cache hasn't been downloaded yet",
|
|
3340
4030
|
"haus update"
|
|
@@ -3342,7 +4032,7 @@ async function runDoctor(options) {
|
|
|
3342
4032
|
} else {
|
|
3343
4033
|
const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
|
|
3344
4034
|
if (cacheAgeDays >= 7) {
|
|
3345
|
-
|
|
4035
|
+
suggest(
|
|
3346
4036
|
`- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`,
|
|
3347
4037
|
`The catalog cache is ${cacheAgeDays} days old`,
|
|
3348
4038
|
"haus update"
|
|
@@ -3351,11 +4041,11 @@ async function runDoctor(options) {
|
|
|
3351
4041
|
ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
|
|
3352
4042
|
}
|
|
3353
4043
|
}
|
|
3354
|
-
const pkgJson = await readJson(
|
|
4044
|
+
const pkgJson = await readJson(path28.join(packageRoot(), "package.json"));
|
|
3355
4045
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
3356
4046
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
3357
4047
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
3358
|
-
|
|
4048
|
+
suggest(
|
|
3359
4049
|
`- CLI UPDATE: ${currentVersion} \u2192 ${npmStatus.latest} available (run: npm install -g ${NPM_PACKAGE_NAME})`,
|
|
3360
4050
|
`A newer haus (${npmStatus.latest}) is available`,
|
|
3361
4051
|
`npm install -g ${NPM_PACKAGE_NAME}`
|
|
@@ -3365,12 +4055,17 @@ async function runDoctor(options) {
|
|
|
3365
4055
|
} else {
|
|
3366
4056
|
ok(`- CLI: ${currentVersion} (version check unavailable)`);
|
|
3367
4057
|
}
|
|
4058
|
+
if (attention.length > 0) process.exitCode = 1;
|
|
3368
4059
|
if (attention.length === 0) {
|
|
3369
4060
|
log("\u2705 Your project is set up and healthy.");
|
|
3370
4061
|
} else {
|
|
3371
4062
|
log(`\u26A0\uFE0F ${attention.length} thing(s) need attention:`);
|
|
3372
4063
|
for (const a of attention) log(` \u2022 ${a.sentence} \u2014 fix: ${a.fix}`);
|
|
3373
4064
|
}
|
|
4065
|
+
if (suggestions.length > 0) {
|
|
4066
|
+
log(`\u{1F4A1} ${suggestions.length} suggestion(s):`);
|
|
4067
|
+
for (const s of suggestions) log(` \u2022 ${s.sentence} \u2014 try: ${s.fix}`);
|
|
4068
|
+
}
|
|
3374
4069
|
log("Haus Doctor");
|
|
3375
4070
|
for (const line2 of detail) {
|
|
3376
4071
|
if (line2.stream === "warn") warn(line2.text);
|
|
@@ -3502,6 +4197,61 @@ async function runExplainRecommendation(options) {
|
|
|
3502
4197
|
log(formatRecommendationHuman(normalized));
|
|
3503
4198
|
}
|
|
3504
4199
|
|
|
4200
|
+
// src/commands/fetch-refs.ts
|
|
4201
|
+
var REFS_CACHE_SUBDIR = "llms-cache";
|
|
4202
|
+
async function runFetchRefs(options) {
|
|
4203
|
+
const root = process.cwd();
|
|
4204
|
+
const cacheDir = hausPath(root, REFS_CACHE_SUBDIR);
|
|
4205
|
+
let allItems;
|
|
4206
|
+
try {
|
|
4207
|
+
allItems = await loadCatalog(root);
|
|
4208
|
+
} catch (err) {
|
|
4209
|
+
warn(`Could not load catalog: ${err instanceof Error ? err.message : String(err)}`);
|
|
4210
|
+
process.exitCode = 1;
|
|
4211
|
+
return;
|
|
4212
|
+
}
|
|
4213
|
+
let items = allItems;
|
|
4214
|
+
if (options.id) {
|
|
4215
|
+
items = allItems.filter((item) => item.id === options.id);
|
|
4216
|
+
if (items.length === 0) {
|
|
4217
|
+
warn(`No catalog item found with id "${options.id}".`);
|
|
4218
|
+
process.exitCode = 1;
|
|
4219
|
+
return;
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
const withRefs = items.filter((item) => (item.references ?? []).some(isLlmsTxtUrl));
|
|
4223
|
+
if (withRefs.length === 0) {
|
|
4224
|
+
if (options.json) {
|
|
4225
|
+
log(
|
|
4226
|
+
JSON.stringify(
|
|
4227
|
+
{ fetched: 0, unchanged: 0, failed: 0, failedUrls: [], cachedFiles: {} },
|
|
4228
|
+
null,
|
|
4229
|
+
2
|
|
4230
|
+
)
|
|
4231
|
+
);
|
|
4232
|
+
} else {
|
|
4233
|
+
log("No catalog items with llms.txt references found.");
|
|
4234
|
+
}
|
|
4235
|
+
return;
|
|
4236
|
+
}
|
|
4237
|
+
const summary = await fetchRefsForItems(withRefs, cacheDir);
|
|
4238
|
+
if (options.json) {
|
|
4239
|
+
log(JSON.stringify(summary, null, 2));
|
|
4240
|
+
return;
|
|
4241
|
+
}
|
|
4242
|
+
for (const [url, filePath] of Object.entries(summary.cachedFiles)) {
|
|
4243
|
+
log(`Cached: ${filePath} (${url})`);
|
|
4244
|
+
}
|
|
4245
|
+
if (summary.fetched > 0) log(`Fetched: ${summary.fetched} URL(s)`);
|
|
4246
|
+
if (summary.unchanged > 0) log(`Unchanged (etag match): ${summary.unchanged} URL(s)`);
|
|
4247
|
+
if (summary.failed > 0) {
|
|
4248
|
+
warn(`Failed to fetch ${summary.failed} URL(s): ${summary.failedUrls.join(", ")}`);
|
|
4249
|
+
}
|
|
4250
|
+
if (summary.fetched === 0 && summary.unchanged > 0 && summary.failed === 0) {
|
|
4251
|
+
log("All references up to date.");
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
|
|
3505
4255
|
// src/commands/guard.ts
|
|
3506
4256
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
3507
4257
|
|
|
@@ -3538,7 +4288,15 @@ function stdin() {
|
|
|
3538
4288
|
}
|
|
3539
4289
|
}
|
|
3540
4290
|
function deny(reason) {
|
|
3541
|
-
log(
|
|
4291
|
+
log(
|
|
4292
|
+
JSON.stringify({
|
|
4293
|
+
hookSpecificOutput: {
|
|
4294
|
+
hookEventName: "PreToolUse",
|
|
4295
|
+
permissionDecision: "deny",
|
|
4296
|
+
permissionDecisionReason: reason
|
|
4297
|
+
}
|
|
4298
|
+
})
|
|
4299
|
+
);
|
|
3542
4300
|
}
|
|
3543
4301
|
async function runGuard(kind, _options) {
|
|
3544
4302
|
const raw = stdin();
|
|
@@ -3549,7 +4307,6 @@ async function runGuard(kind, _options) {
|
|
|
3549
4307
|
if (isRecord(parsed)) payload = parsed;
|
|
3550
4308
|
} catch {
|
|
3551
4309
|
deny("Malformed hook payload");
|
|
3552
|
-
process.exitCode = 1;
|
|
3553
4310
|
return;
|
|
3554
4311
|
}
|
|
3555
4312
|
}
|
|
@@ -3559,8 +4316,6 @@ async function runGuard(kind, _options) {
|
|
|
3559
4316
|
const reason2 = guardFileAccess(candidate);
|
|
3560
4317
|
if (reason2) {
|
|
3561
4318
|
deny(reason2);
|
|
3562
|
-
process.exitCode = 1;
|
|
3563
|
-
return;
|
|
3564
4319
|
}
|
|
3565
4320
|
return;
|
|
3566
4321
|
}
|
|
@@ -3568,13 +4323,12 @@ async function runGuard(kind, _options) {
|
|
|
3568
4323
|
const reason = guardBash(command);
|
|
3569
4324
|
if (reason) {
|
|
3570
4325
|
deny(reason);
|
|
3571
|
-
process.exitCode = 1;
|
|
3572
4326
|
}
|
|
3573
4327
|
}
|
|
3574
4328
|
|
|
3575
4329
|
// src/commands/init.ts
|
|
3576
|
-
import
|
|
3577
|
-
import
|
|
4330
|
+
import path29 from "node:path";
|
|
4331
|
+
import fs23 from "fs-extra";
|
|
3578
4332
|
|
|
3579
4333
|
// src/utils/prompts.ts
|
|
3580
4334
|
import { stdin as input, stdout as output } from "node:process";
|
|
@@ -3595,6 +4349,7 @@ async function confirm(question) {
|
|
|
3595
4349
|
}
|
|
3596
4350
|
|
|
3597
4351
|
// src/recommender/git-signal.ts
|
|
4352
|
+
init_exec();
|
|
3598
4353
|
async function readChangedFiles(root) {
|
|
3599
4354
|
if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
|
|
3600
4355
|
try {
|
|
@@ -4009,37 +4764,39 @@ function buildOptInEligible(items, skipped) {
|
|
|
4009
4764
|
|
|
4010
4765
|
// src/commands/setup-core.ts
|
|
4011
4766
|
async function runSetupCore(root, opts) {
|
|
4012
|
-
const { json, apply, dryRun, confirm: confirm2 } = opts;
|
|
4767
|
+
const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
|
|
4768
|
+
const say = quiet ? () => {
|
|
4769
|
+
} : log;
|
|
4013
4770
|
const scanResult = await scanProject(root);
|
|
4014
4771
|
if (json) {
|
|
4015
|
-
|
|
4772
|
+
say(JSON.stringify(scanResult, null, 2));
|
|
4016
4773
|
} else {
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4774
|
+
say("Haus scan complete");
|
|
4775
|
+
say(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
|
|
4776
|
+
say(`Package manager: ${scanResult.packageManager}`);
|
|
4020
4777
|
}
|
|
4021
4778
|
const context = await readContextOrScan(root);
|
|
4022
4779
|
const recommendation = await recommend(root, context);
|
|
4023
4780
|
await writeJson(hausPath(root, "recommendation.json"), recommendation);
|
|
4024
4781
|
if (json) {
|
|
4025
|
-
|
|
4782
|
+
say(JSON.stringify(recommendation, null, 2));
|
|
4026
4783
|
} else {
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4784
|
+
say("Haus recommendation ready");
|
|
4785
|
+
say(`Recommended: ${recommendation.recommended.length}`);
|
|
4786
|
+
say(`Skipped: ${recommendation.skipped.length}`);
|
|
4030
4787
|
}
|
|
4031
4788
|
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
4032
4789
|
const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
|
|
4033
|
-
|
|
4034
|
-
for (const warning of warningLines)
|
|
4790
|
+
say(`Repo: ${context.repoName}`);
|
|
4791
|
+
for (const warning of warningLines) say(`- WARN: ${warning}`);
|
|
4035
4792
|
const hooksOk = hooks.ok;
|
|
4036
4793
|
if (hooks.skipped) {
|
|
4037
|
-
|
|
4794
|
+
say(`- HOOKS: (skipped) ${hooks.message}`);
|
|
4038
4795
|
} else if (!hooks.ok) {
|
|
4039
|
-
|
|
4040
|
-
process.exitCode = 1;
|
|
4796
|
+
say(`- HOOKS FAIL: ${hooks.message}`);
|
|
4797
|
+
if (!apply) process.exitCode = 1;
|
|
4041
4798
|
} else {
|
|
4042
|
-
|
|
4799
|
+
say(`- HOOKS OK: ${hooks.message}`);
|
|
4043
4800
|
}
|
|
4044
4801
|
const baseResult = {
|
|
4045
4802
|
root,
|
|
@@ -4054,32 +4811,32 @@ async function runSetupCore(root, opts) {
|
|
|
4054
4811
|
if (confirm2) {
|
|
4055
4812
|
const approved = await confirm2();
|
|
4056
4813
|
if (!approved) {
|
|
4057
|
-
|
|
4058
|
-
|
|
4814
|
+
say("Setup reviewed. No files written.");
|
|
4815
|
+
say("Next step: run `haus apply --write` when ready.");
|
|
4059
4816
|
return baseResult;
|
|
4060
4817
|
}
|
|
4061
4818
|
}
|
|
4062
4819
|
if (!dryRun && !process.env["HAUS_FIXTURE_CATALOG"]) {
|
|
4063
|
-
|
|
4820
|
+
say("Syncing remote catalog...");
|
|
4064
4821
|
const sync = await syncRemoteCatalog();
|
|
4065
4822
|
if (sync.newItems.length > 0) {
|
|
4066
|
-
|
|
4823
|
+
say(`Catalog cache populated: ${sync.newItems.length} new item(s).`);
|
|
4067
4824
|
} else if (sync.refreshed.length > 0) {
|
|
4068
|
-
|
|
4825
|
+
say(`Catalog cache refreshed: ${sync.refreshed.length} updated item(s).`);
|
|
4069
4826
|
}
|
|
4070
4827
|
}
|
|
4071
|
-
const files = await writeClaudeFiles(root, dryRun ?? false);
|
|
4072
|
-
|
|
4073
|
-
files.forEach((f) =>
|
|
4828
|
+
const files = await writeClaudeFiles(root, dryRun ?? false, void 0, { quiet: opts.quiet });
|
|
4829
|
+
say("Applied files:");
|
|
4830
|
+
files.forEach((f) => say(`- ${displayPath(root, f)}`));
|
|
4074
4831
|
const hooksAfter = await verifyProjectSettingsHooksContract(root);
|
|
4075
4832
|
const hooksOkAfter = hooksAfter.ok;
|
|
4076
4833
|
if (hooksAfter.skipped) {
|
|
4077
|
-
|
|
4834
|
+
say(`- HOOKS: (skipped) ${hooksAfter.message}`);
|
|
4078
4835
|
} else if (!hooksAfter.ok) {
|
|
4079
|
-
|
|
4836
|
+
say(`- HOOKS FAIL: ${hooksAfter.message}`);
|
|
4080
4837
|
process.exitCode = 1;
|
|
4081
4838
|
} else {
|
|
4082
|
-
|
|
4839
|
+
say(`- HOOKS OK: ${hooksAfter.message}`);
|
|
4083
4840
|
}
|
|
4084
4841
|
return { ...baseResult, hooksOk: hooksOkAfter, written: files };
|
|
4085
4842
|
}
|
|
@@ -4098,8 +4855,8 @@ async function runSetupProject(options) {
|
|
|
4098
4855
|
// src/commands/init.ts
|
|
4099
4856
|
async function runInit(options) {
|
|
4100
4857
|
const root = process.cwd();
|
|
4101
|
-
const hausDir =
|
|
4102
|
-
const alreadyInit = await
|
|
4858
|
+
const hausDir = path29.join(root, ".haus-workflow");
|
|
4859
|
+
const alreadyInit = await fs23.pathExists(hausDir);
|
|
4103
4860
|
if (alreadyInit) {
|
|
4104
4861
|
log("Haus AI already initialized in this project.");
|
|
4105
4862
|
log("Run `haus setup-project` to reconfigure.");
|
|
@@ -4111,8 +4868,8 @@ async function runInit(options) {
|
|
|
4111
4868
|
|
|
4112
4869
|
// src/install/apply.ts
|
|
4113
4870
|
import crypto2 from "node:crypto";
|
|
4114
|
-
import
|
|
4115
|
-
import
|
|
4871
|
+
import path30 from "node:path";
|
|
4872
|
+
import fs24 from "fs-extra";
|
|
4116
4873
|
|
|
4117
4874
|
// src/install/header.ts
|
|
4118
4875
|
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
@@ -4198,40 +4955,40 @@ function hashContent(content2) {
|
|
|
4198
4955
|
}
|
|
4199
4956
|
function sourceVersion() {
|
|
4200
4957
|
try {
|
|
4201
|
-
const pkgPath =
|
|
4202
|
-
const pkg = JSON.parse(
|
|
4958
|
+
const pkgPath = path30.join(packageRoot(), "package.json");
|
|
4959
|
+
const pkg = JSON.parse(fs24.readFileSync(pkgPath, "utf8"));
|
|
4203
4960
|
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
4204
4961
|
} catch {
|
|
4205
4962
|
return "haus@0.0.0";
|
|
4206
4963
|
}
|
|
4207
4964
|
}
|
|
4208
4965
|
function globalSrcDir() {
|
|
4209
|
-
return
|
|
4966
|
+
return path30.join(packageRoot(), "library", "global");
|
|
4210
4967
|
}
|
|
4211
4968
|
function collectSourceFiles(srcDir, claudeDir) {
|
|
4212
4969
|
const entries = [];
|
|
4213
|
-
const skillsDir =
|
|
4214
|
-
if (
|
|
4215
|
-
for (const skillName of
|
|
4216
|
-
const skillFile =
|
|
4217
|
-
if (
|
|
4970
|
+
const skillsDir = path30.join(srcDir, "skills");
|
|
4971
|
+
if (fs24.pathExistsSync(skillsDir)) {
|
|
4972
|
+
for (const skillName of fs24.readdirSync(skillsDir)) {
|
|
4973
|
+
const skillFile = path30.join(skillsDir, skillName, "SKILL.md");
|
|
4974
|
+
if (fs24.pathExistsSync(skillFile)) {
|
|
4218
4975
|
entries.push({
|
|
4219
4976
|
stableId: `skill.${skillName}`,
|
|
4220
|
-
srcRelPath:
|
|
4221
|
-
destPath:
|
|
4977
|
+
srcRelPath: path30.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
4978
|
+
destPath: path30.join(claudeDir, "skills", skillName, "SKILL.md")
|
|
4222
4979
|
});
|
|
4223
4980
|
}
|
|
4224
4981
|
}
|
|
4225
4982
|
}
|
|
4226
|
-
const commandsDir =
|
|
4227
|
-
if (
|
|
4228
|
-
for (const fileName of
|
|
4983
|
+
const commandsDir = path30.join(srcDir, "commands");
|
|
4984
|
+
if (fs24.pathExistsSync(commandsDir)) {
|
|
4985
|
+
for (const fileName of fs24.readdirSync(commandsDir)) {
|
|
4229
4986
|
if (!fileName.endsWith(".md")) continue;
|
|
4230
4987
|
const commandName = fileName.slice(0, -".md".length);
|
|
4231
4988
|
entries.push({
|
|
4232
4989
|
stableId: `command.${commandName}`,
|
|
4233
|
-
srcRelPath:
|
|
4234
|
-
destPath:
|
|
4990
|
+
srcRelPath: path30.join("library", "global", "commands", fileName),
|
|
4991
|
+
destPath: path30.join(claudeDir, "commands", fileName)
|
|
4235
4992
|
});
|
|
4236
4993
|
}
|
|
4237
4994
|
}
|
|
@@ -4249,13 +5006,15 @@ async function applyInstall(options = {}) {
|
|
|
4249
5006
|
created: [],
|
|
4250
5007
|
updated: [],
|
|
4251
5008
|
skipped: [],
|
|
5009
|
+
userOwned: [],
|
|
5010
|
+
userEdited: [],
|
|
4252
5011
|
deleted: [],
|
|
4253
5012
|
hookIds: [],
|
|
4254
5013
|
drift: false
|
|
4255
5014
|
};
|
|
4256
5015
|
const manifestFiles = [];
|
|
4257
5016
|
for (const entry of sourceFiles) {
|
|
4258
|
-
const srcPath =
|
|
5017
|
+
const srcPath = path30.join(packageRoot(), entry.srcRelPath);
|
|
4259
5018
|
const rawContent = await readText(srcPath);
|
|
4260
5019
|
if (rawContent === void 0) {
|
|
4261
5020
|
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
@@ -4275,7 +5034,7 @@ async function applyInstall(options = {}) {
|
|
|
4275
5034
|
}
|
|
4276
5035
|
continue;
|
|
4277
5036
|
}
|
|
4278
|
-
const destExists =
|
|
5037
|
+
const destExists = fs24.pathExistsSync(entry.destPath);
|
|
4279
5038
|
if (destExists) {
|
|
4280
5039
|
const currentContent = await readText(entry.destPath);
|
|
4281
5040
|
if (currentContent !== void 0) {
|
|
@@ -4283,11 +5042,13 @@ async function applyInstall(options = {}) {
|
|
|
4283
5042
|
if (!hasHeader) {
|
|
4284
5043
|
warn(`Refusing to overwrite user-owned file: ${entry.destPath}`);
|
|
4285
5044
|
result.skipped.push(entry.destPath);
|
|
5045
|
+
result.userOwned.push(entry.destPath);
|
|
4286
5046
|
continue;
|
|
4287
5047
|
}
|
|
4288
5048
|
if (existing && hashContent(currentContent) !== existing.hash && !force) {
|
|
4289
5049
|
warn(`User edited haus file (skipping): ${entry.destPath} \u2014 use --force to overwrite`);
|
|
4290
5050
|
result.skipped.push(entry.destPath);
|
|
5051
|
+
result.userEdited.push(entry.destPath);
|
|
4291
5052
|
manifestFiles.push(existing);
|
|
4292
5053
|
continue;
|
|
4293
5054
|
}
|
|
@@ -4311,25 +5072,29 @@ async function applyInstall(options = {}) {
|
|
|
4311
5072
|
schemaVersion: SCHEMA_VERSION2
|
|
4312
5073
|
});
|
|
4313
5074
|
}
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
|
|
4321
|
-
|
|
5075
|
+
let mergedSettings;
|
|
5076
|
+
if (!check) {
|
|
5077
|
+
const fragmentPath = path30.join(srcDir, "settings-fragments", "hooks.json");
|
|
5078
|
+
const fragments = await loadHooksFragment(fragmentPath);
|
|
5079
|
+
const settings = await readSettings();
|
|
5080
|
+
const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
|
|
5081
|
+
const { settings: deniedSettings } = mergeDenyRules(hookSettings, buildDenyRules());
|
|
5082
|
+
const { settings: allowedSettings } = mergeAllowRules(deniedSettings, buildAllowRules());
|
|
5083
|
+
const merged = mergeAskRules(allowedSettings, buildAskRules());
|
|
5084
|
+
mergedSettings = merged.settings;
|
|
5085
|
+
result.hookIds = addedIds;
|
|
5086
|
+
}
|
|
4322
5087
|
if (!check && existingManifest) {
|
|
4323
5088
|
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
4324
5089
|
for (const entry of existingManifest.files) {
|
|
4325
5090
|
if (currentDestPaths.has(entry.destPath)) continue;
|
|
4326
|
-
if (!
|
|
5091
|
+
if (!fs24.pathExistsSync(entry.destPath)) continue;
|
|
4327
5092
|
const content2 = await readText(entry.destPath);
|
|
4328
5093
|
if (!content2) continue;
|
|
4329
5094
|
const hasHeader = parseMarkdownHeader(content2) !== void 0;
|
|
4330
5095
|
const currentHash = hashContent(content2);
|
|
4331
5096
|
if (hasHeader && currentHash === entry.hash) {
|
|
4332
|
-
if (!dryRun) await
|
|
5097
|
+
if (!dryRun) await fs24.remove(entry.destPath);
|
|
4333
5098
|
result.deleted.push(entry.destPath);
|
|
4334
5099
|
} else {
|
|
4335
5100
|
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
@@ -4337,10 +5102,10 @@ async function applyInstall(options = {}) {
|
|
|
4337
5102
|
}
|
|
4338
5103
|
}
|
|
4339
5104
|
}
|
|
4340
|
-
if (!dryRun && !check) {
|
|
5105
|
+
if (!dryRun && !check && mergedSettings) {
|
|
4341
5106
|
await writeSettings(mergedSettings);
|
|
4342
5107
|
const manifest = buildManifest(source, manifestFiles, [
|
|
4343
|
-
.../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...
|
|
5108
|
+
.../* @__PURE__ */ new Set([...existingManifest?.hooks ?? [], ...result.hookIds])
|
|
4344
5109
|
]);
|
|
4345
5110
|
await writeManifest(manifest);
|
|
4346
5111
|
}
|
|
@@ -4360,9 +5125,10 @@ function printApplyResult(result, dryRun) {
|
|
|
4360
5125
|
log(`${prefix}Deleted (orphaned):`);
|
|
4361
5126
|
result.deleted.forEach((p) => log(` x ${p}`));
|
|
4362
5127
|
}
|
|
4363
|
-
|
|
5128
|
+
const silentlySkipped = result.skipped.filter((p) => !result.userOwned.includes(p));
|
|
5129
|
+
if (silentlySkipped.length) {
|
|
4364
5130
|
log(`${prefix}Skipped:`);
|
|
4365
|
-
|
|
5131
|
+
silentlySkipped.forEach((p) => log(` - ${p}`));
|
|
4366
5132
|
}
|
|
4367
5133
|
if (result.hookIds.length) {
|
|
4368
5134
|
log(`${prefix}Hooks added: ${result.hookIds.join(", ")}`);
|
|
@@ -4410,7 +5176,7 @@ function buildCompanionSuggestions(tools, isInstalled) {
|
|
|
4410
5176
|
}
|
|
4411
5177
|
return lines;
|
|
4412
5178
|
}
|
|
4413
|
-
function
|
|
5179
|
+
function commandExists2(bin) {
|
|
4414
5180
|
try {
|
|
4415
5181
|
const r = execaSync("sh", ["-c", 'command -v -- "$1"', "probe", bin], { reject: false });
|
|
4416
5182
|
return r.exitCode === 0;
|
|
@@ -4419,7 +5185,7 @@ function commandExists(bin) {
|
|
|
4419
5185
|
}
|
|
4420
5186
|
}
|
|
4421
5187
|
function printCompanionToolSuggestions(deps = {}) {
|
|
4422
|
-
const isInstalled = deps.isInstalled ??
|
|
5188
|
+
const isInstalled = deps.isInstalled ?? commandExists2;
|
|
4423
5189
|
const emit2 = deps.log ?? log;
|
|
4424
5190
|
const lines = buildCompanionSuggestions(COMPANION_TOOLS, isInstalled);
|
|
4425
5191
|
for (const line2 of lines) emit2(line2);
|
|
@@ -4481,7 +5247,7 @@ async function runRecommend(options) {
|
|
|
4481
5247
|
`Opt-in available: ${result.optInEligible.length} (add with \`haus recommend --include <id>\`)`
|
|
4482
5248
|
);
|
|
4483
5249
|
}
|
|
4484
|
-
for (const w of result.warnings
|
|
5250
|
+
for (const w of result.warnings) log(`- WARN: ${w}`);
|
|
4485
5251
|
}
|
|
4486
5252
|
|
|
4487
5253
|
// src/commands/refresh.ts
|
|
@@ -4497,8 +5263,8 @@ async function runRefresh() {
|
|
|
4497
5263
|
}
|
|
4498
5264
|
|
|
4499
5265
|
// src/install/scaffold.ts
|
|
4500
|
-
import
|
|
4501
|
-
import
|
|
5266
|
+
import path31 from "node:path";
|
|
5267
|
+
import fs25 from "fs-extra";
|
|
4502
5268
|
async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
4503
5269
|
const scaffolded = [];
|
|
4504
5270
|
const skipped = [];
|
|
@@ -4511,27 +5277,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
4511
5277
|
skipped.push(name);
|
|
4512
5278
|
if (outcome === "skipped-exists") skippedExisting.push(name);
|
|
4513
5279
|
};
|
|
4514
|
-
const realRoot = await
|
|
5280
|
+
const realRoot = await fs25.realpath(catalogRoot).catch(() => null);
|
|
4515
5281
|
for (const item of items) {
|
|
4516
5282
|
if (item.type !== "config") continue;
|
|
4517
|
-
const sourcePath =
|
|
4518
|
-
const rel =
|
|
4519
|
-
const escapes = rel === "" ||
|
|
5283
|
+
const sourcePath = path31.resolve(catalogRoot, item.path);
|
|
5284
|
+
const rel = path31.relative(catalogRoot, sourcePath);
|
|
5285
|
+
const escapes = rel === "" || path31.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
|
|
4520
5286
|
if (escapes) {
|
|
4521
5287
|
warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
|
|
4522
5288
|
continue;
|
|
4523
5289
|
}
|
|
4524
|
-
const realSource = await
|
|
5290
|
+
const realSource = await fs25.realpath(sourcePath).catch(() => null);
|
|
4525
5291
|
if (!realRoot || !realSource) {
|
|
4526
5292
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
4527
5293
|
continue;
|
|
4528
5294
|
}
|
|
4529
|
-
const realRel =
|
|
4530
|
-
if (realRel !== "" && (
|
|
5295
|
+
const realRel = path31.relative(realRoot, realSource);
|
|
5296
|
+
if (realRel !== "" && (path31.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
|
|
4531
5297
|
warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
|
|
4532
5298
|
continue;
|
|
4533
5299
|
}
|
|
4534
|
-
const stat = await
|
|
5300
|
+
const stat = await fs25.lstat(sourcePath).catch(() => null);
|
|
4535
5301
|
if (!stat) {
|
|
4536
5302
|
warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
|
|
4537
5303
|
continue;
|
|
@@ -4541,14 +5307,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
4541
5307
|
continue;
|
|
4542
5308
|
}
|
|
4543
5309
|
if (stat.isFile()) {
|
|
4544
|
-
const filename =
|
|
4545
|
-
const dest =
|
|
5310
|
+
const filename = path31.basename(sourcePath);
|
|
5311
|
+
const dest = path31.join(projectRoot, filename);
|
|
4546
5312
|
record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
|
|
4547
5313
|
} else if (stat.isDirectory()) {
|
|
4548
|
-
const entries = await
|
|
5314
|
+
const entries = await fs25.readdir(sourcePath);
|
|
4549
5315
|
for (const entry of entries) {
|
|
4550
|
-
const src =
|
|
4551
|
-
const dest =
|
|
5316
|
+
const src = path31.join(sourcePath, entry);
|
|
5317
|
+
const dest = path31.join(projectRoot, entry);
|
|
4552
5318
|
record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
|
|
4553
5319
|
}
|
|
4554
5320
|
}
|
|
@@ -4556,42 +5322,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
|
|
|
4556
5322
|
return { scaffolded, skipped, skippedExisting };
|
|
4557
5323
|
}
|
|
4558
5324
|
async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
|
|
4559
|
-
const destDisplay =
|
|
4560
|
-
const srcStat = await
|
|
5325
|
+
const destDisplay = path31.relative(projectRoot, dest) || path31.basename(dest);
|
|
5326
|
+
const srcStat = await fs25.lstat(src).catch(() => null);
|
|
4561
5327
|
if (!srcStat) {
|
|
4562
|
-
warn(`Skipping ${
|
|
5328
|
+
warn(`Skipping ${path31.basename(src)}: source not found`);
|
|
4563
5329
|
return "skipped-refused";
|
|
4564
5330
|
}
|
|
4565
5331
|
if (srcStat.isSymbolicLink()) {
|
|
4566
|
-
warn(`Skipping ${
|
|
5332
|
+
warn(`Skipping ${path31.basename(src)}: source is a symlink`);
|
|
4567
5333
|
return "skipped-refused";
|
|
4568
5334
|
}
|
|
4569
|
-
const exists = await
|
|
5335
|
+
const exists = await fs25.pathExists(dest);
|
|
4570
5336
|
if (exists && !opts.force) {
|
|
4571
5337
|
warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
|
|
4572
5338
|
return "skipped-exists";
|
|
4573
5339
|
}
|
|
4574
5340
|
if (opts.dryRun) {
|
|
4575
|
-
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${
|
|
5341
|
+
log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path31.basename(dest)} (${itemId})`);
|
|
4576
5342
|
return "scaffolded";
|
|
4577
5343
|
}
|
|
4578
5344
|
if (exists && opts.force) {
|
|
4579
|
-
await
|
|
5345
|
+
await fs25.remove(dest);
|
|
4580
5346
|
}
|
|
4581
|
-
await
|
|
4582
|
-
await
|
|
5347
|
+
await fs25.ensureDir(path31.dirname(dest));
|
|
5348
|
+
await fs25.copy(src, dest, {
|
|
4583
5349
|
overwrite: true,
|
|
4584
5350
|
dereference: false,
|
|
4585
5351
|
// A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
|
|
4586
5352
|
filter: (s) => {
|
|
4587
5353
|
try {
|
|
4588
|
-
return !
|
|
5354
|
+
return !fs25.lstatSync(s).isSymbolicLink();
|
|
4589
5355
|
} catch {
|
|
4590
5356
|
return false;
|
|
4591
5357
|
}
|
|
4592
5358
|
}
|
|
4593
5359
|
});
|
|
4594
|
-
log(`${exists ? "Overwrote" : "Created"} ${
|
|
5360
|
+
log(`${exists ? "Overwrote" : "Created"} ${path31.basename(dest)} (${itemId})`);
|
|
4595
5361
|
return "scaffolded";
|
|
4596
5362
|
}
|
|
4597
5363
|
|
|
@@ -4625,13 +5391,12 @@ async function runScaffold(ids, options) {
|
|
|
4625
5391
|
log(`\u2713 Scaffolded: ${result.scaffolded.join(", ")}`);
|
|
4626
5392
|
}
|
|
4627
5393
|
}
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
}
|
|
5394
|
+
const refused = result.skipped.filter((name) => !result.skippedExisting.includes(name));
|
|
5395
|
+
if (refused.length > 0) {
|
|
5396
|
+
log(`Skipped (source unavailable): ${refused.join(", ")}`);
|
|
5397
|
+
}
|
|
5398
|
+
if (result.skippedExisting.length > 0) {
|
|
5399
|
+
log(`Already present (re-run with --force to overwrite): ${result.skippedExisting.join(", ")}`);
|
|
4635
5400
|
}
|
|
4636
5401
|
}
|
|
4637
5402
|
|
|
@@ -4645,11 +5410,12 @@ async function runScan(options) {
|
|
|
4645
5410
|
log("Haus scan complete");
|
|
4646
5411
|
log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
|
|
4647
5412
|
log(`Package manager: ${result.packageManager}`);
|
|
5413
|
+
for (const w of result.warnings) log(`- WARN: ${w}`);
|
|
4648
5414
|
}
|
|
4649
5415
|
|
|
4650
5416
|
// src/commands/undo.ts
|
|
4651
|
-
import
|
|
4652
|
-
import
|
|
5417
|
+
import path32 from "node:path";
|
|
5418
|
+
import fs26 from "fs-extra";
|
|
4653
5419
|
|
|
4654
5420
|
// src/claude/managed-paths.ts
|
|
4655
5421
|
var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md", "commands/haus-doctor.md"];
|
|
@@ -4666,74 +5432,77 @@ async function collectManagedPaths(root) {
|
|
|
4666
5432
|
const lock = await readJson(hausPath(root, "haus.lock.json"));
|
|
4667
5433
|
for (const row of lock ?? []) {
|
|
4668
5434
|
for (const rel of row.paths ?? []) {
|
|
4669
|
-
paths.add(
|
|
5435
|
+
paths.add(path32.resolve(root, rel));
|
|
4670
5436
|
}
|
|
4671
5437
|
}
|
|
4672
5438
|
const existing = [];
|
|
4673
5439
|
for (const abs of paths) {
|
|
4674
|
-
if (await
|
|
5440
|
+
if (await fs26.pathExists(abs)) existing.push(abs);
|
|
4675
5441
|
}
|
|
4676
5442
|
return existing;
|
|
4677
5443
|
}
|
|
4678
5444
|
async function settingsHasHausContent(root) {
|
|
4679
5445
|
const settingsPath = claudePath(root, "settings.json");
|
|
4680
|
-
if (!await
|
|
5446
|
+
if (!await fs26.pathExists(settingsPath)) return false;
|
|
4681
5447
|
const settings = await readProjectSettings(root);
|
|
4682
5448
|
return settings._haus != null;
|
|
4683
5449
|
}
|
|
4684
5450
|
async function claudeMdHasHausBlock(root) {
|
|
4685
|
-
const filePath =
|
|
4686
|
-
if (!await
|
|
4687
|
-
const text = await
|
|
5451
|
+
const filePath = path32.join(root, "CLAUDE.md");
|
|
5452
|
+
if (!await fs26.pathExists(filePath)) return false;
|
|
5453
|
+
const text = await fs26.readFile(filePath, "utf8");
|
|
4688
5454
|
return text.includes(BLOCK_BEGIN);
|
|
4689
5455
|
}
|
|
4690
5456
|
async function stripProjectSettings(root) {
|
|
4691
5457
|
const settingsPath = claudePath(root, "settings.json");
|
|
4692
|
-
if (!await
|
|
5458
|
+
if (!await fs26.pathExists(settingsPath)) return false;
|
|
4693
5459
|
let settings = await readProjectSettings(root);
|
|
4694
5460
|
settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
4695
5461
|
const hasContent = Object.keys(settings).length > 0;
|
|
4696
5462
|
if (hasContent) {
|
|
4697
5463
|
await writeProjectSettings(root, settings);
|
|
4698
|
-
log(`Stripped haus rules from ${
|
|
5464
|
+
log(`Stripped haus rules from ${path32.relative(root, settingsPath)} (user settings preserved).`);
|
|
4699
5465
|
return true;
|
|
4700
5466
|
}
|
|
4701
|
-
await
|
|
4702
|
-
log(`Removed ${
|
|
5467
|
+
await fs26.remove(settingsPath);
|
|
5468
|
+
log(`Removed ${path32.relative(root, settingsPath)} (no user-owned settings remained).`);
|
|
4703
5469
|
return true;
|
|
4704
5470
|
}
|
|
4705
5471
|
async function stripRootClaudeMd(root) {
|
|
4706
|
-
const filePath =
|
|
4707
|
-
if (!await
|
|
4708
|
-
const prev = await
|
|
5472
|
+
const filePath = path32.join(root, "CLAUDE.md");
|
|
5473
|
+
if (!await fs26.pathExists(filePath)) return false;
|
|
5474
|
+
const prev = await fs26.readFile(filePath, "utf8");
|
|
4709
5475
|
if (!prev.includes(BLOCK_BEGIN)) return false;
|
|
4710
5476
|
const next = stripHausBlock(prev);
|
|
4711
5477
|
if (next.length === 0) {
|
|
4712
|
-
await
|
|
5478
|
+
await fs26.remove(filePath);
|
|
4713
5479
|
log("Removed CLAUDE.md (only contained haus import block).");
|
|
4714
5480
|
} else {
|
|
4715
|
-
await
|
|
5481
|
+
await fs26.writeFile(filePath, next, "utf8");
|
|
4716
5482
|
log("Removed haus import block from CLAUDE.md (user content preserved).");
|
|
4717
5483
|
}
|
|
4718
5484
|
return true;
|
|
4719
5485
|
}
|
|
4720
5486
|
async function pruneDirIfEmpty(dir) {
|
|
4721
|
-
if (!await
|
|
4722
|
-
|
|
4723
|
-
|
|
5487
|
+
if (!await fs26.pathExists(dir)) return;
|
|
5488
|
+
try {
|
|
5489
|
+
const entries = await fs26.readdir(dir);
|
|
5490
|
+
if (entries.length === 0) await fs26.remove(dir);
|
|
5491
|
+
} catch {
|
|
5492
|
+
}
|
|
4724
5493
|
}
|
|
4725
5494
|
async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
|
|
4726
5495
|
if (managedAbsPaths.length === 0) return;
|
|
4727
5496
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
4728
5497
|
const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
|
|
4729
5498
|
for (const abs of managedAbsPaths) {
|
|
4730
|
-
if (!await
|
|
4731
|
-
const rel =
|
|
4732
|
-
const backupPath =
|
|
4733
|
-
await
|
|
4734
|
-
await
|
|
5499
|
+
if (!await fs26.pathExists(abs)) continue;
|
|
5500
|
+
const rel = path32.relative(root, abs);
|
|
5501
|
+
const backupPath = path32.join(backupRoot, rel);
|
|
5502
|
+
await fs26.ensureDir(path32.dirname(backupPath));
|
|
5503
|
+
await fs26.copy(abs, backupPath);
|
|
4735
5504
|
}
|
|
4736
|
-
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${
|
|
5505
|
+
log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path32.relative(root, backupRoot)}.`);
|
|
4737
5506
|
}
|
|
4738
5507
|
async function runUndo(options) {
|
|
4739
5508
|
const root = process.cwd();
|
|
@@ -4744,7 +5513,7 @@ async function runUndo(options) {
|
|
|
4744
5513
|
log("Nothing to remove: no haus-managed files found in this directory.");
|
|
4745
5514
|
return;
|
|
4746
5515
|
}
|
|
4747
|
-
const relTargets = managed.map((p) =>
|
|
5516
|
+
const relTargets = managed.map((p) => path32.relative(root, p));
|
|
4748
5517
|
const summaryParts = [...relTargets];
|
|
4749
5518
|
if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
|
|
4750
5519
|
if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
|
|
@@ -4761,9 +5530,9 @@ User-owned .claude/ files will be preserved.`
|
|
|
4761
5530
|
}
|
|
4762
5531
|
await backupManagedFilesBeforeUndo(root, managed);
|
|
4763
5532
|
for (const abs of managed) {
|
|
4764
|
-
if (!await
|
|
4765
|
-
await
|
|
4766
|
-
log(`Removed ${
|
|
5533
|
+
if (!await fs26.pathExists(abs)) continue;
|
|
5534
|
+
await fs26.remove(abs);
|
|
5535
|
+
log(`Removed ${path32.relative(root, abs)}`);
|
|
4767
5536
|
}
|
|
4768
5537
|
if (stripSettings) await stripProjectSettings(root);
|
|
4769
5538
|
if (stripClaudeMd) await stripRootClaudeMd(root);
|
|
@@ -4774,8 +5543,8 @@ User-owned .claude/ files will be preserved.`
|
|
|
4774
5543
|
|
|
4775
5544
|
// src/install/uninstall.ts
|
|
4776
5545
|
import crypto3 from "node:crypto";
|
|
4777
|
-
import
|
|
4778
|
-
import
|
|
5546
|
+
import path33 from "node:path";
|
|
5547
|
+
import fs27 from "fs-extra";
|
|
4779
5548
|
async function runUninstall(options = {}) {
|
|
4780
5549
|
const { force = false } = options;
|
|
4781
5550
|
const manifest = await readManifest();
|
|
@@ -4785,7 +5554,7 @@ async function runUninstall(options = {}) {
|
|
|
4785
5554
|
return result;
|
|
4786
5555
|
}
|
|
4787
5556
|
for (const entry of manifest.files) {
|
|
4788
|
-
const exists =
|
|
5557
|
+
const exists = fs27.pathExistsSync(entry.destPath);
|
|
4789
5558
|
if (!exists) continue;
|
|
4790
5559
|
const content2 = await readText(entry.destPath);
|
|
4791
5560
|
if (content2 === void 0) continue;
|
|
@@ -4803,22 +5572,25 @@ async function runUninstall(options = {}) {
|
|
|
4803
5572
|
result.skipped.push(entry.destPath);
|
|
4804
5573
|
continue;
|
|
4805
5574
|
}
|
|
4806
|
-
await
|
|
4807
|
-
await pruneEmptyDir(
|
|
5575
|
+
await fs27.remove(entry.destPath);
|
|
5576
|
+
await pruneEmptyDir(path33.dirname(entry.destPath));
|
|
4808
5577
|
result.deleted.push(entry.destPath);
|
|
4809
5578
|
}
|
|
4810
5579
|
const settings = await readSettings();
|
|
4811
5580
|
const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
|
|
4812
5581
|
await writeSettings(stripped);
|
|
4813
5582
|
result.hooksStripped = true;
|
|
4814
|
-
const hausDir =
|
|
5583
|
+
const hausDir = path33.join(globalClaudeDir(), "haus");
|
|
4815
5584
|
const manifestPath2 = hausManifestPath();
|
|
4816
|
-
if (
|
|
4817
|
-
await
|
|
5585
|
+
if (fs27.pathExistsSync(manifestPath2)) {
|
|
5586
|
+
await fs27.remove(manifestPath2);
|
|
4818
5587
|
}
|
|
4819
|
-
if (
|
|
4820
|
-
|
|
4821
|
-
|
|
5588
|
+
if (fs27.pathExistsSync(hausDir)) {
|
|
5589
|
+
try {
|
|
5590
|
+
const remaining = await fs27.readdir(hausDir);
|
|
5591
|
+
if (remaining.length === 0) await fs27.remove(hausDir);
|
|
5592
|
+
} catch {
|
|
5593
|
+
}
|
|
4822
5594
|
}
|
|
4823
5595
|
return result;
|
|
4824
5596
|
}
|
|
@@ -4849,12 +5621,12 @@ async function runUninstallCommand(options) {
|
|
|
4849
5621
|
}
|
|
4850
5622
|
|
|
4851
5623
|
// src/commands/update.ts
|
|
4852
|
-
import
|
|
5624
|
+
import path35 from "node:path";
|
|
4853
5625
|
|
|
4854
5626
|
// src/claude/refresh-project.ts
|
|
4855
|
-
import
|
|
5627
|
+
import fs28 from "fs-extra";
|
|
4856
5628
|
async function isHausProject(root) {
|
|
4857
|
-
if (await
|
|
5629
|
+
if (await fs28.pathExists(hausPath(root, "recommendation.json"))) return true;
|
|
4858
5630
|
const result = await readJsonDetailed(claudePath(root, "settings.json"));
|
|
4859
5631
|
if (result.status === "ok") return result.value._haus != null;
|
|
4860
5632
|
return false;
|
|
@@ -4889,7 +5661,7 @@ function summarizeLockDiff(before, after) {
|
|
|
4889
5661
|
|
|
4890
5662
|
// src/update/lockfile.ts
|
|
4891
5663
|
import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
|
|
4892
|
-
import
|
|
5664
|
+
import path34 from "node:path";
|
|
4893
5665
|
async function checkLock(root) {
|
|
4894
5666
|
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
4895
5667
|
const hasValidVersions = lock.every(
|
|
@@ -4920,7 +5692,7 @@ async function applyLock(root) {
|
|
|
4920
5692
|
try {
|
|
4921
5693
|
const backupDir = hausPath(root, "backups");
|
|
4922
5694
|
await mkdir(backupDir, { recursive: true });
|
|
4923
|
-
await copyFile(lockPath,
|
|
5695
|
+
await copyFile(lockPath, path34.join(backupDir, `haus.lock.${Date.now()}.json`));
|
|
4924
5696
|
} catch {
|
|
4925
5697
|
}
|
|
4926
5698
|
const enriched = await Promise.all(
|
|
@@ -4942,7 +5714,7 @@ function diffLock(before, after) {
|
|
|
4942
5714
|
}
|
|
4943
5715
|
async function hasLocalOverrides(root) {
|
|
4944
5716
|
try {
|
|
4945
|
-
await readFile2(
|
|
5717
|
+
await readFile2(path34.join(root, ".claude", "settings.json"), "utf8");
|
|
4946
5718
|
return true;
|
|
4947
5719
|
} catch {
|
|
4948
5720
|
return false;
|
|
@@ -4954,7 +5726,7 @@ var NPM_PACKAGE_NAME2 = "@haus-tech/haus-workflow";
|
|
|
4954
5726
|
async function runUpdate(options) {
|
|
4955
5727
|
const root = process.cwd();
|
|
4956
5728
|
if (options.check) {
|
|
4957
|
-
const pkgJson2 = await readJson(
|
|
5729
|
+
const pkgJson2 = await readJson(path35.join(packageRoot(), "package.json"));
|
|
4958
5730
|
const currentVersion2 = pkgJson2?.version ?? "0.0.0";
|
|
4959
5731
|
const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
|
|
4960
5732
|
checkLock(root),
|
|
@@ -4980,11 +5752,10 @@ async function runUpdate(options) {
|
|
|
4980
5752
|
2
|
|
4981
5753
|
)
|
|
4982
5754
|
);
|
|
4983
|
-
if (!status.ok) process.exitCode = 1;
|
|
4984
|
-
if (status.driftCount > 0) process.exitCode = 1;
|
|
5755
|
+
if (status.count > 0 && !status.ok) process.exitCode = 1;
|
|
4985
5756
|
return;
|
|
4986
5757
|
}
|
|
4987
|
-
const pkgJson = await readJson(
|
|
5758
|
+
const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
|
|
4988
5759
|
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
4989
5760
|
const npmStatus = await fetchNpmVersionStatus(currentVersion);
|
|
4990
5761
|
if (npmStatus.updateAvailable && npmStatus.latest !== null) {
|
|
@@ -5042,9 +5813,9 @@ async function refreshGlobalInstall() {
|
|
|
5042
5813
|
} else {
|
|
5043
5814
|
log("~/.claude already up to date.");
|
|
5044
5815
|
}
|
|
5045
|
-
if (result.
|
|
5816
|
+
if (result.userEdited.length > 0) {
|
|
5046
5817
|
log(
|
|
5047
|
-
`Preserved ${result.
|
|
5818
|
+
`Preserved ${result.userEdited.length} locally-edited file(s) (run \`haus install --force\` to overwrite).`
|
|
5048
5819
|
);
|
|
5049
5820
|
}
|
|
5050
5821
|
} catch (err) {
|
|
@@ -5061,16 +5832,16 @@ async function detectGlobalInstallDrift() {
|
|
|
5061
5832
|
}
|
|
5062
5833
|
|
|
5063
5834
|
// src/commands/validate-catalog.ts
|
|
5064
|
-
import
|
|
5835
|
+
import path37 from "node:path";
|
|
5065
5836
|
|
|
5066
5837
|
// src/catalog/validate-core.ts
|
|
5067
|
-
import
|
|
5068
|
-
import
|
|
5838
|
+
import fs29 from "node:fs";
|
|
5839
|
+
import path36 from "node:path";
|
|
5069
5840
|
var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
5070
5841
|
var MANIFEST_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/;
|
|
5071
5842
|
function isSafeCatalogPath2(itemPath) {
|
|
5072
|
-
if (!itemPath ||
|
|
5073
|
-
const normalized =
|
|
5843
|
+
if (!itemPath || path36.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
|
|
5844
|
+
const normalized = path36.normalize(itemPath);
|
|
5074
5845
|
return !normalized.startsWith("..") && !normalized.includes("/..");
|
|
5075
5846
|
}
|
|
5076
5847
|
function auditForbiddenStacks(items) {
|
|
@@ -5181,10 +5952,19 @@ function checkRequiredFrontmatter(text, label) {
|
|
|
5181
5952
|
}
|
|
5182
5953
|
return failures;
|
|
5183
5954
|
}
|
|
5955
|
+
function readForAudit(absPath, label, failures) {
|
|
5956
|
+
try {
|
|
5957
|
+
return fs29.readFileSync(absPath, "utf8");
|
|
5958
|
+
} catch (err) {
|
|
5959
|
+
failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
|
|
5960
|
+
return null;
|
|
5961
|
+
}
|
|
5962
|
+
}
|
|
5184
5963
|
function auditTemplateContent(manifestDir, absPath, itemId) {
|
|
5185
|
-
const rel =
|
|
5186
|
-
const text = fs22.readFileSync(absPath, "utf8");
|
|
5964
|
+
const rel = path36.relative(manifestDir, absPath);
|
|
5187
5965
|
const failures = [];
|
|
5966
|
+
const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
|
|
5967
|
+
if (text === null) return failures;
|
|
5188
5968
|
const lines = text.split(/\r?\n/);
|
|
5189
5969
|
for (let i = 0; i < lines.length; i++) {
|
|
5190
5970
|
const line2 = lines[i] ?? "";
|
|
@@ -5203,44 +5983,47 @@ function auditShippedFiles(manifestDir, items) {
|
|
|
5203
5983
|
const failures = [];
|
|
5204
5984
|
for (const item of items) {
|
|
5205
5985
|
if (!item.path || !isSafeCatalogPath2(item.path)) continue;
|
|
5206
|
-
const absPath =
|
|
5986
|
+
const absPath = path36.join(manifestDir, item.path);
|
|
5207
5987
|
if (item.type === "skill") {
|
|
5208
|
-
const skillMd =
|
|
5209
|
-
if (!
|
|
5210
|
-
failures.push(`${item.id}: missing ${
|
|
5988
|
+
const skillMd = path36.join(absPath, "SKILL.md");
|
|
5989
|
+
if (!fs29.existsSync(skillMd)) {
|
|
5990
|
+
failures.push(`${item.id}: missing ${path36.relative(manifestDir, skillMd)}`);
|
|
5211
5991
|
continue;
|
|
5212
5992
|
}
|
|
5213
|
-
const text =
|
|
5993
|
+
const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
|
|
5994
|
+
if (text === null) continue;
|
|
5214
5995
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
|
|
5215
5996
|
failures.push(
|
|
5216
|
-
...auditForbiddenTagsInText(text, `${item.id}: ${
|
|
5997
|
+
...auditForbiddenTagsInText(text, `${item.id}: ${path36.relative(manifestDir, skillMd)}`)
|
|
5217
5998
|
);
|
|
5218
5999
|
} else if (item.type === "agent") {
|
|
5219
|
-
if (!
|
|
6000
|
+
if (!fs29.existsSync(absPath)) {
|
|
5220
6001
|
failures.push(`${item.id}: missing agent file ${item.path}`);
|
|
5221
6002
|
continue;
|
|
5222
6003
|
}
|
|
5223
|
-
const
|
|
5224
|
-
const
|
|
6004
|
+
const rel = path36.relative(manifestDir, absPath);
|
|
6005
|
+
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
6006
|
+
if (text === null) continue;
|
|
5225
6007
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
5226
6008
|
failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
|
|
5227
6009
|
} else if (item.type === "template") {
|
|
5228
|
-
if (!
|
|
6010
|
+
if (!fs29.existsSync(absPath)) {
|
|
5229
6011
|
failures.push(`${item.id}: missing template file ${item.path}`);
|
|
5230
6012
|
continue;
|
|
5231
6013
|
}
|
|
5232
6014
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
5233
6015
|
} else if (item.type === "command") {
|
|
5234
|
-
if (!
|
|
6016
|
+
if (!fs29.existsSync(absPath)) {
|
|
5235
6017
|
failures.push(`${item.id}: missing command file ${item.path}`);
|
|
5236
6018
|
continue;
|
|
5237
6019
|
}
|
|
5238
|
-
const
|
|
5239
|
-
const
|
|
6020
|
+
const rel = path36.relative(manifestDir, absPath);
|
|
6021
|
+
const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
|
|
6022
|
+
if (text === null) continue;
|
|
5240
6023
|
failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
|
|
5241
6024
|
failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
|
|
5242
6025
|
} else if (item.type === "config") {
|
|
5243
|
-
if (!
|
|
6026
|
+
if (!fs29.existsSync(absPath)) {
|
|
5244
6027
|
failures.push(`${item.id}: missing config path ${item.path}`);
|
|
5245
6028
|
}
|
|
5246
6029
|
}
|
|
@@ -5253,10 +6036,19 @@ var DIR_ITEM_TYPE = {
|
|
|
5253
6036
|
templates: "template",
|
|
5254
6037
|
commands: "command"
|
|
5255
6038
|
};
|
|
5256
|
-
function walkMd(dir, fn) {
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
6039
|
+
function walkMd(dir, fn, failures) {
|
|
6040
|
+
let entries;
|
|
6041
|
+
try {
|
|
6042
|
+
entries = fs29.readdirSync(dir, { withFileTypes: true });
|
|
6043
|
+
} catch (err) {
|
|
6044
|
+
failures.push(
|
|
6045
|
+
`${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
|
|
6046
|
+
);
|
|
6047
|
+
return;
|
|
6048
|
+
}
|
|
6049
|
+
for (const entry of entries) {
|
|
6050
|
+
const full = path36.join(dir, entry.name);
|
|
6051
|
+
if (entry.isDirectory()) walkMd(full, fn, failures);
|
|
5260
6052
|
else if (entry.name.endsWith(".md")) fn(full);
|
|
5261
6053
|
}
|
|
5262
6054
|
}
|
|
@@ -5264,26 +6056,31 @@ function auditMarkdownContent(manifestDir, items) {
|
|
|
5264
6056
|
const failures = [];
|
|
5265
6057
|
const pathSourceMap = buildItemPathSourceMap(items);
|
|
5266
6058
|
for (const dir of ["skills", "agents", "templates", "commands"]) {
|
|
5267
|
-
const abs =
|
|
5268
|
-
if (!
|
|
6059
|
+
const abs = path36.join(manifestDir, dir);
|
|
6060
|
+
if (!fs29.existsSync(abs)) continue;
|
|
5269
6061
|
const dirType = DIR_ITEM_TYPE[dir] ?? "";
|
|
5270
|
-
walkMd(
|
|
5271
|
-
|
|
5272
|
-
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
const
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
6062
|
+
walkMd(
|
|
6063
|
+
abs,
|
|
6064
|
+
(file) => {
|
|
6065
|
+
const rel = path36.relative(manifestDir, file);
|
|
6066
|
+
const text = readForAudit(file, rel, failures);
|
|
6067
|
+
if (text === null) return;
|
|
6068
|
+
const norm = rel.replace(/\\/g, "/");
|
|
6069
|
+
const source = resolveMarkdownItemSource(norm, pathSourceMap);
|
|
6070
|
+
const checkNonTsxNpx = !isNpxTsxOnlyExempt(dirType, source);
|
|
6071
|
+
const lines = text.split(/\r?\n/);
|
|
6072
|
+
for (let i = 0; i < lines.length; i++) {
|
|
6073
|
+
const line2 = lines[i] ?? "";
|
|
6074
|
+
if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2)))
|
|
6075
|
+
failures.push(`${rel}:${i + 1}: risky install pattern`);
|
|
6076
|
+
if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
|
|
6077
|
+
failures.push(`${rel}:${i + 1}: disallowed npx (only npx tsx allowed)`);
|
|
6078
|
+
}
|
|
5283
6079
|
}
|
|
5284
|
-
|
|
5285
|
-
|
|
5286
|
-
|
|
6080
|
+
if (!norm.includes("/references/")) failures.push(...auditForbiddenTagsInText(text, rel));
|
|
6081
|
+
},
|
|
6082
|
+
failures
|
|
6083
|
+
);
|
|
5287
6084
|
}
|
|
5288
6085
|
return failures;
|
|
5289
6086
|
}
|
|
@@ -5306,8 +6103,8 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
5306
6103
|
process.exitCode = 1;
|
|
5307
6104
|
return;
|
|
5308
6105
|
}
|
|
5309
|
-
const abs =
|
|
5310
|
-
const manifestDir =
|
|
6106
|
+
const abs = path37.resolve(process.cwd(), manifestPath2);
|
|
6107
|
+
const manifestDir = path37.dirname(abs);
|
|
5311
6108
|
const data = await readJson(abs);
|
|
5312
6109
|
if (!data?.items) {
|
|
5313
6110
|
error(`Could not read catalog manifest at ${abs}`);
|
|
@@ -5325,7 +6122,7 @@ async function runValidateCatalog(manifestPath2) {
|
|
|
5325
6122
|
|
|
5326
6123
|
// src/commands/workspace.ts
|
|
5327
6124
|
import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
|
|
5328
|
-
import
|
|
6125
|
+
import path44 from "node:path";
|
|
5329
6126
|
|
|
5330
6127
|
// src/commands/workspace/aggregate.ts
|
|
5331
6128
|
async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
|
|
@@ -5379,7 +6176,7 @@ ${summaries.map(
|
|
|
5379
6176
|
}
|
|
5380
6177
|
|
|
5381
6178
|
// src/commands/workspace/config.ts
|
|
5382
|
-
import
|
|
6179
|
+
import path38 from "node:path";
|
|
5383
6180
|
import YAML from "yaml";
|
|
5384
6181
|
var WORKSPACE_FILE = "haus.workspace.yaml";
|
|
5385
6182
|
function parseWorkspaceConfig(text) {
|
|
@@ -5402,11 +6199,11 @@ function parseWorkspaceConfig(text) {
|
|
|
5402
6199
|
};
|
|
5403
6200
|
}
|
|
5404
6201
|
async function readWorkspaceConfig(workspaceRoot) {
|
|
5405
|
-
return parseWorkspaceConfig(await readText(
|
|
6202
|
+
return parseWorkspaceConfig(await readText(path38.join(workspaceRoot, WORKSPACE_FILE)));
|
|
5406
6203
|
}
|
|
5407
6204
|
|
|
5408
6205
|
// src/commands/workspace/discover.ts
|
|
5409
|
-
import
|
|
6206
|
+
import path39 from "node:path";
|
|
5410
6207
|
import fg4 from "fast-glob";
|
|
5411
6208
|
import YAML2 from "yaml";
|
|
5412
6209
|
var DEFAULT_MAX_DEPTH = 3;
|
|
@@ -5429,13 +6226,16 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
5429
6226
|
onlyFiles: false,
|
|
5430
6227
|
deep: maxDepth,
|
|
5431
6228
|
followSymbolicLinks: false,
|
|
6229
|
+
// Discovery walks arbitrary directories under the workspace root; an
|
|
6230
|
+
// unreadable subtree (EPERM/EACCES) must be skipped, not abort the whole scan.
|
|
6231
|
+
suppressErrors: true,
|
|
5432
6232
|
ignore: IGNORE
|
|
5433
6233
|
});
|
|
5434
6234
|
const gitDirs = /* @__PURE__ */ new Set();
|
|
5435
6235
|
const manifestDirs = /* @__PURE__ */ new Set();
|
|
5436
6236
|
for (const match of matches) {
|
|
5437
|
-
const base =
|
|
5438
|
-
const dir =
|
|
6237
|
+
const base = path39.posix.basename(match);
|
|
6238
|
+
const dir = path39.posix.dirname(match);
|
|
5439
6239
|
const owner = dir === "." ? "." : dir;
|
|
5440
6240
|
if (base === ".git") gitDirs.add(owner);
|
|
5441
6241
|
else manifestDirs.add(owner);
|
|
@@ -5451,9 +6251,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
|
|
|
5451
6251
|
}
|
|
5452
6252
|
repoRoots.sort((a, b) => a.localeCompare(b));
|
|
5453
6253
|
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 :
|
|
6254
|
+
const absDir = path39.resolve(workspaceRoot, relDir);
|
|
6255
|
+
const pkg = await readJson(path39.join(absDir, "package.json"));
|
|
6256
|
+
const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path39.basename(relDir === "." ? workspaceRoot : absDir);
|
|
5457
6257
|
let role = "auto";
|
|
5458
6258
|
try {
|
|
5459
6259
|
const scan = await scanProject(absDir);
|
|
@@ -5496,7 +6296,7 @@ function renderWorkspaceYaml(config) {
|
|
|
5496
6296
|
});
|
|
5497
6297
|
}
|
|
5498
6298
|
async function runDiscover(workspaceRoot, opts = {}) {
|
|
5499
|
-
const yamlPath =
|
|
6299
|
+
const yamlPath = path39.join(workspaceRoot, "haus.workspace.yaml");
|
|
5500
6300
|
const existingText = await readText(yamlPath);
|
|
5501
6301
|
const existing = parseWorkspaceConfig(existingText);
|
|
5502
6302
|
if (existingText && !existing) {
|
|
@@ -5514,34 +6314,37 @@ async function runDiscover(workspaceRoot, opts = {}) {
|
|
|
5514
6314
|
}
|
|
5515
6315
|
const merged = mergeWorkspaceConfig(existing, discovered, { client: opts.client });
|
|
5516
6316
|
const yamlText = renderWorkspaceYaml(merged);
|
|
5517
|
-
if (opts.json) {
|
|
5518
|
-
log(JSON.stringify({ discovered, config: merged }, null, 2));
|
|
5519
|
-
}
|
|
5520
6317
|
if (opts.write) {
|
|
5521
6318
|
await writeText(yamlPath, yamlText);
|
|
5522
|
-
|
|
6319
|
+
if (opts.json) {
|
|
6320
|
+
log(JSON.stringify({ discovered, config: merged, wrote: yamlPath }, null, 2));
|
|
6321
|
+
} else {
|
|
6322
|
+
log(`Wrote ${merged.repos.length} repo(s) to haus.workspace.yaml`);
|
|
6323
|
+
}
|
|
5523
6324
|
return;
|
|
5524
6325
|
}
|
|
5525
|
-
if (
|
|
5526
|
-
log(
|
|
5527
|
-
|
|
6326
|
+
if (opts.json) {
|
|
6327
|
+
log(JSON.stringify({ discovered, config: merged }, null, 2));
|
|
6328
|
+
return;
|
|
5528
6329
|
}
|
|
6330
|
+
log("Proposed haus.workspace.yaml (run with --write to persist):\n");
|
|
6331
|
+
log(yamlText);
|
|
5529
6332
|
}
|
|
5530
6333
|
|
|
5531
6334
|
// src/commands/workspace/doctor.ts
|
|
5532
6335
|
import { existsSync as existsSync3 } from "node:fs";
|
|
5533
|
-
import
|
|
6336
|
+
import path41 from "node:path";
|
|
5534
6337
|
|
|
5535
6338
|
// src/commands/workspace/manifest.ts
|
|
5536
6339
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
5537
|
-
import
|
|
6340
|
+
import path40 from "node:path";
|
|
5538
6341
|
var MANIFEST_FILE = "workspace.manifest.json";
|
|
5539
6342
|
function manifestPath(workspaceRoot) {
|
|
5540
6343
|
return hausPath(workspaceRoot, MANIFEST_FILE);
|
|
5541
6344
|
}
|
|
5542
6345
|
function hausVersion() {
|
|
5543
6346
|
try {
|
|
5544
|
-
const pkg = JSON.parse(readFileSync3(
|
|
6347
|
+
const pkg = JSON.parse(readFileSync3(path40.join(packageRoot(), "package.json"), "utf8"));
|
|
5545
6348
|
return pkg.version ?? "0.0.0";
|
|
5546
6349
|
} catch {
|
|
5547
6350
|
return "0.0.0";
|
|
@@ -5607,7 +6410,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
|
|
|
5607
6410
|
}
|
|
5608
6411
|
const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
|
|
5609
6412
|
for (const repo of config.repos) {
|
|
5610
|
-
const repoRoot =
|
|
6413
|
+
const repoRoot = path41.resolve(workspaceRoot, repo.path);
|
|
5611
6414
|
const entry = manifestByName.get(repo.name);
|
|
5612
6415
|
if (!entry) {
|
|
5613
6416
|
flag({
|
|
@@ -5681,11 +6484,11 @@ function emit(args) {
|
|
|
5681
6484
|
|
|
5682
6485
|
// src/commands/workspace/setup.ts
|
|
5683
6486
|
import { existsSync as existsSync4, statSync } from "node:fs";
|
|
5684
|
-
import
|
|
6487
|
+
import path43 from "node:path";
|
|
5685
6488
|
|
|
5686
6489
|
// src/claude/write-workspace-claude-md.ts
|
|
5687
|
-
import
|
|
5688
|
-
import
|
|
6490
|
+
import path42 from "node:path";
|
|
6491
|
+
import fs30 from "fs-extra";
|
|
5689
6492
|
function buildWorkspaceImportBlock(client, members) {
|
|
5690
6493
|
const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
|
|
5691
6494
|
const body = [
|
|
@@ -5703,25 +6506,27 @@ ${BLOCK_END}`;
|
|
|
5703
6506
|
async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
5704
6507
|
const block = buildWorkspaceImportBlock(opts.client, opts.members);
|
|
5705
6508
|
const dryRun = opts.dryRun ?? false;
|
|
5706
|
-
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") :
|
|
5707
|
-
const
|
|
6509
|
+
const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path42.join(workspaceRoot, "CLAUDE.md");
|
|
6510
|
+
const say = opts.quiet ? () => {
|
|
6511
|
+
} : log;
|
|
6512
|
+
const prev = await fs30.pathExists(filePath) ? await fs30.readFile(filePath, "utf8") : "";
|
|
5708
6513
|
const next = opts.collision ? `${block}
|
|
5709
6514
|
` : injectHausBlock(prev, block);
|
|
5710
6515
|
const printable = displayPath(workspaceRoot, filePath);
|
|
5711
6516
|
if (dryRun) {
|
|
5712
6517
|
if (!prev) {
|
|
5713
|
-
|
|
6518
|
+
say(createUnifiedDiff(printable, "", next));
|
|
5714
6519
|
} else if (hasTextChanged(prev, next)) {
|
|
5715
|
-
|
|
6520
|
+
say(createUnifiedDiff(printable, prev, next));
|
|
5716
6521
|
} else {
|
|
5717
|
-
|
|
6522
|
+
say(`${printable}: unchanged`);
|
|
5718
6523
|
}
|
|
5719
6524
|
return filePath;
|
|
5720
6525
|
}
|
|
5721
6526
|
if (hasTextChanged(prev, next) && prev.length > 0) {
|
|
5722
6527
|
const diffText = createUnifiedDiff(printable, prev, next);
|
|
5723
6528
|
const summary = summarizeDiff(diffText);
|
|
5724
|
-
|
|
6529
|
+
say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
5725
6530
|
}
|
|
5726
6531
|
await writeText(filePath, next);
|
|
5727
6532
|
return filePath;
|
|
@@ -5729,20 +6534,20 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
|
|
|
5729
6534
|
|
|
5730
6535
|
// src/commands/workspace/setup.ts
|
|
5731
6536
|
function resolveWorkspaceRoot(start = process.cwd()) {
|
|
5732
|
-
let dir =
|
|
6537
|
+
let dir = path43.resolve(start);
|
|
5733
6538
|
for (; ; ) {
|
|
5734
|
-
if (existsSync4(
|
|
5735
|
-
const parent =
|
|
5736
|
-
if (parent === dir) return
|
|
6539
|
+
if (existsSync4(path43.join(dir, WORKSPACE_FILE))) return dir;
|
|
6540
|
+
const parent = path43.dirname(dir);
|
|
6541
|
+
if (parent === dir) return path43.resolve(start);
|
|
5737
6542
|
dir = parent;
|
|
5738
6543
|
}
|
|
5739
6544
|
}
|
|
5740
6545
|
function isRootRepo(workspaceRoot, repoPath) {
|
|
5741
|
-
return
|
|
6546
|
+
return path43.resolve(workspaceRoot, repoPath) === path43.resolve(workspaceRoot);
|
|
5742
6547
|
}
|
|
5743
6548
|
async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
5744
6549
|
const apply = options.write ?? false;
|
|
5745
|
-
const configText = await readText(
|
|
6550
|
+
const configText = await readText(path43.join(workspaceRoot, WORKSPACE_FILE));
|
|
5746
6551
|
if (!configText) {
|
|
5747
6552
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
5748
6553
|
process.exitCode = 1;
|
|
@@ -5754,22 +6559,34 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5754
6559
|
process.exitCode = 1;
|
|
5755
6560
|
return { workspaceRoot, statuses: [], written: [] };
|
|
5756
6561
|
}
|
|
6562
|
+
const say = options.json ? () => {
|
|
6563
|
+
} : log;
|
|
5757
6564
|
const onlySet = options.only && options.only.length > 0 ? new Set(options.only) : void 0;
|
|
6565
|
+
if (onlySet) {
|
|
6566
|
+
const unknown = [...onlySet].filter((name) => !config.repos.some((r) => r.name === name));
|
|
6567
|
+
if (unknown.length > 0) {
|
|
6568
|
+
error(`--only matched no configured repo(s): ${unknown.join(", ")}`);
|
|
6569
|
+
process.exitCode = 1;
|
|
6570
|
+
return { workspaceRoot, statuses: [], written: [] };
|
|
6571
|
+
}
|
|
6572
|
+
}
|
|
5758
6573
|
const repos = onlySet ? config.repos.filter((r) => onlySet.has(r.name)) : config.repos;
|
|
5759
6574
|
const statuses = [];
|
|
5760
6575
|
const aggregateInputs = [];
|
|
5761
6576
|
for (const repo of repos) {
|
|
5762
|
-
const repoRoot =
|
|
5763
|
-
|
|
6577
|
+
const repoRoot = path43.resolve(workspaceRoot, repo.path);
|
|
6578
|
+
say(`
|
|
5764
6579
|
\u2192 ${repo.name} (${repo.path})`);
|
|
5765
6580
|
try {
|
|
5766
6581
|
if (!existsSync4(repoRoot) || !statSync(repoRoot).isDirectory()) {
|
|
5767
6582
|
throw new Error(`Repo path is not a directory: ${repo.path}`);
|
|
5768
6583
|
}
|
|
5769
6584
|
const res = await runSetupCore(repoRoot, {
|
|
5770
|
-
json: options.json,
|
|
5771
6585
|
apply,
|
|
5772
|
-
dryRun: options.dryRun
|
|
6586
|
+
dryRun: options.dryRun,
|
|
6587
|
+
// Under --json, mute the inner pipeline entirely; we serialize the
|
|
6588
|
+
// aggregated result below. Otherwise let it print its normal human output.
|
|
6589
|
+
quiet: options.json
|
|
5773
6590
|
});
|
|
5774
6591
|
statuses.push({
|
|
5775
6592
|
name: repo.name,
|
|
@@ -5809,9 +6626,10 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5809
6626
|
client: config.client,
|
|
5810
6627
|
members: config.repos.map((r) => ({ name: r.name, path: r.path })),
|
|
5811
6628
|
collision,
|
|
5812
|
-
dryRun: options.dryRun
|
|
6629
|
+
dryRun: options.dryRun,
|
|
6630
|
+
quiet: options.json
|
|
5813
6631
|
});
|
|
5814
|
-
written.push(docPath);
|
|
6632
|
+
if (!options.dryRun) written.push(docPath);
|
|
5815
6633
|
}
|
|
5816
6634
|
if (apply && !options.dryRun) {
|
|
5817
6635
|
const statusByName = new Map(statuses.map((s) => [s.name, s]));
|
|
@@ -5827,7 +6645,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5827
6645
|
const status = statusByName.get(repo.name);
|
|
5828
6646
|
const role = repo.role ?? status?.roles?.[0] ?? "auto";
|
|
5829
6647
|
if (status?.status === "ok") {
|
|
5830
|
-
const lock = await checkLock(
|
|
6648
|
+
const lock = await checkLock(path43.resolve(workspaceRoot, repo.path));
|
|
5831
6649
|
manifestRepos.push({
|
|
5832
6650
|
name: repo.name,
|
|
5833
6651
|
path: repo.path,
|
|
@@ -5876,9 +6694,17 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
|
|
|
5876
6694
|
}
|
|
5877
6695
|
const ok = statuses.filter((s) => s.status === "ok").length;
|
|
5878
6696
|
const failed = statuses.length - ok;
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
6697
|
+
const result = { workspaceRoot, statuses, written };
|
|
6698
|
+
if (options.json) {
|
|
6699
|
+
log(JSON.stringify(result, null, 2));
|
|
6700
|
+
} else if (!apply) {
|
|
6701
|
+
say(`
|
|
6702
|
+
Workspace preview complete: ${ok} ok, ${failed} failed. Re-run with --write to apply.`);
|
|
6703
|
+
} else {
|
|
6704
|
+
say(`
|
|
6705
|
+
Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} file(s).`);
|
|
6706
|
+
}
|
|
6707
|
+
return result;
|
|
5882
6708
|
}
|
|
5883
6709
|
|
|
5884
6710
|
// src/commands/workspace.ts
|
|
@@ -5891,7 +6717,9 @@ function normalizeOnly(only) {
|
|
|
5891
6717
|
function normalizeMaxDepth(maxDepth) {
|
|
5892
6718
|
if (maxDepth === void 0) return void 0;
|
|
5893
6719
|
const n = typeof maxDepth === "number" ? maxDepth : Number.parseInt(maxDepth, 10);
|
|
5894
|
-
|
|
6720
|
+
if (Number.isFinite(n) && n > 0) return Math.floor(n);
|
|
6721
|
+
warn(`Ignoring invalid --max-depth "${String(maxDepth)}"; using the default depth.`);
|
|
6722
|
+
return void 0;
|
|
5895
6723
|
}
|
|
5896
6724
|
async function initWorkspace() {
|
|
5897
6725
|
await writeText(
|
|
@@ -5907,7 +6735,7 @@ relationships: []
|
|
|
5907
6735
|
log("Workspace initialized.");
|
|
5908
6736
|
}
|
|
5909
6737
|
async function scanWorkspace(workspaceRoot, opts) {
|
|
5910
|
-
const configText = await readText(
|
|
6738
|
+
const configText = await readText(path44.join(workspaceRoot, WORKSPACE_FILE));
|
|
5911
6739
|
if (!configText) {
|
|
5912
6740
|
error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
|
|
5913
6741
|
process.exitCode = 1;
|
|
@@ -5927,20 +6755,31 @@ async function scanWorkspace(workspaceRoot, opts) {
|
|
|
5927
6755
|
return;
|
|
5928
6756
|
}
|
|
5929
6757
|
const inputs = [];
|
|
6758
|
+
const failures = [];
|
|
5930
6759
|
for (const repo of config.repos) {
|
|
5931
|
-
const repoRoot =
|
|
5932
|
-
|
|
5933
|
-
|
|
6760
|
+
const repoRoot = path44.resolve(workspaceRoot, repo.path);
|
|
6761
|
+
try {
|
|
6762
|
+
if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
|
|
6763
|
+
throw new Error("path is not a directory");
|
|
6764
|
+
}
|
|
6765
|
+
const result = await scanProject(repoRoot);
|
|
6766
|
+
inputs.push({ name: repo.name, path: repo.path, context: result });
|
|
6767
|
+
} catch (err) {
|
|
6768
|
+
failures.push(
|
|
6769
|
+
`${repo.name} (${repo.path}): ${err instanceof Error ? err.message : String(err)}`
|
|
6770
|
+
);
|
|
5934
6771
|
}
|
|
5935
|
-
const result = await scanProject(repoRoot);
|
|
5936
|
-
inputs.push({ name: repo.name, path: repo.path, context: result });
|
|
5937
6772
|
}
|
|
5938
6773
|
const written = await writeWorkspaceArtifacts(workspaceRoot, inputs, config.relationships);
|
|
5939
6774
|
if (opts.json) {
|
|
5940
|
-
log(JSON.stringify({ written }, null, 2));
|
|
6775
|
+
log(JSON.stringify({ written, scanned: inputs.length, failed: failures }, null, 2));
|
|
5941
6776
|
} else {
|
|
5942
|
-
log(
|
|
6777
|
+
log(
|
|
6778
|
+
`Workspace scan complete. Scanned ${inputs.length} repo(s); wrote ${written.length} artifact(s) under .haus-workflow/.`
|
|
6779
|
+
);
|
|
6780
|
+
for (const f of failures) warn(`- skipped ${f}`);
|
|
5943
6781
|
}
|
|
6782
|
+
if (failures.length > 0) process.exitCode = 1;
|
|
5944
6783
|
}
|
|
5945
6784
|
async function runWorkspace(action, options = {}) {
|
|
5946
6785
|
if (action === "init") {
|
|
@@ -5978,7 +6817,7 @@ async function runWorkspace(action, options = {}) {
|
|
|
5978
6817
|
// src/cli.ts
|
|
5979
6818
|
function cliVersion() {
|
|
5980
6819
|
try {
|
|
5981
|
-
const pkgPath =
|
|
6820
|
+
const pkgPath = path45.join(packageRoot(), "package.json");
|
|
5982
6821
|
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
5983
6822
|
return pkg.version ?? "0.0.0";
|
|
5984
6823
|
} catch {
|
|
@@ -5988,7 +6827,7 @@ function cliVersion() {
|
|
|
5988
6827
|
var program = new Command();
|
|
5989
6828
|
function validateRuntimeNodeVersion() {
|
|
5990
6829
|
try {
|
|
5991
|
-
const pkgPath =
|
|
6830
|
+
const pkgPath = path45.join(packageRoot(), "package.json");
|
|
5992
6831
|
const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
5993
6832
|
const requiredRange = pkg.engines?.node;
|
|
5994
6833
|
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
@@ -6009,6 +6848,7 @@ program.command("recommend").option("--json").option(
|
|
|
6009
6848
|
).action(runRecommend);
|
|
6010
6849
|
program.command("setup-project").option("--json").action(runSetupProject);
|
|
6011
6850
|
program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
|
|
6851
|
+
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
6852
|
program.command("apply").option("--dry-run").option("--write").option("--select", "Interactively select catalog items before applying").option(
|
|
6013
6853
|
"--ids <ids...>",
|
|
6014
6854
|
"Install exactly these recommended item ids non-interactively (skill backend; space- or comma-separated)"
|
|
@@ -6037,6 +6877,17 @@ program.command("uninstall").option("--force").action(runUninstallCommand);
|
|
|
6037
6877
|
var guard = program.command("guard");
|
|
6038
6878
|
guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
|
|
6039
6879
|
guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
|
|
6880
|
+
function registerDecisionsCommand(name) {
|
|
6881
|
+
const cmd = program.command(name).description("Architecture decision records (ADR) gate and drafts");
|
|
6882
|
+
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));
|
|
6883
|
+
cmd.command("suggest").option("--from-hook", "Emit JSON for Claude Code Stop hook").option("--title <title>", "Draft title").action(
|
|
6884
|
+
(opts) => runDecisions("suggest", void 0, opts)
|
|
6885
|
+
);
|
|
6886
|
+
cmd.command("next-number").action(() => runDecisions("next-number"));
|
|
6887
|
+
cmd.command("validate").argument("[path]", "Decision markdown file").action((filePath) => runDecisions("validate", filePath));
|
|
6888
|
+
}
|
|
6889
|
+
registerDecisionsCommand("decisions");
|
|
6890
|
+
registerDecisionsCommand("adr");
|
|
6040
6891
|
var workspace = program.command("workspace");
|
|
6041
6892
|
workspace.command("init").action(() => runWorkspace("init"));
|
|
6042
6893
|
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));
|