@haus-tech/haus-workflow 0.1.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 +1 -0
- package/README.md +60 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +3232 -0
- package/docs/user-guide.md +176 -0
- package/library/catalog/allowed-stacks.json +73 -0
- package/library/catalog/haus-lock.schema.json +43 -0
- package/library/catalog/manifest.json +696 -0
- package/library/catalog/sources.yaml +411 -0
- package/library/global/agents/haus-code-reviewer.md +27 -0
- package/library/global/agents/haus-docs-researcher.md +26 -0
- package/library/global/agents/haus-planner.md +26 -0
- package/library/global/agents/haus-security-reviewer.md +26 -0
- package/library/global/agents/haus-test-reviewer.md +26 -0
- package/library/global/settings-fragments/hooks.json +31 -0
- package/library/global/skills/haus-workflow/SKILL.md +56 -0
- package/library/global/templates/haus-way-of-work.md +40 -0
- package/package.json +88 -0
- package/tests/README.md +70 -0
- package/tests/fixtures/catalog/agents/code-reviewer.md +15 -0
- package/tests/fixtures/catalog/agents/docs-researcher.md +15 -0
- package/tests/fixtures/catalog/agents/planner.md +15 -0
- package/tests/fixtures/catalog/agents/security-reviewer.md +15 -0
- package/tests/fixtures/catalog/agents/test-reviewer.md +15 -0
- package/tests/fixtures/catalog/manifest.json +696 -0
- package/tests/fixtures/catalog/skills/auth-oidc-azure-bankid-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/database-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/dotnet-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/dotnet-service-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/global-engineering-rules/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/laravel-nova-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/laravel-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/nestjs-graphql-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/nextjs-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/nx21-monorepo-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/package-manager-yarn4-pnpm89/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/phpunit-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/playwright-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/production-readiness-review/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/radix-shadcn-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/react19-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/security-review/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/storybook-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/tailwind-scss-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/tanstack-query-router-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/testing-library-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/turbo-monorepo-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/typescript6-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/vendure-app-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/vendure-plugin-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/vite8-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/vue-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/wordpress-acf-elementor-jetengine-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/wordpress-bedrock-patterns/SKILL.md +9 -0
- package/tests/fixtures/catalog/skills/wordpress-patterns/SKILL.md +9 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
|
+
import path25 from "path";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
|
|
8
|
+
// src/claude/write-claude-files.ts
|
|
9
|
+
import path9 from "path";
|
|
10
|
+
import fs8 from "fs-extra";
|
|
11
|
+
|
|
12
|
+
// src/catalog/remote-catalog.ts
|
|
13
|
+
import os from "os";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import fs from "fs-extra";
|
|
16
|
+
|
|
17
|
+
// src/utils/logger.ts
|
|
18
|
+
var log = (msg, ...args) => {
|
|
19
|
+
console.log(msg, ...args);
|
|
20
|
+
};
|
|
21
|
+
var warn = (msg, ...args) => {
|
|
22
|
+
console.warn(msg, ...args);
|
|
23
|
+
};
|
|
24
|
+
var error = (msg, ...args) => {
|
|
25
|
+
console.error(msg, ...args);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/catalog/constants.ts
|
|
29
|
+
var CATALOG_REPO_URL = "https://raw.githubusercontent.com/wearehaustech/haus-workflow-catalog";
|
|
30
|
+
var CATALOG_REF = "main";
|
|
31
|
+
var CATALOG_CACHE_SUBDIR = ".claude/haus/catalog-cache";
|
|
32
|
+
|
|
33
|
+
// src/catalog/remote-catalog.ts
|
|
34
|
+
var CACHE_DIR = process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path.join(os.homedir(), CATALOG_CACHE_SUBDIR);
|
|
35
|
+
var REMOTE_BASE = process.env["HAUS_CATALOG_REMOTE_BASE"] ?? `${CATALOG_REPO_URL}/${CATALOG_REF}`;
|
|
36
|
+
var REMOTE_MANIFEST_URL = `${REMOTE_BASE}/manifest.json`;
|
|
37
|
+
async function fetchText(url) {
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
|
|
40
|
+
if (!res.ok) return null;
|
|
41
|
+
return await res.text();
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async function fetchRemoteManifest() {
|
|
47
|
+
const text = await fetchText(REMOTE_MANIFEST_URL);
|
|
48
|
+
if (!text) return null;
|
|
49
|
+
try {
|
|
50
|
+
const data = JSON.parse(text);
|
|
51
|
+
return data?.items?.length ? data.items : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function isSafeCatalogPath(itemPath) {
|
|
57
|
+
if (!itemPath || path.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
|
|
58
|
+
const normalized = path.normalize(itemPath);
|
|
59
|
+
return !normalized.startsWith("..") && !normalized.includes("/..");
|
|
60
|
+
}
|
|
61
|
+
function safeJoin(base, itemPath) {
|
|
62
|
+
if (!isSafeCatalogPath(itemPath)) return null;
|
|
63
|
+
const resolved = path.resolve(base, itemPath);
|
|
64
|
+
return resolved.startsWith(base + path.sep) || resolved === base ? resolved : null;
|
|
65
|
+
}
|
|
66
|
+
async function syncRemoteCatalog() {
|
|
67
|
+
const items = await fetchRemoteManifest();
|
|
68
|
+
if (!items) {
|
|
69
|
+
warn("Remote catalog fetch failed \u2014 using bundled catalog");
|
|
70
|
+
return { newItems: [], unchanged: 0, failed: [] };
|
|
71
|
+
}
|
|
72
|
+
await fs.ensureDir(CACHE_DIR);
|
|
73
|
+
await fs.writeFile(path.join(CACHE_DIR, "manifest.json"), `${JSON.stringify({ items }, null, 2)}
|
|
74
|
+
`, "utf8");
|
|
75
|
+
const newItems = [];
|
|
76
|
+
let unchanged = 0;
|
|
77
|
+
const failed = [];
|
|
78
|
+
for (const item of items) {
|
|
79
|
+
if (item.type !== "skill" && item.type !== "agent" || !item.path) continue;
|
|
80
|
+
if (!isSafeCatalogPath(item.path)) {
|
|
81
|
+
warn(`Skipping ${item.id}: invalid path "${item.path}"`);
|
|
82
|
+
failed.push(item.id);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (item.type === "skill") {
|
|
86
|
+
const destDir = safeJoin(CACHE_DIR, item.path);
|
|
87
|
+
if (!destDir) {
|
|
88
|
+
warn(`Skipping ${item.id}: path traversal detected`);
|
|
89
|
+
failed.push(item.id);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const dest = path.join(destDir, "SKILL.md");
|
|
93
|
+
if (await fs.pathExists(dest)) {
|
|
94
|
+
unchanged++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const url = `${REMOTE_BASE}/${item.path}/SKILL.md`;
|
|
98
|
+
const text = await fetchText(url);
|
|
99
|
+
if (!text) {
|
|
100
|
+
warn(`Failed to fetch content for ${item.id}`);
|
|
101
|
+
failed.push(item.id);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
await fs.ensureDir(path.dirname(dest));
|
|
105
|
+
await fs.writeFile(dest, text, "utf8");
|
|
106
|
+
newItems.push(item.id);
|
|
107
|
+
} else {
|
|
108
|
+
const dest = safeJoin(CACHE_DIR, item.path);
|
|
109
|
+
if (!dest) {
|
|
110
|
+
warn(`Skipping ${item.id}: path traversal detected`);
|
|
111
|
+
failed.push(item.id);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (await fs.pathExists(dest)) {
|
|
115
|
+
unchanged++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const url = `${REMOTE_BASE}/${item.path}`;
|
|
119
|
+
const text = await fetchText(url);
|
|
120
|
+
if (!text) {
|
|
121
|
+
warn(`Failed to fetch content for ${item.id}`);
|
|
122
|
+
failed.push(item.id);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
await fs.ensureDir(path.dirname(dest));
|
|
126
|
+
await fs.writeFile(dest, text, "utf8");
|
|
127
|
+
newItems.push(item.id);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { newItems, unchanged, failed };
|
|
131
|
+
}
|
|
132
|
+
async function getCacheManifestAge() {
|
|
133
|
+
try {
|
|
134
|
+
const stat = await fs.stat(path.join(CACHE_DIR, "manifest.json"));
|
|
135
|
+
return Date.now() - stat.mtimeMs;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/update/hash-installed.ts
|
|
142
|
+
import path3 from "path";
|
|
143
|
+
import fg2 from "fast-glob";
|
|
144
|
+
import fs3 from "fs-extra";
|
|
145
|
+
|
|
146
|
+
// src/utils/fs.ts
|
|
147
|
+
import crypto from "crypto";
|
|
148
|
+
import path2 from "path";
|
|
149
|
+
import fg from "fast-glob";
|
|
150
|
+
import fs2 from "fs-extra";
|
|
151
|
+
async function readJson(file) {
|
|
152
|
+
try {
|
|
153
|
+
return await fs2.readJson(file);
|
|
154
|
+
} catch {
|
|
155
|
+
return void 0;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async function writeJson(file, value) {
|
|
159
|
+
await fs2.ensureDir(path2.dirname(file));
|
|
160
|
+
await fs2.writeFile(file, `${JSON.stringify(value, null, 2)}
|
|
161
|
+
`, "utf8");
|
|
162
|
+
}
|
|
163
|
+
async function readText(file) {
|
|
164
|
+
try {
|
|
165
|
+
return await fs2.readFile(file, "utf8");
|
|
166
|
+
} catch {
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function writeText(file, value) {
|
|
171
|
+
await fs2.ensureDir(path2.dirname(file));
|
|
172
|
+
await fs2.writeFile(file, value, "utf8");
|
|
173
|
+
}
|
|
174
|
+
async function listFiles(root, patterns) {
|
|
175
|
+
const files = await fg(patterns, {
|
|
176
|
+
cwd: root,
|
|
177
|
+
dot: true,
|
|
178
|
+
onlyFiles: true,
|
|
179
|
+
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**"]
|
|
180
|
+
});
|
|
181
|
+
return files.sort((a, b) => a.localeCompare(b));
|
|
182
|
+
}
|
|
183
|
+
function hashText(value) {
|
|
184
|
+
return `sha256-${crypto.createHash("sha256").update(value).digest("hex")}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/update/hash-installed.ts
|
|
188
|
+
var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
|
|
189
|
+
async function hashInstalledPaths(root, relPaths) {
|
|
190
|
+
if (relPaths.length === 0) {
|
|
191
|
+
return hashText(EMPTY_LOCK_PATHS_TOKEN);
|
|
192
|
+
}
|
|
193
|
+
const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
|
|
194
|
+
const fileDigests = [];
|
|
195
|
+
for (const rel of normalized) {
|
|
196
|
+
const abs = path3.join(root, rel);
|
|
197
|
+
if (!await fs3.pathExists(abs)) continue;
|
|
198
|
+
const stat = await fs3.stat(abs);
|
|
199
|
+
if (stat.isFile()) {
|
|
200
|
+
const body = await fs3.readFile(abs, "utf8");
|
|
201
|
+
fileDigests.push({ rel, digest: hashText(body) });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!stat.isDirectory()) continue;
|
|
205
|
+
const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
|
|
206
|
+
for (const sub of inner.sort()) {
|
|
207
|
+
const relFile = path3.join(rel, sub).replace(/\\/g, "/");
|
|
208
|
+
const absFile = path3.join(abs, sub);
|
|
209
|
+
const body = await fs3.readFile(absFile, "utf8");
|
|
210
|
+
fileDigests.push({ rel: relFile, digest: hashText(body) });
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (fileDigests.length === 0) {
|
|
214
|
+
return hashText(`${EMPTY_LOCK_PATHS_TOKEN}|${normalized.join("|")}`);
|
|
215
|
+
}
|
|
216
|
+
fileDigests.sort((a, b) => a.rel.localeCompare(b.rel));
|
|
217
|
+
return hashText(fileDigests.map((f) => `${f.rel}=${f.digest}`).join("|"));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/utils/diff.ts
|
|
221
|
+
import { createTwoFilesPatch } from "diff";
|
|
222
|
+
function hasTextChanged(before, after) {
|
|
223
|
+
return before !== after;
|
|
224
|
+
}
|
|
225
|
+
function createUnifiedDiff(filePath, before, after) {
|
|
226
|
+
return createTwoFilesPatch(filePath, filePath, before, after, "before", "after", {
|
|
227
|
+
context: 3
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
function summarizeDiff(diffText) {
|
|
231
|
+
const lines = diffText.split("\n");
|
|
232
|
+
let additions = 0;
|
|
233
|
+
let deletions = 0;
|
|
234
|
+
for (const line of lines) {
|
|
235
|
+
if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
|
|
236
|
+
if (line.startsWith("+")) additions += 1;
|
|
237
|
+
if (line.startsWith("-")) deletions += 1;
|
|
238
|
+
}
|
|
239
|
+
return { additions, deletions };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// src/utils/paths.ts
|
|
243
|
+
import { existsSync, readFileSync } from "fs";
|
|
244
|
+
import os2 from "os";
|
|
245
|
+
import path4 from "path";
|
|
246
|
+
import { fileURLToPath } from "url";
|
|
247
|
+
var HAUS_DIR = ".haus-workflow";
|
|
248
|
+
function hausPath(root, ...parts) {
|
|
249
|
+
return path4.join(root, HAUS_DIR, ...parts);
|
|
250
|
+
}
|
|
251
|
+
function claudePath(root, ...parts) {
|
|
252
|
+
return path4.join(root, ".claude", ...parts);
|
|
253
|
+
}
|
|
254
|
+
function displayPath(root, targetPath) {
|
|
255
|
+
const rel = path4.relative(root, targetPath).replace(/\\/g, "/");
|
|
256
|
+
if (rel && !rel.startsWith("../") && rel !== "..") {
|
|
257
|
+
return rel.startsWith("./") ? rel : `./${rel}`;
|
|
258
|
+
}
|
|
259
|
+
const home = os2.homedir();
|
|
260
|
+
const normalized = targetPath.replace(/\\/g, "/");
|
|
261
|
+
if (home && home.trim().length > 0) {
|
|
262
|
+
const homeRel = path4.relative(home, targetPath).replace(/\\/g, "/");
|
|
263
|
+
if (homeRel && !homeRel.startsWith("../") && homeRel !== "..") {
|
|
264
|
+
return `~/${homeRel}`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return normalized;
|
|
268
|
+
}
|
|
269
|
+
function packageRoot() {
|
|
270
|
+
let dir = path4.dirname(fileURLToPath(import.meta.url));
|
|
271
|
+
for (let i = 0; i < 12; i++) {
|
|
272
|
+
const pkgPath = path4.join(dir, "package.json");
|
|
273
|
+
if (existsSync(pkgPath)) {
|
|
274
|
+
try {
|
|
275
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
276
|
+
if (pkg.name === "haus" || pkg.name === "@haus-tech/haus-workflow") return dir;
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const parent = path4.dirname(dir);
|
|
281
|
+
if (parent === dir) break;
|
|
282
|
+
dir = parent;
|
|
283
|
+
}
|
|
284
|
+
const file = fileURLToPath(import.meta.url);
|
|
285
|
+
return path4.resolve(path4.dirname(file), "../..");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/claude/load-hooks-config.ts
|
|
289
|
+
import path5 from "path";
|
|
290
|
+
var CONFIG_PATH = ".haus-workflow/config.json";
|
|
291
|
+
var DEFAULT_HOOKS_CONFIG = {
|
|
292
|
+
hooks: {
|
|
293
|
+
context: { enabled: false },
|
|
294
|
+
memoryInject: { enabled: false }
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
async function isHookEnabled(root, key) {
|
|
298
|
+
const cfg = await readJson(path5.join(root, CONFIG_PATH));
|
|
299
|
+
return cfg?.hooks?.[key]?.enabled === true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/claude/load-hooks.ts
|
|
303
|
+
var CANONICAL_HOOKS = {
|
|
304
|
+
hooks: {
|
|
305
|
+
UserPromptSubmit: [
|
|
306
|
+
{
|
|
307
|
+
hooks: [
|
|
308
|
+
{ type: "command", command: "haus context --from-hook || true" },
|
|
309
|
+
{ type: "command", command: "haus memory inject --from-hook || true" }
|
|
310
|
+
]
|
|
311
|
+
}
|
|
312
|
+
],
|
|
313
|
+
PreToolUse: [
|
|
314
|
+
{
|
|
315
|
+
matcher: "Read|Edit|Write",
|
|
316
|
+
hooks: [{ type: "command", command: "haus guard file-access --from-hook || true" }]
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
matcher: "Bash",
|
|
320
|
+
hooks: [{ type: "command", command: "haus guard bash --from-hook || true" }]
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
var STABLE_HOOK_IDS = {
|
|
326
|
+
"haus context --from-hook || true": "haus.context-hook",
|
|
327
|
+
"haus memory inject --from-hook || true": "haus.memory-hook",
|
|
328
|
+
"haus guard file-access --from-hook || true": "haus.guard-file",
|
|
329
|
+
"haus guard bash --from-hook || true": "haus.guard-bash"
|
|
330
|
+
};
|
|
331
|
+
async function loadClaudeHooksSettings() {
|
|
332
|
+
return CANONICAL_HOOKS;
|
|
333
|
+
}
|
|
334
|
+
function flattenRecommendedHooks(settings) {
|
|
335
|
+
const out = [];
|
|
336
|
+
let generic = 0;
|
|
337
|
+
for (const block of settings.hooks.UserPromptSubmit) {
|
|
338
|
+
for (const h of block.hooks) {
|
|
339
|
+
const id = STABLE_HOOK_IDS[h.command] ?? `haus.hook.user-${generic++}`;
|
|
340
|
+
out.push({ id, command: h.command });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const block of settings.hooks.PreToolUse) {
|
|
344
|
+
for (const h of block.hooks) {
|
|
345
|
+
const id = STABLE_HOOK_IDS[h.command] ?? `haus.hook.pre-${generic++}`;
|
|
346
|
+
out.push({ id, command: h.command });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/claude/verify-hooks-contract.ts
|
|
353
|
+
import { isDeepStrictEqual } from "util";
|
|
354
|
+
import fs4 from "fs-extra";
|
|
355
|
+
async function assertPostApplySettingsMatchCanonical(root, canonical) {
|
|
356
|
+
const written = await readJson(claudePath(root, "settings.json"));
|
|
357
|
+
if (written == null || typeof written !== "object") {
|
|
358
|
+
throw new Error("haus: post-apply self-check failed: .claude/settings.json missing or unreadable");
|
|
359
|
+
}
|
|
360
|
+
if (!isDeepStrictEqual(canonical, written)) {
|
|
361
|
+
throw new Error("haus: post-apply self-check failed: .claude/settings.json does not match canonical hook contract");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async function verifyProjectSettingsHooksContract(root) {
|
|
365
|
+
const settingsPath = claudePath(root, "settings.json");
|
|
366
|
+
if (!await fs4.pathExists(settingsPath)) {
|
|
367
|
+
return {
|
|
368
|
+
ok: true,
|
|
369
|
+
skipped: true,
|
|
370
|
+
message: "No .claude/settings.json (run `haus apply --write` to install hooks)."
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
let canonical;
|
|
374
|
+
try {
|
|
375
|
+
canonical = await loadClaudeHooksSettings();
|
|
376
|
+
} catch (err) {
|
|
377
|
+
return { ok: false, message: `Cannot load canonical hooks: ${String(err)}` };
|
|
378
|
+
}
|
|
379
|
+
const project = await readJson(settingsPath);
|
|
380
|
+
if (project == null || typeof project !== "object") {
|
|
381
|
+
return { ok: false, message: ".claude/settings.json is unreadable." };
|
|
382
|
+
}
|
|
383
|
+
if (!isDeepStrictEqual(canonical, project)) {
|
|
384
|
+
return {
|
|
385
|
+
ok: false,
|
|
386
|
+
message: ".claude/settings.json drifts from canonical hook config (regenerate with `haus apply --write`)."
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return { ok: true, message: "settings.json matches canonical hook contract." };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/claude/write-project-facts.ts
|
|
393
|
+
import path6 from "path";
|
|
394
|
+
import fs5 from "fs-extra";
|
|
395
|
+
var STABLE_ID = "generated.project-facts";
|
|
396
|
+
var SCHEMA_VERSION = "1";
|
|
397
|
+
function makeHeader(pkgVersion) {
|
|
398
|
+
return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} -->`;
|
|
399
|
+
}
|
|
400
|
+
function renderProjectFacts(ctx, rec, pkgVersion) {
|
|
401
|
+
const header = makeHeader(pkgVersion);
|
|
402
|
+
const stackEntries = Object.entries(ctx.detectedStacks ?? {});
|
|
403
|
+
const stackLines = stackEntries.length > 0 ? stackEntries.map(([stack, files]) => {
|
|
404
|
+
const f = files;
|
|
405
|
+
return `- **${stack}**: ${f.slice(0, 3).join(", ")}${f.length > 3 ? ", \u2026" : ""}`;
|
|
406
|
+
}).join("\n") : "- none detected";
|
|
407
|
+
const roles = ctx.repoRoles?.length > 0 ? ctx.repoRoles.join(", ") : "unknown";
|
|
408
|
+
const recLines = rec.recommended.length > 0 ? rec.recommended.map((r) => `- \`${r.id}\` (${r.type}) \u2014 ${r.reason}`).join("\n") : "- none";
|
|
409
|
+
const warningLines = [...ctx.warnings ?? [], ...rec.warnings ?? []];
|
|
410
|
+
const warnSection = warningLines.length > 0 ? warningLines.map((w) => `- ${w}`).join("\n") : "- none";
|
|
411
|
+
const repoName = ctx.repoName ?? path6.basename(ctx.root ?? "unknown");
|
|
412
|
+
return `${header}
|
|
413
|
+
|
|
414
|
+
# Project facts
|
|
415
|
+
|
|
416
|
+
> Auto-generated by \`haus apply\`. Do not edit \u2014 changes will be overwritten on next apply.
|
|
417
|
+
|
|
418
|
+
**Repo:** ${repoName}
|
|
419
|
+
**Package manager:** ${ctx.packageManager ?? "unknown"}
|
|
420
|
+
**Roles:** ${roles}
|
|
421
|
+
|
|
422
|
+
## Detected stacks
|
|
423
|
+
|
|
424
|
+
${stackLines}
|
|
425
|
+
|
|
426
|
+
## Recommended context
|
|
427
|
+
|
|
428
|
+
${recLines}
|
|
429
|
+
|
|
430
|
+
## Warnings
|
|
431
|
+
|
|
432
|
+
${warnSection}
|
|
433
|
+
`;
|
|
434
|
+
}
|
|
435
|
+
async function writeProjectFacts(root, pkgVersion, dryRun) {
|
|
436
|
+
const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
|
|
437
|
+
mode: "fast",
|
|
438
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
439
|
+
root,
|
|
440
|
+
repoName: path6.basename(root),
|
|
441
|
+
packageManager: "unknown",
|
|
442
|
+
repoRoles: [],
|
|
443
|
+
confidence: 0,
|
|
444
|
+
detectedStacks: {},
|
|
445
|
+
dependencies: [],
|
|
446
|
+
securityRisks: [],
|
|
447
|
+
crossRepoHints: [],
|
|
448
|
+
warnings: []
|
|
449
|
+
};
|
|
450
|
+
const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
|
|
451
|
+
mode: "fast",
|
|
452
|
+
recommended: [],
|
|
453
|
+
skipped: [],
|
|
454
|
+
warnings: [],
|
|
455
|
+
estimatedContextTokens: 0,
|
|
456
|
+
selectedRules: 0,
|
|
457
|
+
skippedRules: 0,
|
|
458
|
+
estimatedTokenReductionPct: 0
|
|
459
|
+
};
|
|
460
|
+
const destPath = hausPath(root, "project.md");
|
|
461
|
+
const printable = displayPath(root, destPath);
|
|
462
|
+
const next = renderProjectFacts(ctx, rec, pkgVersion);
|
|
463
|
+
const prev = await fs5.pathExists(destPath) ? await fs5.readFile(destPath, "utf8") : "";
|
|
464
|
+
if (dryRun) {
|
|
465
|
+
if (!prev) {
|
|
466
|
+
log(createUnifiedDiff(printable, "", next));
|
|
467
|
+
} else if (hasTextChanged(prev, next)) {
|
|
468
|
+
log(createUnifiedDiff(printable, prev, next));
|
|
469
|
+
} else {
|
|
470
|
+
log(`${printable}: unchanged`);
|
|
471
|
+
}
|
|
472
|
+
return destPath;
|
|
473
|
+
}
|
|
474
|
+
if (hasTextChanged(prev, next) && prev.length > 0) {
|
|
475
|
+
const diffText = createUnifiedDiff(printable, prev, next);
|
|
476
|
+
const summary = summarizeDiff(diffText);
|
|
477
|
+
log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
478
|
+
}
|
|
479
|
+
await writeText(destPath, next);
|
|
480
|
+
return destPath;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// src/claude/write-root-claude-md.ts
|
|
484
|
+
import path7 from "path";
|
|
485
|
+
import fs6 from "fs-extra";
|
|
486
|
+
var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
|
|
487
|
+
var BLOCK_END = "<!-- HAUS:END haus-imports -->";
|
|
488
|
+
var IMPORT_CONTENT = `@.haus-workflow/haus-way-of-work.md
|
|
489
|
+
@.haus-workflow/project.md`;
|
|
490
|
+
function buildImportBlock() {
|
|
491
|
+
return `${BLOCK_BEGIN}
|
|
492
|
+
${IMPORT_CONTENT}
|
|
493
|
+
${BLOCK_END}`;
|
|
494
|
+
}
|
|
495
|
+
function injectHausBlock(existing, block) {
|
|
496
|
+
const beginIdx = existing.indexOf(BLOCK_BEGIN);
|
|
497
|
+
const endIdx = existing.indexOf(BLOCK_END);
|
|
498
|
+
if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
|
|
499
|
+
const before = existing.slice(0, beginIdx);
|
|
500
|
+
const after = existing.slice(endIdx + BLOCK_END.length);
|
|
501
|
+
return `${before}${block}${after}`;
|
|
502
|
+
}
|
|
503
|
+
const trimmed = existing.trimEnd();
|
|
504
|
+
if (trimmed.length === 0) {
|
|
505
|
+
return `${block}
|
|
506
|
+
`;
|
|
507
|
+
}
|
|
508
|
+
return `${trimmed}
|
|
509
|
+
|
|
510
|
+
${block}
|
|
511
|
+
`;
|
|
512
|
+
}
|
|
513
|
+
async function writeRootClaudeMd(root, dryRun) {
|
|
514
|
+
const filePath = path7.join(root, "CLAUDE.md");
|
|
515
|
+
const block = buildImportBlock();
|
|
516
|
+
const prev = await fs6.pathExists(filePath) ? await fs6.readFile(filePath, "utf8") : "";
|
|
517
|
+
const next = injectHausBlock(prev, block);
|
|
518
|
+
const printable = displayPath(root, filePath);
|
|
519
|
+
if (dryRun) {
|
|
520
|
+
if (!prev) {
|
|
521
|
+
log(createUnifiedDiff(printable, "", next));
|
|
522
|
+
} else if (hasTextChanged(prev, next)) {
|
|
523
|
+
log(createUnifiedDiff(printable, prev, next));
|
|
524
|
+
} else {
|
|
525
|
+
log(`${printable}: unchanged`);
|
|
526
|
+
}
|
|
527
|
+
return filePath;
|
|
528
|
+
}
|
|
529
|
+
if (hasTextChanged(prev, next) && prev.length > 0) {
|
|
530
|
+
const diffText = createUnifiedDiff(printable, prev, next);
|
|
531
|
+
const summary = summarizeDiff(diffText);
|
|
532
|
+
log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
533
|
+
}
|
|
534
|
+
await writeText(filePath, next);
|
|
535
|
+
return filePath;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/claude/write-way-of-work.ts
|
|
539
|
+
import os3 from "os";
|
|
540
|
+
import path8 from "path";
|
|
541
|
+
import fs7 from "fs-extra";
|
|
542
|
+
var STABLE_ID2 = "template.way-of-work";
|
|
543
|
+
var SCHEMA_VERSION2 = "1";
|
|
544
|
+
var TEMPLATE_REL = "library/global/templates/haus-way-of-work.md";
|
|
545
|
+
var CATALOG_CACHE_TEMPLATE = path8.join(os3.homedir(), CATALOG_CACHE_SUBDIR, "templates/haus-way-of-work.md");
|
|
546
|
+
function makeWayOfWorkHeader(pkgVersion, contentHash) {
|
|
547
|
+
return `<!-- HAUS-MANAGED id=${STABLE_ID2} v=${SCHEMA_VERSION2} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
|
|
548
|
+
}
|
|
549
|
+
function parseHausManagedHeader(line) {
|
|
550
|
+
const match = line.match(/<!-- HAUS-MANAGED id=([\w.:-]+)/);
|
|
551
|
+
if (!match) return null;
|
|
552
|
+
const hashMatch = line.match(/hash=(sha256-[a-f0-9]+)/);
|
|
553
|
+
return { id: match[1], hash: hashMatch?.[1] };
|
|
554
|
+
}
|
|
555
|
+
async function writeWayOfWork(root, pkgVersion, dryRun) {
|
|
556
|
+
const cachePath = CATALOG_CACHE_TEMPLATE;
|
|
557
|
+
const packagePath = path8.join(packageRoot(), TEMPLATE_REL);
|
|
558
|
+
const templatePath = await fs7.pathExists(cachePath) ? cachePath : packagePath;
|
|
559
|
+
if (!await fs7.pathExists(templatePath)) {
|
|
560
|
+
warn(`Way-of-work template not found \u2014 run \`haus update\` to fetch from catalog`);
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const templateContent = await fs7.readFile(templatePath, "utf8");
|
|
564
|
+
const contentHash = hashText(templateContent);
|
|
565
|
+
const header = makeWayOfWorkHeader(pkgVersion, contentHash);
|
|
566
|
+
const next = `${header}
|
|
567
|
+
${templateContent}`;
|
|
568
|
+
const destPath = hausPath(root, "haus-way-of-work.md");
|
|
569
|
+
const printable = displayPath(root, destPath);
|
|
570
|
+
if (await fs7.pathExists(destPath)) {
|
|
571
|
+
const existing = await fs7.readFile(destPath, "utf8");
|
|
572
|
+
const firstLine = existing.split("\n")[0] ?? "";
|
|
573
|
+
const parsed = parseHausManagedHeader(firstLine);
|
|
574
|
+
if (!parsed) {
|
|
575
|
+
warn(`${printable}: no HAUS-MANAGED header \u2014 file appears user-owned, skipping`);
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
if (parsed.id !== STABLE_ID2) {
|
|
579
|
+
warn(`${printable}: HAUS-MANAGED id mismatch (expected ${STABLE_ID2}) \u2014 skipping`);
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
const existingContent = existing.slice(firstLine.length + 1);
|
|
583
|
+
if (parsed.hash && hashText(existingContent) !== parsed.hash) {
|
|
584
|
+
warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
if (!hasTextChanged(existing, next)) {
|
|
588
|
+
if (dryRun) log(`${printable}: unchanged`);
|
|
589
|
+
return destPath;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (dryRun) {
|
|
593
|
+
const prev = await fs7.pathExists(destPath) ? await fs7.readFile(destPath, "utf8") : "";
|
|
594
|
+
if (!prev) {
|
|
595
|
+
log(createUnifiedDiff(printable, "", next));
|
|
596
|
+
} else {
|
|
597
|
+
const diffText = createUnifiedDiff(printable, prev, next);
|
|
598
|
+
const summary = summarizeDiff(diffText);
|
|
599
|
+
log(`${printable}: would update (diff +${summary.additions} -${summary.deletions})`);
|
|
600
|
+
}
|
|
601
|
+
return destPath;
|
|
602
|
+
}
|
|
603
|
+
await writeText(destPath, next);
|
|
604
|
+
return destPath;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// src/claude/write-claude-files.ts
|
|
608
|
+
async function writeClaudeFiles(root, dryRun) {
|
|
609
|
+
const rec = await readJson(hausPath(root, "recommendation.json")) ?? {
|
|
610
|
+
mode: "fast",
|
|
611
|
+
recommended: [],
|
|
612
|
+
skipped: [],
|
|
613
|
+
warnings: [],
|
|
614
|
+
estimatedContextTokens: 0,
|
|
615
|
+
selectedRules: 0,
|
|
616
|
+
skippedRules: 0,
|
|
617
|
+
estimatedTokenReductionPct: 0
|
|
618
|
+
};
|
|
619
|
+
const pkgRoot = packageRoot();
|
|
620
|
+
const hausVersion = (await readJson(path9.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
|
|
621
|
+
const coreFiles = [
|
|
622
|
+
claudePath(root, "settings.json"),
|
|
623
|
+
claudePath(root, "rules", "haus.md"),
|
|
624
|
+
claudePath(root, "rules", "security.md"),
|
|
625
|
+
claudePath(root, "commands", "haus-doctor.md"),
|
|
626
|
+
claudePath(root, "commands", "haus-review.md")
|
|
627
|
+
];
|
|
628
|
+
const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
|
|
629
|
+
const wayOfWorkPath = await writeWayOfWork(root, hausVersion, dryRun);
|
|
630
|
+
const projectFactsPath = await writeProjectFacts(root, hausVersion, dryRun);
|
|
631
|
+
const p6Files = [rootClaudeMdPath, projectFactsPath, ...wayOfWorkPath ? [wayOfWorkPath] : []];
|
|
632
|
+
const files = dryRun ? [...coreFiles, ...p6Files] : [...coreFiles, ...p6Files, hausPath(root, "selected-context.json"), hausPath(root, "haus.lock.json")];
|
|
633
|
+
const hookSettings = await loadClaudeHooksSettings();
|
|
634
|
+
await writeManagedJson(root, claudePath(root, "settings.json"), hookSettings, dryRun);
|
|
635
|
+
if (!dryRun) await assertPostApplySettingsMatchCanonical(root, hookSettings);
|
|
636
|
+
const configPath = hausPath(root, "config.json");
|
|
637
|
+
if (!await fs8.pathExists(configPath)) {
|
|
638
|
+
await writeManagedJson(root, configPath, DEFAULT_HOOKS_CONFIG, dryRun);
|
|
639
|
+
}
|
|
640
|
+
await writeManagedText(root, claudePath(root, "commands", "haus-doctor.md"), "Run `haus doctor`.", dryRun);
|
|
641
|
+
await writeManagedText(
|
|
642
|
+
root,
|
|
643
|
+
claudePath(root, "commands", "haus-review.md"),
|
|
644
|
+
'Run `haus context --task "code review"` then review diff.',
|
|
645
|
+
dryRun
|
|
646
|
+
);
|
|
647
|
+
await writeManagedText(
|
|
648
|
+
root,
|
|
649
|
+
claudePath(root, "rules", "haus.md"),
|
|
650
|
+
"- Keep context minimal.\n- Follow project conventions.\n",
|
|
651
|
+
dryRun
|
|
652
|
+
);
|
|
653
|
+
await writeManagedText(
|
|
654
|
+
root,
|
|
655
|
+
claudePath(root, "rules", "security.md"),
|
|
656
|
+
"- Never read secrets.\n- Block dangerous shell commands.\n",
|
|
657
|
+
dryRun
|
|
658
|
+
);
|
|
659
|
+
const manifest = await readJson(
|
|
660
|
+
path9.join(pkgRoot, "library", "catalog", "manifest.json")
|
|
661
|
+
) ?? { items: [] };
|
|
662
|
+
const manifestById = new Map((manifest.items ?? []).map((item) => [item.id, item]));
|
|
663
|
+
const cacheManifest = await readJson(path9.join(CACHE_DIR, "manifest.json"));
|
|
664
|
+
const cacheManifestById = new Map((cacheManifest?.items ?? []).map((item) => [item.id, item]));
|
|
665
|
+
const installedPathsByItem = /* @__PURE__ */ new Map();
|
|
666
|
+
const installedIds = /* @__PURE__ */ new Set();
|
|
667
|
+
for (const item of rec.recommended) {
|
|
668
|
+
const manifestItem = manifestById.get(item.id);
|
|
669
|
+
if (!manifestItem?.path) continue;
|
|
670
|
+
if (manifestItem.source === "curated") {
|
|
671
|
+
if (manifestItem.reviewStatus !== "approved") {
|
|
672
|
+
warn(
|
|
673
|
+
`Skipping curated item ${item.id}: reviewStatus is not approved (${manifestItem.reviewStatus ?? "unset"})`
|
|
674
|
+
);
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
if (manifestItem.riskLevel === "blocked") {
|
|
678
|
+
warn(`Skipping curated item ${item.id}: riskLevel is blocked`);
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const cachedItem = cacheManifestById.get(item.id);
|
|
683
|
+
const cachePath = cachedItem?.path ? path9.join(CACHE_DIR, cachedItem.path) : null;
|
|
684
|
+
const sourcePath = cachePath && await fs8.pathExists(cachePath) ? cachePath : path9.join(pkgRoot, manifestItem.path);
|
|
685
|
+
const target = item.type === "agent" ? "agents" : "skills";
|
|
686
|
+
const destination = claudePath(root, target, path9.basename(sourcePath));
|
|
687
|
+
if (await fs8.pathExists(sourcePath)) {
|
|
688
|
+
if (dryRun) {
|
|
689
|
+
const exists = await fs8.pathExists(destination);
|
|
690
|
+
log(`${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`);
|
|
691
|
+
} else {
|
|
692
|
+
await fs8.ensureDir(path9.dirname(destination));
|
|
693
|
+
await fs8.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
|
|
694
|
+
}
|
|
695
|
+
files.push(destination);
|
|
696
|
+
const current = installedPathsByItem.get(item.id) ?? [];
|
|
697
|
+
installedPathsByItem.set(item.id, [...current, path9.relative(root, destination)]);
|
|
698
|
+
installedIds.add(item.id);
|
|
699
|
+
} else {
|
|
700
|
+
warn(`Skipping ${item.id}: source not found at ${sourcePath} \u2014 run \`haus update\` to populate catalog cache`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
if (dryRun) return [...new Set(files)];
|
|
704
|
+
const installedItems = rec.recommended.filter((r) => installedIds.has(r.id));
|
|
705
|
+
await writeManagedJson(
|
|
706
|
+
root,
|
|
707
|
+
hausPath(root, "selected-context.json"),
|
|
708
|
+
installedItems.map((r) => ({ id: r.id, type: r.type, reason: r.reason, confidenceLevel: r.confidenceLevel })),
|
|
709
|
+
false
|
|
710
|
+
);
|
|
711
|
+
const lock = await Promise.all(
|
|
712
|
+
installedItems.map(async (r) => {
|
|
713
|
+
const relPaths = installedPathsByItem.get(r.id) ?? [];
|
|
714
|
+
const manifestItem = manifestById.get(r.id);
|
|
715
|
+
const isCurated = manifestItem?.source === "curated";
|
|
716
|
+
const base = {
|
|
717
|
+
id: r.id,
|
|
718
|
+
type: r.type,
|
|
719
|
+
source: isCurated ? "curated" : "haus",
|
|
720
|
+
version: hausVersion,
|
|
721
|
+
hash: await hashInstalledPaths(root, relPaths),
|
|
722
|
+
installMode: "copied",
|
|
723
|
+
paths: relPaths
|
|
724
|
+
};
|
|
725
|
+
if (!isCurated || !manifestItem) return base;
|
|
726
|
+
return {
|
|
727
|
+
...base,
|
|
728
|
+
...manifestItem.originSourceId ? { originSourceId: manifestItem.originSourceId } : {},
|
|
729
|
+
...manifestItem.useMode ? { useMode: manifestItem.useMode } : {},
|
|
730
|
+
...manifestItem.license ? { license: manifestItem.license } : {},
|
|
731
|
+
...manifestItem.riskLevel ? { riskLevel: manifestItem.riskLevel } : {},
|
|
732
|
+
...manifestItem.reviewStatus ? { reviewStatus: manifestItem.reviewStatus } : {}
|
|
733
|
+
};
|
|
734
|
+
})
|
|
735
|
+
);
|
|
736
|
+
await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
|
|
737
|
+
return [...new Set(files)];
|
|
738
|
+
}
|
|
739
|
+
async function writeManagedText(root, filePath, nextText, dryRun) {
|
|
740
|
+
const prev = await fs8.pathExists(filePath) ? await fs8.readFile(filePath, "utf8") : "";
|
|
741
|
+
const printable = displayPath(root, filePath);
|
|
742
|
+
if (dryRun) {
|
|
743
|
+
if (!prev) {
|
|
744
|
+
log(createUnifiedDiff(printable, "", nextText));
|
|
745
|
+
} else if (hasTextChanged(prev, nextText)) {
|
|
746
|
+
log(createUnifiedDiff(printable, prev, nextText));
|
|
747
|
+
} else {
|
|
748
|
+
log(`${printable}: unchanged`);
|
|
749
|
+
}
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
if (hasTextChanged(prev, nextText) && prev.length > 0) {
|
|
753
|
+
const diffText = createUnifiedDiff(printable, prev, nextText);
|
|
754
|
+
const summary = summarizeDiff(diffText);
|
|
755
|
+
log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
|
|
756
|
+
}
|
|
757
|
+
await writeText(filePath, nextText);
|
|
758
|
+
}
|
|
759
|
+
async function writeManagedJson(root, filePath, value, dryRun) {
|
|
760
|
+
const nextText = `${JSON.stringify(value, null, 2)}
|
|
761
|
+
`;
|
|
762
|
+
await writeManagedText(root, filePath, nextText, dryRun);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/commands/apply.ts
|
|
766
|
+
async function runApply(options) {
|
|
767
|
+
if (!options.dryRun && !options.write) {
|
|
768
|
+
log("Use --dry-run or --write");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const root = process.cwd();
|
|
772
|
+
const isDryRun = Boolean(options.dryRun) && !options.write;
|
|
773
|
+
const files = await writeClaudeFiles(root, isDryRun);
|
|
774
|
+
if (isDryRun) {
|
|
775
|
+
log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
|
|
776
|
+
} else {
|
|
777
|
+
log("Applied files:");
|
|
778
|
+
files.forEach((f) => log(`- ${displayPath(root, f)}`));
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// src/catalog/load-catalog.ts
|
|
783
|
+
import os4 from "os";
|
|
784
|
+
import path10 from "path";
|
|
785
|
+
var CACHE_MANIFEST = path10.join(os4.homedir(), CATALOG_CACHE_SUBDIR, "manifest.json");
|
|
786
|
+
async function loadCatalog(root) {
|
|
787
|
+
const envPath = process.env["HAUS_FIXTURE_CATALOG"];
|
|
788
|
+
if (envPath) {
|
|
789
|
+
const data2 = await readJson(envPath);
|
|
790
|
+
return data2?.items ?? [];
|
|
791
|
+
}
|
|
792
|
+
const cacheData = await readJson(CACHE_MANIFEST);
|
|
793
|
+
if (cacheData?.items?.length) return cacheData.items;
|
|
794
|
+
const localManifest = path10.join(root, "library/catalog/manifest.json");
|
|
795
|
+
const localData = await readJson(localManifest);
|
|
796
|
+
if (localData?.items?.length) return localData.items;
|
|
797
|
+
const packageManifest = path10.join(packageRoot(), "library/catalog/manifest.json");
|
|
798
|
+
const data = await readJson(packageManifest);
|
|
799
|
+
return data?.items ?? [];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/commands/catalog-audit.ts
|
|
803
|
+
var FORBIDDEN = [
|
|
804
|
+
"python",
|
|
805
|
+
"django",
|
|
806
|
+
"go",
|
|
807
|
+
"rust",
|
|
808
|
+
"java",
|
|
809
|
+
"spring",
|
|
810
|
+
"kotlin",
|
|
811
|
+
"swift",
|
|
812
|
+
"android",
|
|
813
|
+
"flutter",
|
|
814
|
+
"dart",
|
|
815
|
+
"c++",
|
|
816
|
+
"perl",
|
|
817
|
+
"defi",
|
|
818
|
+
"trading"
|
|
819
|
+
];
|
|
820
|
+
async function runCatalogAudit() {
|
|
821
|
+
const items = await loadCatalog(process.cwd());
|
|
822
|
+
const failures = [];
|
|
823
|
+
for (const item of items) {
|
|
824
|
+
const text = `${item.id} ${item.tags.join(" ")}`.toLowerCase();
|
|
825
|
+
for (const word of FORBIDDEN) if (text.includes(word)) failures.push(`${item.id} has unsupported tag ${word}`);
|
|
826
|
+
}
|
|
827
|
+
if (failures.length) {
|
|
828
|
+
failures.forEach((f) => error(f));
|
|
829
|
+
process.exitCode = 1;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
log("Catalog audit passed.");
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// src/recommender/explain-recommendation.ts
|
|
836
|
+
function normalizeRecommendation(input2) {
|
|
837
|
+
const recommended = (input2.recommended ?? []).map((item) => {
|
|
838
|
+
const normalizedReasons = item.reasons?.map((reason) => ({
|
|
839
|
+
code: reason.code ?? "legacy-reason",
|
|
840
|
+
message: reason.message ?? item.reason ?? "legacy recommendation reason",
|
|
841
|
+
weight: reason.weight ?? 0,
|
|
842
|
+
...reason.signal ? { signal: reason.signal } : {}
|
|
843
|
+
})) ?? [{ code: "legacy-reason", message: item.reason ?? "legacy recommendation reason", weight: 0 }];
|
|
844
|
+
const confidence = item.confidence ?? 0;
|
|
845
|
+
return {
|
|
846
|
+
id: item.id,
|
|
847
|
+
type: item.type ?? "skill",
|
|
848
|
+
reason: item.reason ?? normalizedReasons.map((reason) => reason.message).join(", "),
|
|
849
|
+
reasons: normalizedReasons,
|
|
850
|
+
confidence,
|
|
851
|
+
confidenceLevel: item.confidenceLevel ?? (confidence >= 0.75 ? "high" : confidence >= 0.4 ? "medium" : "low"),
|
|
852
|
+
selectionMode: item.selectionMode ?? "matched",
|
|
853
|
+
install: item.install ?? true,
|
|
854
|
+
score: item.score ?? 0,
|
|
855
|
+
scoreBreakdown: {
|
|
856
|
+
bonuses: normalizedReasons,
|
|
857
|
+
penalties: [],
|
|
858
|
+
finalScore: item.score ?? 0
|
|
859
|
+
},
|
|
860
|
+
tags: item.tags,
|
|
861
|
+
ecosystem: item.ecosystem
|
|
862
|
+
};
|
|
863
|
+
});
|
|
864
|
+
const skipped = (input2.skipped ?? []).map((item) => ({
|
|
865
|
+
id: item.id,
|
|
866
|
+
reason: item.reason ?? "legacy skipped reason",
|
|
867
|
+
skipReasons: item.skipReasons?.map((reason) => ({
|
|
868
|
+
code: reason.code ?? "legacy-skip-reason",
|
|
869
|
+
message: reason.message ?? item.reason ?? "legacy skipped reason",
|
|
870
|
+
penalty: reason.penalty ?? 0,
|
|
871
|
+
...reason.signal ? { signal: reason.signal } : {}
|
|
872
|
+
})) ?? [
|
|
873
|
+
{
|
|
874
|
+
code: "legacy-skip-reason",
|
|
875
|
+
message: item.reason ?? "legacy skipped reason",
|
|
876
|
+
penalty: 0
|
|
877
|
+
}
|
|
878
|
+
]
|
|
879
|
+
}));
|
|
880
|
+
return {
|
|
881
|
+
mode: input2.mode === "guided" ? "guided" : "fast",
|
|
882
|
+
recommended,
|
|
883
|
+
skipped,
|
|
884
|
+
warnings: input2.warnings ?? [],
|
|
885
|
+
estimatedContextTokens: input2.estimatedContextTokens ?? recommended.length * 320,
|
|
886
|
+
selectedRules: input2.selectedRules ?? recommended.length,
|
|
887
|
+
skippedRules: input2.skippedRules ?? skipped.length,
|
|
888
|
+
estimatedTokenReductionPct: input2.estimatedTokenReductionPct ?? Math.max(0, Math.round(skipped.length / Math.max(recommended.length + skipped.length, 1) * 100))
|
|
889
|
+
};
|
|
890
|
+
}
|
|
891
|
+
function buildRecommendationExplanation(recommendation) {
|
|
892
|
+
return {
|
|
893
|
+
selected: recommendation.recommended.map((item) => ({
|
|
894
|
+
id: item.id,
|
|
895
|
+
confidence: item.confidence,
|
|
896
|
+
confidenceLevel: item.confidenceLevel,
|
|
897
|
+
selectionMode: item.selectionMode,
|
|
898
|
+
reasons: item.reasons.map((reason) => reason.message)
|
|
899
|
+
})),
|
|
900
|
+
skipped: recommendation.skipped.map((item) => ({
|
|
901
|
+
id: item.id,
|
|
902
|
+
reasons: item.skipReasons.map((reason) => reason.message),
|
|
903
|
+
reasonDetails: item.skipReasons.map((reason) => ({
|
|
904
|
+
code: reason.code,
|
|
905
|
+
message: reason.message,
|
|
906
|
+
penalty: reason.penalty,
|
|
907
|
+
...reason.signal ? { signal: reason.signal } : {}
|
|
908
|
+
}))
|
|
909
|
+
})),
|
|
910
|
+
stats: {
|
|
911
|
+
selectedRules: recommendation.selectedRules,
|
|
912
|
+
skippedRules: recommendation.skippedRules,
|
|
913
|
+
estimatedTokenReductionPct: recommendation.estimatedTokenReductionPct
|
|
914
|
+
}
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/recommender/task-intent.ts
|
|
919
|
+
function pickTaskRelevantRules(recommendation, task, taskIntents = /* @__PURE__ */ new Set()) {
|
|
920
|
+
const recommended = recommendation?.recommended ?? [];
|
|
921
|
+
if (!task) return recommended;
|
|
922
|
+
if (taskIntents.size > 0) {
|
|
923
|
+
const intentMatches = recommended.filter((rule) => {
|
|
924
|
+
if (rule.selectionMode === "baseline") return false;
|
|
925
|
+
const ruleIntents = computeRuleIntents(rule);
|
|
926
|
+
if (ruleIntents.size === 0) return false;
|
|
927
|
+
for (const ti of taskIntents) {
|
|
928
|
+
if (ruleIntents.has(ti)) return true;
|
|
929
|
+
}
|
|
930
|
+
return false;
|
|
931
|
+
});
|
|
932
|
+
if (intentMatches.length > 0) return intentMatches;
|
|
933
|
+
}
|
|
934
|
+
const tokens = task.toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3);
|
|
935
|
+
const tokenMatches = recommended.filter((rule) => {
|
|
936
|
+
if (rule.selectionMode === "baseline") return false;
|
|
937
|
+
const corpus = [
|
|
938
|
+
rule.id,
|
|
939
|
+
rule.ecosystem ?? "",
|
|
940
|
+
...rule.tags ?? [],
|
|
941
|
+
rule.reason ?? "",
|
|
942
|
+
...rule.reasons.map((r) => r.message)
|
|
943
|
+
].join(" ").toLowerCase();
|
|
944
|
+
return tokens.some((token) => corpus.includes(token));
|
|
945
|
+
});
|
|
946
|
+
if (tokenMatches.length > 0) return tokenMatches;
|
|
947
|
+
const taskWantsTesting = taskIntents.has("testing");
|
|
948
|
+
const cappedMediumOrHigh = recommended.filter((rule) => {
|
|
949
|
+
if (rule.selectionMode === "baseline") return false;
|
|
950
|
+
if (rule.confidenceLevel === "low") return false;
|
|
951
|
+
if (taskWantsTesting) return true;
|
|
952
|
+
const ruleIntents = computeRuleIntents(rule);
|
|
953
|
+
const isTestingOnly = ruleIntents.size > 0 && [...ruleIntents].every((i) => i === "testing");
|
|
954
|
+
return !isTestingOnly;
|
|
955
|
+
});
|
|
956
|
+
return cappedMediumOrHigh.slice(0, 8);
|
|
957
|
+
}
|
|
958
|
+
var ALL_INTENTS = [
|
|
959
|
+
"backend",
|
|
960
|
+
"frontend",
|
|
961
|
+
"admin-ui",
|
|
962
|
+
"storefront",
|
|
963
|
+
"graphql",
|
|
964
|
+
"database",
|
|
965
|
+
"auth",
|
|
966
|
+
"testing",
|
|
967
|
+
"docs",
|
|
968
|
+
"monorepo"
|
|
969
|
+
];
|
|
970
|
+
var TASK_INTENT_KEYWORDS = {
|
|
971
|
+
backend: [
|
|
972
|
+
"api",
|
|
973
|
+
"endpoint",
|
|
974
|
+
"controller",
|
|
975
|
+
"service",
|
|
976
|
+
"queue",
|
|
977
|
+
"job",
|
|
978
|
+
"worker",
|
|
979
|
+
"cron",
|
|
980
|
+
"middleware",
|
|
981
|
+
"resolver",
|
|
982
|
+
"migration",
|
|
983
|
+
"seeder",
|
|
984
|
+
"model",
|
|
985
|
+
"repository",
|
|
986
|
+
"handler",
|
|
987
|
+
"plugin",
|
|
988
|
+
"webhook",
|
|
989
|
+
"schedule",
|
|
990
|
+
"background",
|
|
991
|
+
"consumer",
|
|
992
|
+
"producer",
|
|
993
|
+
"command",
|
|
994
|
+
"nova resource",
|
|
995
|
+
"api mutation",
|
|
996
|
+
"api subscription"
|
|
997
|
+
],
|
|
998
|
+
frontend: [
|
|
999
|
+
"component",
|
|
1000
|
+
"page",
|
|
1001
|
+
"route",
|
|
1002
|
+
"view",
|
|
1003
|
+
"layout",
|
|
1004
|
+
"form",
|
|
1005
|
+
"dashboard",
|
|
1006
|
+
"modal",
|
|
1007
|
+
"navbar",
|
|
1008
|
+
"navigation",
|
|
1009
|
+
"sidebar",
|
|
1010
|
+
"menu",
|
|
1011
|
+
"tailwind",
|
|
1012
|
+
"scss",
|
|
1013
|
+
"style",
|
|
1014
|
+
"theme",
|
|
1015
|
+
"tanstack",
|
|
1016
|
+
"shadcn",
|
|
1017
|
+
"radix",
|
|
1018
|
+
"block",
|
|
1019
|
+
"client component",
|
|
1020
|
+
"server component"
|
|
1021
|
+
],
|
|
1022
|
+
"admin-ui": [
|
|
1023
|
+
"admin",
|
|
1024
|
+
"admin-ui",
|
|
1025
|
+
"admin ui",
|
|
1026
|
+
"backoffice",
|
|
1027
|
+
"back-office",
|
|
1028
|
+
"back office",
|
|
1029
|
+
"nova",
|
|
1030
|
+
"control panel",
|
|
1031
|
+
"wp-admin",
|
|
1032
|
+
"vendure admin"
|
|
1033
|
+
],
|
|
1034
|
+
storefront: [
|
|
1035
|
+
"storefront",
|
|
1036
|
+
"checkout",
|
|
1037
|
+
"cart",
|
|
1038
|
+
"product page",
|
|
1039
|
+
"product listing",
|
|
1040
|
+
"category page",
|
|
1041
|
+
"shop",
|
|
1042
|
+
"ecommerce",
|
|
1043
|
+
"e-commerce",
|
|
1044
|
+
"order page"
|
|
1045
|
+
],
|
|
1046
|
+
graphql: ["graphql", "resolver", "graphql mutation", "graphql subscription", "schema", "codegen"],
|
|
1047
|
+
database: [
|
|
1048
|
+
"database",
|
|
1049
|
+
"migration",
|
|
1050
|
+
"seed",
|
|
1051
|
+
"table",
|
|
1052
|
+
"index",
|
|
1053
|
+
"elasticsearch",
|
|
1054
|
+
"postgres",
|
|
1055
|
+
"mariadb",
|
|
1056
|
+
"mssql",
|
|
1057
|
+
"sql query",
|
|
1058
|
+
"db query"
|
|
1059
|
+
],
|
|
1060
|
+
auth: [
|
|
1061
|
+
"auth",
|
|
1062
|
+
"login",
|
|
1063
|
+
"logout",
|
|
1064
|
+
"oauth",
|
|
1065
|
+
"oidc",
|
|
1066
|
+
"bankid",
|
|
1067
|
+
"azure ad",
|
|
1068
|
+
"session",
|
|
1069
|
+
"jwt",
|
|
1070
|
+
"permission",
|
|
1071
|
+
"rbac",
|
|
1072
|
+
"acl",
|
|
1073
|
+
"guard",
|
|
1074
|
+
"saml"
|
|
1075
|
+
],
|
|
1076
|
+
testing: [
|
|
1077
|
+
"test",
|
|
1078
|
+
"tests",
|
|
1079
|
+
"testing",
|
|
1080
|
+
"spec",
|
|
1081
|
+
"e2e",
|
|
1082
|
+
"unit",
|
|
1083
|
+
"story",
|
|
1084
|
+
"stories",
|
|
1085
|
+
"snapshot",
|
|
1086
|
+
"fixture",
|
|
1087
|
+
"playwright",
|
|
1088
|
+
"cypress",
|
|
1089
|
+
"phpunit",
|
|
1090
|
+
"vitest"
|
|
1091
|
+
],
|
|
1092
|
+
docs: ["doc", "docs", "documentation", "readme", "guide", "tutorial", "changelog"],
|
|
1093
|
+
monorepo: [
|
|
1094
|
+
"lib",
|
|
1095
|
+
"library",
|
|
1096
|
+
"package",
|
|
1097
|
+
"workspace",
|
|
1098
|
+
"shared",
|
|
1099
|
+
"monorepo",
|
|
1100
|
+
"nx",
|
|
1101
|
+
"turbo",
|
|
1102
|
+
"pnpm workspace",
|
|
1103
|
+
"yarn workspace"
|
|
1104
|
+
]
|
|
1105
|
+
};
|
|
1106
|
+
function normalizeTaskForMatching(task) {
|
|
1107
|
+
return ` ${task.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `;
|
|
1108
|
+
}
|
|
1109
|
+
function classifyTaskIntents(task) {
|
|
1110
|
+
const t = normalizeTaskForMatching(task);
|
|
1111
|
+
const intents = /* @__PURE__ */ new Set();
|
|
1112
|
+
for (const intent of ALL_INTENTS) {
|
|
1113
|
+
const keywords = TASK_INTENT_KEYWORDS[intent];
|
|
1114
|
+
for (const kw of keywords) {
|
|
1115
|
+
const needle = ` ${kw} `;
|
|
1116
|
+
if (t.includes(needle)) {
|
|
1117
|
+
intents.add(intent);
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
return intents;
|
|
1123
|
+
}
|
|
1124
|
+
function computeRuleIntents(rule) {
|
|
1125
|
+
const intents = /* @__PURE__ */ new Set();
|
|
1126
|
+
const tags = new Set((rule.tags ?? []).map((t) => t.toLowerCase()));
|
|
1127
|
+
const eco = rule.ecosystem;
|
|
1128
|
+
if (!eco && tags.size === 0) return intents;
|
|
1129
|
+
const isTestingRule = tags.has("playwright") || tags.has("phpunit") || tags.has("testing-library") || tags.has("storybook") || tags.has("testing");
|
|
1130
|
+
if (isTestingRule) {
|
|
1131
|
+
intents.add("testing");
|
|
1132
|
+
return intents;
|
|
1133
|
+
}
|
|
1134
|
+
if (eco === "laravel" || eco === "nestjs" || eco === "dotnet") {
|
|
1135
|
+
intents.add("backend");
|
|
1136
|
+
}
|
|
1137
|
+
if (eco === "vendure") {
|
|
1138
|
+
intents.add("backend");
|
|
1139
|
+
intents.add("admin-ui");
|
|
1140
|
+
}
|
|
1141
|
+
if (eco === "wordpress") {
|
|
1142
|
+
intents.add("backend");
|
|
1143
|
+
intents.add("frontend");
|
|
1144
|
+
intents.add("admin-ui");
|
|
1145
|
+
}
|
|
1146
|
+
if (eco === "nextjs" || eco === "react" || eco === "vue") {
|
|
1147
|
+
intents.add("frontend");
|
|
1148
|
+
intents.add("admin-ui");
|
|
1149
|
+
intents.add("storefront");
|
|
1150
|
+
}
|
|
1151
|
+
if (eco === "nx" || eco === "turbo") {
|
|
1152
|
+
intents.add("monorepo");
|
|
1153
|
+
}
|
|
1154
|
+
if (tags.has("backend")) intents.add("backend");
|
|
1155
|
+
if (tags.has("frontend")) intents.add("frontend");
|
|
1156
|
+
if (tags.has("graphql")) intents.add("graphql");
|
|
1157
|
+
if (tags.has("laravel-nova")) intents.add("admin-ui");
|
|
1158
|
+
if (tags.has("oidc") || tags.has("azure-ad") || tags.has("bankid")) intents.add("auth");
|
|
1159
|
+
if (tags.has("postgresql") || tags.has("mariadb") || tags.has("mssql") || tags.has("elasticsearch")) {
|
|
1160
|
+
intents.add("database");
|
|
1161
|
+
}
|
|
1162
|
+
if (tags.has("nx21") || tags.has("turbo") || tags.has("yarn4") || tags.has("pnpm89")) {
|
|
1163
|
+
intents.add("monorepo");
|
|
1164
|
+
}
|
|
1165
|
+
return intents;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// src/scanner/scan-project.ts
|
|
1169
|
+
import { readFile } from "fs/promises";
|
|
1170
|
+
import path12 from "path";
|
|
1171
|
+
|
|
1172
|
+
// src/utils/audit-checks.ts
|
|
1173
|
+
function isRecord(v) {
|
|
1174
|
+
return typeof v === "object" && v !== null;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// src/utils/versions.ts
|
|
1178
|
+
import semver from "semver";
|
|
1179
|
+
function normalizeVersion(version) {
|
|
1180
|
+
return semver.valid(semver.coerce(version));
|
|
1181
|
+
}
|
|
1182
|
+
function satisfiesVersion(version, range) {
|
|
1183
|
+
const normalized = normalizeVersion(version);
|
|
1184
|
+
if (!normalized) return false;
|
|
1185
|
+
return semver.satisfies(normalized, range, { includePrerelease: true });
|
|
1186
|
+
}
|
|
1187
|
+
function compareVersions(a, b) {
|
|
1188
|
+
const normalizedA = normalizeVersion(a);
|
|
1189
|
+
const normalizedB = normalizeVersion(b);
|
|
1190
|
+
if (!normalizedA || !normalizedB) {
|
|
1191
|
+
throw new Error(`Cannot compare invalid versions: ${a} vs ${b}`);
|
|
1192
|
+
}
|
|
1193
|
+
return semver.compare(normalizedA, normalizedB);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// src/scanner/detect-package-manager.ts
|
|
1197
|
+
import path11 from "path";
|
|
1198
|
+
import fs9 from "fs-extra";
|
|
1199
|
+
function detectPackageManager(root, packageManagerField) {
|
|
1200
|
+
const field = String(packageManagerField ?? "").trim();
|
|
1201
|
+
if (field.startsWith("yarn@")) {
|
|
1202
|
+
const version = field.slice("yarn@".length);
|
|
1203
|
+
if (satisfiesVersion(version, ">=4 <5")) return "yarn";
|
|
1204
|
+
return "unknown";
|
|
1205
|
+
}
|
|
1206
|
+
if (field.startsWith("pnpm@")) {
|
|
1207
|
+
const version = field.slice("pnpm@".length);
|
|
1208
|
+
if (satisfiesVersion(version, ">=8 <10")) return "pnpm";
|
|
1209
|
+
return "unknown";
|
|
1210
|
+
}
|
|
1211
|
+
if (field.startsWith("npm@")) {
|
|
1212
|
+
const version = field.slice("npm@".length);
|
|
1213
|
+
if (satisfiesVersion(version, ">=9")) return "npm";
|
|
1214
|
+
return "unknown";
|
|
1215
|
+
}
|
|
1216
|
+
if (fs9.existsSync(path11.join(root, "yarn.lock"))) return "yarn";
|
|
1217
|
+
if (fs9.existsSync(path11.join(root, "pnpm-lock.yaml"))) return "pnpm";
|
|
1218
|
+
if (fs9.existsSync(path11.join(root, "package-lock.json"))) return "npm";
|
|
1219
|
+
return "unknown";
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// src/scanner/scan-project.ts
|
|
1223
|
+
var SAFE_FILES = [
|
|
1224
|
+
"package.json",
|
|
1225
|
+
"yarn.lock",
|
|
1226
|
+
"pnpm-lock.yaml",
|
|
1227
|
+
"composer.json",
|
|
1228
|
+
"composer.lock",
|
|
1229
|
+
"nx.json",
|
|
1230
|
+
"turbo.json",
|
|
1231
|
+
"tsconfig.json",
|
|
1232
|
+
"vite.config.*",
|
|
1233
|
+
"next.config.*",
|
|
1234
|
+
"tailwind.config.*",
|
|
1235
|
+
"components.json",
|
|
1236
|
+
"playwright.config.*",
|
|
1237
|
+
"phpunit.xml",
|
|
1238
|
+
"artisan",
|
|
1239
|
+
"routes/*.php",
|
|
1240
|
+
"app/Providers/*.php",
|
|
1241
|
+
"schema.graphql",
|
|
1242
|
+
"**/*.graphql",
|
|
1243
|
+
"**/vendure-config.*",
|
|
1244
|
+
"**/*module.ts",
|
|
1245
|
+
"web/app/**",
|
|
1246
|
+
"wp-content/plugins/**",
|
|
1247
|
+
"wp-content/themes/**",
|
|
1248
|
+
"wp-content/mu-plugins/**",
|
|
1249
|
+
"wp-content/acf-json/**",
|
|
1250
|
+
".storybook/**",
|
|
1251
|
+
".env.example",
|
|
1252
|
+
"wp-config.php",
|
|
1253
|
+
"**/*.csproj",
|
|
1254
|
+
"**/*.sln",
|
|
1255
|
+
"docker-compose.*",
|
|
1256
|
+
"Dockerfile"
|
|
1257
|
+
];
|
|
1258
|
+
var SENSITIVE = [
|
|
1259
|
+
/^\.env(\.|$)/,
|
|
1260
|
+
/(^|\/)\.env(\.|$)/,
|
|
1261
|
+
/\.pem$/,
|
|
1262
|
+
/\.key$/,
|
|
1263
|
+
/\.p12$/,
|
|
1264
|
+
/\.pfx$/,
|
|
1265
|
+
/\.sql$/,
|
|
1266
|
+
/\.dump$/,
|
|
1267
|
+
/customer-data/,
|
|
1268
|
+
/exports/,
|
|
1269
|
+
/certs/,
|
|
1270
|
+
/secrets/,
|
|
1271
|
+
/(^|\/)storage\/logs(\/|$)/,
|
|
1272
|
+
/(^|\/)wp-content\/uploads(\/|$)/,
|
|
1273
|
+
/(^|\/)uploads(\/|$)/
|
|
1274
|
+
];
|
|
1275
|
+
function blocked(rel) {
|
|
1276
|
+
return SENSITIVE.some((x) => x.test(rel));
|
|
1277
|
+
}
|
|
1278
|
+
async function scanProject(root, mode = "fast") {
|
|
1279
|
+
const pkg = await readJson(path12.join(root, "package.json"));
|
|
1280
|
+
const composer = await readJson(path12.join(root, "composer.json"));
|
|
1281
|
+
const files = await listFiles(root, SAFE_FILES);
|
|
1282
|
+
const safeFiles = files.filter((f) => !blocked(f));
|
|
1283
|
+
const deps = dependencySet(pkg, composer);
|
|
1284
|
+
const packageManager = detectPackageManager(root, String(pkg?.packageManager ?? ""));
|
|
1285
|
+
const roles = detectRoles(deps, safeFiles);
|
|
1286
|
+
const stacks = await detectStacks(root, deps, safeFiles, packageManager);
|
|
1287
|
+
const warnings = [];
|
|
1288
|
+
const securityRisks = [];
|
|
1289
|
+
const crossRepoHints = [];
|
|
1290
|
+
if (!safeFiles.some((f) => f.endsWith(".env.example"))) warnings.push("No .env.example found");
|
|
1291
|
+
if (!(pkg && isRecord(pkg.scripts) && String(pkg.scripts.test ?? "").length > 0))
|
|
1292
|
+
warnings.push("No package.json test script found");
|
|
1293
|
+
const nodeEngine = isRecord(pkg?.engines) ? String(pkg.engines.node ?? "") : "";
|
|
1294
|
+
if (nodeEngine && !satisfiesVersion(process.version, nodeEngine)) {
|
|
1295
|
+
warnings.push(`Current Node ${process.version} does not satisfy package engine ${nodeEngine}`);
|
|
1296
|
+
}
|
|
1297
|
+
if (safeFiles.some((f) => f.includes("docker-compose"))) crossRepoHints.push("Containerized services detected");
|
|
1298
|
+
if (safeFiles.some((f) => f.includes("turbo.json") || f.includes("nx.json")))
|
|
1299
|
+
crossRepoHints.push("Monorepo orchestration detected");
|
|
1300
|
+
if (!safeFiles.some((f) => f.endsWith(".env.example"))) securityRisks.push("Missing env template");
|
|
1301
|
+
if (safeFiles.some((f) => f.includes("wp-content/uploads"))) securityRisks.push("Uploads directory present");
|
|
1302
|
+
const context = {
|
|
1303
|
+
mode,
|
|
1304
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1305
|
+
root,
|
|
1306
|
+
repoName: String(pkg?.name ?? path12.basename(root)),
|
|
1307
|
+
packageManager,
|
|
1308
|
+
repoRoles: roles,
|
|
1309
|
+
confidence: computeConfidence(roles, stacks),
|
|
1310
|
+
detectedStacks: stacks,
|
|
1311
|
+
dependencies: deps,
|
|
1312
|
+
securityRisks,
|
|
1313
|
+
crossRepoHints,
|
|
1314
|
+
warnings
|
|
1315
|
+
};
|
|
1316
|
+
const dependencyMap = {
|
|
1317
|
+
node: deps.filter((d) => !d.includes("/")),
|
|
1318
|
+
composer: isRecord(composer?.require) ? Object.keys(composer.require) : []
|
|
1319
|
+
};
|
|
1320
|
+
const scanHashes = Object.fromEntries(
|
|
1321
|
+
await Promise.all(safeFiles.map(async (f) => [f, hashText(await readFile(path12.join(root, f), "utf8"))]))
|
|
1322
|
+
);
|
|
1323
|
+
const repoSummary = renderSummary(context);
|
|
1324
|
+
await writeJson(hausPath(root, "context-map.json"), context);
|
|
1325
|
+
await writeJson(hausPath(root, "dependency-map.json"), dependencyMap);
|
|
1326
|
+
await writeJson(hausPath(root, "scan-hashes.json"), scanHashes);
|
|
1327
|
+
await writeText(hausPath(root, "repo-summary.md"), repoSummary);
|
|
1328
|
+
return { ...context, dependencyMap, scanHashes, repoSummary };
|
|
1329
|
+
}
|
|
1330
|
+
function dependencySet(pkg, composer) {
|
|
1331
|
+
const out = /* @__PURE__ */ new Set();
|
|
1332
|
+
const pushObj = (obj) => {
|
|
1333
|
+
if (!isRecord(obj)) return;
|
|
1334
|
+
for (const key of Object.keys(obj)) out.add(key);
|
|
1335
|
+
};
|
|
1336
|
+
pushObj(pkg?.dependencies);
|
|
1337
|
+
pushObj(pkg?.devDependencies);
|
|
1338
|
+
pushObj(composer?.require);
|
|
1339
|
+
pushObj(composer?.["require-dev"]);
|
|
1340
|
+
return [...out].sort();
|
|
1341
|
+
}
|
|
1342
|
+
function detectRoles(deps, files) {
|
|
1343
|
+
const roles = /* @__PURE__ */ new Set();
|
|
1344
|
+
if (deps.includes("next") || files.some((f) => f.includes("next.config."))) roles.add("next-app");
|
|
1345
|
+
if (deps.includes("react")) roles.add("react-app");
|
|
1346
|
+
if (deps.includes("vite") || files.some((f) => f.includes("vite.config."))) roles.add("vite-app");
|
|
1347
|
+
if (deps.includes("@vendure/core")) roles.add("vendure-app");
|
|
1348
|
+
if (deps.some((d) => d.startsWith("@haus/vendure-")) || files.some((f) => f.includes("vendure-config")))
|
|
1349
|
+
roles.add("vendure-plugin");
|
|
1350
|
+
if (deps.includes("@nestjs/core")) roles.add("nestjs-api");
|
|
1351
|
+
if (deps.includes("graphql") || deps.includes("@nestjs/graphql")) roles.add("graphql-api");
|
|
1352
|
+
if (files.some((f) => f.endsWith("nx.json"))) roles.add("nx-monorepo");
|
|
1353
|
+
if (files.some((f) => f.endsWith("turbo.json"))) roles.add("turbo-monorepo");
|
|
1354
|
+
if (files.some((f) => f.endsWith("artisan")) || deps.includes("laravel/framework")) roles.add("laravel-app");
|
|
1355
|
+
if (deps.includes("laravel/nova")) roles.add("laravel-nova-app");
|
|
1356
|
+
if (files.some((f) => f.endsWith("wp-config.php")) && files.some((f) => f.includes("web/app"))) {
|
|
1357
|
+
roles.add("wordpress-bedrock-site");
|
|
1358
|
+
roles.add("wordpress-site");
|
|
1359
|
+
}
|
|
1360
|
+
if (files.some((f) => f.endsWith("wp-config.php")) && !files.some((f) => f.includes("web/app"))) {
|
|
1361
|
+
roles.add("wordpress-vanilla-site");
|
|
1362
|
+
roles.add("wordpress-site");
|
|
1363
|
+
}
|
|
1364
|
+
if (files.some((f) => f.endsWith(".csproj") || f.endsWith(".sln"))) roles.add("dotnet-service");
|
|
1365
|
+
if (deps.includes("express")) roles.add("express-service");
|
|
1366
|
+
return [...roles].sort();
|
|
1367
|
+
}
|
|
1368
|
+
async function detectStacks(root, deps, files, packageManager) {
|
|
1369
|
+
const out = {
|
|
1370
|
+
backend: [],
|
|
1371
|
+
frontend: [],
|
|
1372
|
+
databases: [],
|
|
1373
|
+
testing: [],
|
|
1374
|
+
auth: [],
|
|
1375
|
+
tooling: [],
|
|
1376
|
+
packageManagers: []
|
|
1377
|
+
};
|
|
1378
|
+
const add = (k, v) => {
|
|
1379
|
+
out[k] ??= [];
|
|
1380
|
+
if (!out[k].includes(v)) out[k].push(v);
|
|
1381
|
+
};
|
|
1382
|
+
if (deps.includes("next")) add("frontend", "nextjs");
|
|
1383
|
+
if (deps.includes("react")) add("frontend", "react19");
|
|
1384
|
+
if (deps.includes("vue")) add("frontend", "vue");
|
|
1385
|
+
if (deps.includes("vite")) add("frontend", "vite8");
|
|
1386
|
+
if (deps.includes("@vendure/core")) add("backend", "vendure3");
|
|
1387
|
+
if (deps.includes("@nestjs/core")) add("backend", "nestjs");
|
|
1388
|
+
if (await hasNeedle(root, files, "NestFactory")) add("backend", "nestjs");
|
|
1389
|
+
if (await hasNeedle(root, files, "@VendurePlugin")) add("backend", "vendure3");
|
|
1390
|
+
if (deps.includes("graphql") || deps.includes("@nestjs/graphql")) add("backend", "graphql");
|
|
1391
|
+
if (files.some((f) => f.endsWith(".graphql") || f.endsWith("schema.graphql"))) add("backend", "graphql");
|
|
1392
|
+
if (deps.includes("laravel/framework")) add("backend", "laravel");
|
|
1393
|
+
if (files.some((f) => f.includes("app/Providers/") || f.includes("routes/"))) add("backend", "laravel");
|
|
1394
|
+
if (files.some((f) => f.endsWith("wp-config.php"))) add("backend", "wordpress");
|
|
1395
|
+
if (files.some((f) => f.endsWith(".csproj") || f.endsWith(".sln"))) add("backend", "dotnet");
|
|
1396
|
+
if (deps.includes("@playwright/test")) add("testing", "playwright");
|
|
1397
|
+
if (files.some((f) => f.includes(".storybook"))) add("testing", "storybook");
|
|
1398
|
+
if (deps.some((d) => d.startsWith("@testing-library/"))) add("testing", "testing-library");
|
|
1399
|
+
if (files.some((f) => f.endsWith("phpunit.xml"))) add("testing", "phpunit");
|
|
1400
|
+
if (deps.some((d) => d.startsWith("@storybook/"))) add("testing", "storybook");
|
|
1401
|
+
if (deps.includes("pg")) add("databases", "postgresql");
|
|
1402
|
+
if (deps.includes("mariadb") || deps.includes("mysql2")) add("databases", "mariadb");
|
|
1403
|
+
if (deps.includes("mssql")) add("databases", "mssql");
|
|
1404
|
+
if (deps.includes("@elastic/elasticsearch")) add("databases", "elasticsearch");
|
|
1405
|
+
if (await hasNeedle(root, files, "openid")) add("auth", "oidc");
|
|
1406
|
+
if (await hasNeedle(root, files, "AZURE_AD")) add("auth", "azure-ad");
|
|
1407
|
+
if (await hasNeedle(root, files, "BANKID")) add("auth", "bankid");
|
|
1408
|
+
if (packageManager === "yarn") add("packageManagers", "yarn4");
|
|
1409
|
+
if (packageManager === "pnpm") add("packageManagers", "pnpm89");
|
|
1410
|
+
return out;
|
|
1411
|
+
}
|
|
1412
|
+
async function hasNeedle(root, files, needle) {
|
|
1413
|
+
const candidates = files.filter(
|
|
1414
|
+
(f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
|
|
1415
|
+
);
|
|
1416
|
+
for (const rel of candidates.slice(0, 300)) {
|
|
1417
|
+
try {
|
|
1418
|
+
const content = await readFile(path12.join(root, rel), "utf8");
|
|
1419
|
+
if (content.includes(needle)) return true;
|
|
1420
|
+
} catch {
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return false;
|
|
1425
|
+
}
|
|
1426
|
+
function computeConfidence(roles, stacks) {
|
|
1427
|
+
const stackCount = Object.values(stacks).reduce((sum, arr) => sum + arr.length, 0);
|
|
1428
|
+
if (roles.length === 0) return 0.15;
|
|
1429
|
+
return Math.min(0.99, Number((0.4 + roles.length * 0.08 + stackCount * 0.02).toFixed(2)));
|
|
1430
|
+
}
|
|
1431
|
+
function renderSummary(context) {
|
|
1432
|
+
return `# Repo summary
|
|
1433
|
+
|
|
1434
|
+
- Repo: ${context.repoName}
|
|
1435
|
+
- Package manager: ${context.packageManager}
|
|
1436
|
+
- Roles: ${context.repoRoles.join(", ") || "unknown"}
|
|
1437
|
+
- Generated: ${context.generatedAt}
|
|
1438
|
+
`;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// src/scanner/read-context.ts
|
|
1442
|
+
async function readContextOrScan(root) {
|
|
1443
|
+
const context = await readJson(hausPath(root, "context-map.json"));
|
|
1444
|
+
if (context) return context;
|
|
1445
|
+
const scan = await scanProject(root, "fast");
|
|
1446
|
+
return scan;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
// src/commands/context.ts
|
|
1450
|
+
async function runContext(options) {
|
|
1451
|
+
const root = process.cwd();
|
|
1452
|
+
if (options.fromHook && !await isHookEnabled(root, "context")) {
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
const context = await readContextOrScan(root);
|
|
1456
|
+
const summary = await readText(hausPath(root, "repo-summary.md")) ?? "";
|
|
1457
|
+
const recommendationRaw = await readJson(hausPath(root, "recommendation.json"));
|
|
1458
|
+
const recommendation = recommendationRaw ? normalizeRecommendation(recommendationRaw) : void 0;
|
|
1459
|
+
const rawBreakdownById = new Map(
|
|
1460
|
+
(recommendationRaw?.recommended ?? []).map((item) => [item.id, item.scoreBreakdown])
|
|
1461
|
+
);
|
|
1462
|
+
const taskIntents = options.task ? classifyTaskIntents(options.task) : /* @__PURE__ */ new Set();
|
|
1463
|
+
const selected = pickTaskRelevantRules(recommendation, options.task, taskIntents);
|
|
1464
|
+
const payload = {
|
|
1465
|
+
task: options.task ?? "not provided",
|
|
1466
|
+
taskIntents: [...taskIntents].sort(),
|
|
1467
|
+
roles: context.repoRoles,
|
|
1468
|
+
selectedRules: selected.map((x) => ({
|
|
1469
|
+
id: x.id,
|
|
1470
|
+
confidenceLevel: x.confidenceLevel,
|
|
1471
|
+
selectionMode: x.selectionMode,
|
|
1472
|
+
reasons: x.reasons.map((reason) => reason.message),
|
|
1473
|
+
...options.verbose ? { scoreBreakdown: rawBreakdownById.get(x.id) } : {}
|
|
1474
|
+
})),
|
|
1475
|
+
skippedCount: recommendation?.skippedRules ?? 0,
|
|
1476
|
+
estimatedTokenReductionPct: recommendation?.estimatedTokenReductionPct ?? 0
|
|
1477
|
+
};
|
|
1478
|
+
if (options.json) {
|
|
1479
|
+
log(JSON.stringify(payload, null, 2));
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
const lines = [
|
|
1483
|
+
"# Haus Context",
|
|
1484
|
+
`Task: ${payload.task}`,
|
|
1485
|
+
`Task intents: ${payload.taskIntents.join(", ") || "(none classified)"}`,
|
|
1486
|
+
`Roles: ${payload.roles.join(", ") || "unknown"}`,
|
|
1487
|
+
`Selected rules: ${payload.selectedRules.length}`,
|
|
1488
|
+
`Skipped rules: ${payload.skippedCount}`,
|
|
1489
|
+
`Estimated token reduction: ${payload.estimatedTokenReductionPct}%`,
|
|
1490
|
+
"Use minimal context.",
|
|
1491
|
+
...payload.selectedRules.flatMap((rule) => {
|
|
1492
|
+
const reasonLine = `- ${rule.id}: ${rule.reasons.join(", ")}`;
|
|
1493
|
+
if (!options.verbose) return [reasonLine];
|
|
1494
|
+
const breakdown = rawBreakdownById.get(rule.id);
|
|
1495
|
+
if (!breakdown) return [reasonLine];
|
|
1496
|
+
const bonuses = (breakdown.bonuses ?? []).map(
|
|
1497
|
+
(b) => ` + ${b.code}(+${b.weight})${b.signal ? ` [${b.signal}]` : ""}`
|
|
1498
|
+
);
|
|
1499
|
+
const penalties = (breakdown.penalties ?? []).map(
|
|
1500
|
+
(p) => ` - ${p.code}(${p.penalty})${p.signal ? ` [${p.signal}]` : ""}`
|
|
1501
|
+
);
|
|
1502
|
+
return [reasonLine, ...bonuses, ...penalties];
|
|
1503
|
+
}),
|
|
1504
|
+
summary
|
|
1505
|
+
];
|
|
1506
|
+
const text = lines.join("\n");
|
|
1507
|
+
log(options.fromHook ? text.slice(0, 3e3) : text);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
// src/commands/doctor.ts
|
|
1511
|
+
import path13 from "path";
|
|
1512
|
+
import fs10 from "fs-extra";
|
|
1513
|
+
async function runDoctor(options) {
|
|
1514
|
+
const root = process.cwd();
|
|
1515
|
+
if (options?.hooks) {
|
|
1516
|
+
const hooks2 = await verifyProjectSettingsHooksContract(root);
|
|
1517
|
+
if (hooks2.skipped) {
|
|
1518
|
+
error(`Haus doctor --hooks: ${hooks2.message}`);
|
|
1519
|
+
process.exitCode = 1;
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
if (!hooks2.ok) {
|
|
1523
|
+
error(`Haus doctor --hooks: ${hooks2.message}`);
|
|
1524
|
+
process.exitCode = 1;
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
log(`Haus doctor --hooks: ${hooks2.message}`);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const context = await readContextOrScan(root);
|
|
1531
|
+
const recommendation = await readJson(
|
|
1532
|
+
hausPath(root, "recommendation.json")
|
|
1533
|
+
);
|
|
1534
|
+
log("Haus Doctor");
|
|
1535
|
+
log(`Repo: ${context.repoName}`);
|
|
1536
|
+
log(`Roles: ${context.repoRoles.join(", ") || "unknown"}`);
|
|
1537
|
+
log(`Package manager: ${context.packageManager}`);
|
|
1538
|
+
log(`Recommended items: ${recommendation?.recommended?.length ?? 0}`);
|
|
1539
|
+
const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation?.warnings ?? []])];
|
|
1540
|
+
for (const warning of warningLines) {
|
|
1541
|
+
log(`- WARN: ${warning}`);
|
|
1542
|
+
}
|
|
1543
|
+
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
1544
|
+
if (hooks.skipped) {
|
|
1545
|
+
log(`- HOOKS: (skipped) ${hooks.message}`);
|
|
1546
|
+
} else if (!hooks.ok) {
|
|
1547
|
+
log(`- HOOKS FAIL: ${hooks.message}`);
|
|
1548
|
+
process.exitCode = 1;
|
|
1549
|
+
} else {
|
|
1550
|
+
log(`- HOOKS OK: ${hooks.message}`);
|
|
1551
|
+
}
|
|
1552
|
+
const gatedHooks = ["context", "memoryInject"];
|
|
1553
|
+
for (const key of gatedHooks) {
|
|
1554
|
+
const enabled = await isHookEnabled(root, key);
|
|
1555
|
+
log(`- HOOK ${key}: ${enabled ? "enabled" : "disabled (default)"}`);
|
|
1556
|
+
}
|
|
1557
|
+
const rootClaudeMdPath = path13.join(root, "CLAUDE.md");
|
|
1558
|
+
const rootClaudeMdContent = await readText(rootClaudeMdPath);
|
|
1559
|
+
if (!rootClaudeMdContent) {
|
|
1560
|
+
warn("- CLAUDE.md: missing (run `haus apply --write` to create)");
|
|
1561
|
+
} else if (!rootClaudeMdContent.includes(BLOCK_BEGIN)) {
|
|
1562
|
+
warn("- CLAUDE.md: haus import block missing (run `haus apply --write` to add)");
|
|
1563
|
+
} else {
|
|
1564
|
+
log("- CLAUDE.md: import block present");
|
|
1565
|
+
}
|
|
1566
|
+
const wayOfWorkPath = hausPath(root, "haus-way-of-work.md");
|
|
1567
|
+
const wayOfWorkExists = await fs10.pathExists(wayOfWorkPath);
|
|
1568
|
+
if (!wayOfWorkExists) {
|
|
1569
|
+
warn("- .haus-workflow/haus-way-of-work.md: missing (run `haus apply --write`)");
|
|
1570
|
+
} else {
|
|
1571
|
+
const wayOfWorkContent = await readText(wayOfWorkPath);
|
|
1572
|
+
const firstLine = wayOfWorkContent?.split("\n")[0] ?? "";
|
|
1573
|
+
if (!firstLine.includes("HAUS-MANAGED")) {
|
|
1574
|
+
warn("- .haus-workflow/haus-way-of-work.md: no HAUS-MANAGED header (user-owned)");
|
|
1575
|
+
} else {
|
|
1576
|
+
const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
|
|
1577
|
+
const templatePath = path13.join(packageRoot(), "library", "global", "templates", "haus-way-of-work.md");
|
|
1578
|
+
const templateContent = await readText(templatePath);
|
|
1579
|
+
if (storedHashMatch && templateContent) {
|
|
1580
|
+
const currentHash = hashText(templateContent);
|
|
1581
|
+
if (storedHashMatch[1] !== currentHash) {
|
|
1582
|
+
warn("- .haus-workflow/haus-way-of-work.md: stale (template updated \u2014 run `haus apply --write`)");
|
|
1583
|
+
} else {
|
|
1584
|
+
log("- .haus-workflow/haus-way-of-work.md: OK");
|
|
1585
|
+
}
|
|
1586
|
+
} else {
|
|
1587
|
+
log("- .haus-workflow/haus-way-of-work.md: OK");
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
const projectMdPath = hausPath(root, "project.md");
|
|
1592
|
+
const projectMdExists = await fs10.pathExists(projectMdPath);
|
|
1593
|
+
if (!projectMdExists) {
|
|
1594
|
+
warn("- .haus-workflow/project.md: missing (run `haus apply --write`)");
|
|
1595
|
+
} else {
|
|
1596
|
+
const projectMdContent = await readText(projectMdPath);
|
|
1597
|
+
const hasHeader = projectMdContent?.split("\n")[0]?.includes("HAUS-MANAGED") ?? false;
|
|
1598
|
+
if (!hasHeader) {
|
|
1599
|
+
warn("- .haus-workflow/project.md: no HAUS-MANAGED header (user-owned)");
|
|
1600
|
+
} else {
|
|
1601
|
+
log("- .haus-workflow/project.md: OK");
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
const cacheAgeMs = await getCacheManifestAge();
|
|
1605
|
+
if (cacheAgeMs === null) {
|
|
1606
|
+
warn("- CATALOG CACHE: absent (run `haus update` to populate)");
|
|
1607
|
+
} else {
|
|
1608
|
+
const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
|
|
1609
|
+
if (cacheAgeDays >= 7) {
|
|
1610
|
+
warn(`- CATALOG CACHE: stale (${cacheAgeDays}d old \u2014 run \`haus update\`)`);
|
|
1611
|
+
} else {
|
|
1612
|
+
log(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// src/recommender/explain-formatters.ts
|
|
1618
|
+
function formatReasonWithSignal(reason) {
|
|
1619
|
+
return reason.signal ? `${reason.message} (${reason.signal})` : reason.message;
|
|
1620
|
+
}
|
|
1621
|
+
function formatRecommendationHuman(rec) {
|
|
1622
|
+
const lines = [];
|
|
1623
|
+
lines.push("Recommendation explanation");
|
|
1624
|
+
lines.push(` mode: ${rec.mode}`);
|
|
1625
|
+
lines.push(
|
|
1626
|
+
` selected: ${rec.selectedRules} | skipped: ${rec.skippedRules} | estimated token reduction: ${rec.estimatedTokenReductionPct}%`
|
|
1627
|
+
);
|
|
1628
|
+
if (rec.warnings.length > 0) {
|
|
1629
|
+
lines.push(" warnings:");
|
|
1630
|
+
for (const warning of rec.warnings) lines.push(` - ${warning}`);
|
|
1631
|
+
}
|
|
1632
|
+
lines.push("");
|
|
1633
|
+
lines.push("Selected");
|
|
1634
|
+
if (rec.recommended.length === 0) lines.push(" (none)");
|
|
1635
|
+
for (const item of rec.recommended) {
|
|
1636
|
+
lines.push(`- ${item.id}`);
|
|
1637
|
+
lines.push(` confidence: ${item.confidenceLevel} (${item.confidence.toFixed(2)})`);
|
|
1638
|
+
lines.push(` selection: ${item.selectionMode}`);
|
|
1639
|
+
lines.push(" why:");
|
|
1640
|
+
for (const reason of item.reasons) lines.push(` - ${formatReasonWithSignal(reason)}`);
|
|
1641
|
+
}
|
|
1642
|
+
lines.push("");
|
|
1643
|
+
lines.push("Skipped");
|
|
1644
|
+
if (rec.skipped.length === 0) lines.push(" (none)");
|
|
1645
|
+
for (const item of rec.skipped) {
|
|
1646
|
+
lines.push(`- ${item.id}`);
|
|
1647
|
+
lines.push(" why:");
|
|
1648
|
+
for (const reason of item.skipReasons) lines.push(` - ${formatReasonWithSignal(reason)}`);
|
|
1649
|
+
}
|
|
1650
|
+
return lines.join("\n");
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// src/commands/explain-recommendation.ts
|
|
1654
|
+
async function runExplainRecommendation(options) {
|
|
1655
|
+
const root = process.cwd();
|
|
1656
|
+
const rec = await readJson(hausPath(root, "recommendation.json"));
|
|
1657
|
+
if (!rec) {
|
|
1658
|
+
throw new Error("No recommendation found. Run `haus recommend` first.");
|
|
1659
|
+
}
|
|
1660
|
+
const normalized = normalizeRecommendation(rec);
|
|
1661
|
+
if (options.json) {
|
|
1662
|
+
const explanation = buildRecommendationExplanation(normalized);
|
|
1663
|
+
log(JSON.stringify(explanation, null, 2));
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
log(formatRecommendationHuman(normalized));
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// src/commands/guard.ts
|
|
1670
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
1671
|
+
|
|
1672
|
+
// src/security/dangerous-commands.ts
|
|
1673
|
+
var DANGEROUS_COMMANDS = [
|
|
1674
|
+
"rm -rf",
|
|
1675
|
+
"sudo",
|
|
1676
|
+
"chmod -R 777",
|
|
1677
|
+
"chown -R",
|
|
1678
|
+
"git push --force",
|
|
1679
|
+
"git reset --hard",
|
|
1680
|
+
"docker system prune",
|
|
1681
|
+
"drop database",
|
|
1682
|
+
"truncate table",
|
|
1683
|
+
"php artisan migrate --force",
|
|
1684
|
+
"npm publish",
|
|
1685
|
+
"yarn npm publish",
|
|
1686
|
+
"pnpm publish"
|
|
1687
|
+
];
|
|
1688
|
+
|
|
1689
|
+
// src/security/guard-bash.ts
|
|
1690
|
+
function guardBash(command) {
|
|
1691
|
+
const matched = DANGEROUS_COMMANDS.find((token) => command.includes(token));
|
|
1692
|
+
if (matched) return `Blocked dangerous command: ${command}`;
|
|
1693
|
+
return void 0;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// src/security/sensitive-paths.ts
|
|
1697
|
+
var SENSITIVE_PATHS = [
|
|
1698
|
+
".env",
|
|
1699
|
+
".env.*",
|
|
1700
|
+
"*.pem",
|
|
1701
|
+
"*.key",
|
|
1702
|
+
"*.p12",
|
|
1703
|
+
"*.pfx",
|
|
1704
|
+
"id_rsa",
|
|
1705
|
+
"id_ed25519",
|
|
1706
|
+
"*.sql",
|
|
1707
|
+
"*.dump",
|
|
1708
|
+
"*.backup",
|
|
1709
|
+
"*.bak",
|
|
1710
|
+
"storage/logs",
|
|
1711
|
+
"wp-content/uploads",
|
|
1712
|
+
"uploads",
|
|
1713
|
+
"customer-data",
|
|
1714
|
+
"exports",
|
|
1715
|
+
"secrets",
|
|
1716
|
+
"certs"
|
|
1717
|
+
];
|
|
1718
|
+
|
|
1719
|
+
// src/security/guard-file-access.ts
|
|
1720
|
+
function guardFileAccess(candidate) {
|
|
1721
|
+
const matched = SENSITIVE_PATHS.find((token) => candidate.includes(token.replace("*", "")));
|
|
1722
|
+
if (matched) return `Blocked sensitive path: ${candidate}`;
|
|
1723
|
+
return void 0;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// src/commands/guard.ts
|
|
1727
|
+
function stdin() {
|
|
1728
|
+
try {
|
|
1729
|
+
if (process.stdin.isTTY) return "";
|
|
1730
|
+
return readFileSync2(0, "utf8");
|
|
1731
|
+
} catch {
|
|
1732
|
+
return "";
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
function deny(reason) {
|
|
1736
|
+
log(JSON.stringify({ permissionDecision: "deny", permissionDecisionReason: reason }));
|
|
1737
|
+
}
|
|
1738
|
+
async function runGuard(kind, _options) {
|
|
1739
|
+
const raw = stdin();
|
|
1740
|
+
let payload = {};
|
|
1741
|
+
if (raw) {
|
|
1742
|
+
try {
|
|
1743
|
+
const parsed = JSON.parse(raw);
|
|
1744
|
+
if (isRecord(parsed)) payload = parsed;
|
|
1745
|
+
} catch {
|
|
1746
|
+
deny("Malformed hook payload");
|
|
1747
|
+
process.exitCode = 1;
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
const toolInput = isRecord(payload.tool_input) ? payload.tool_input : {};
|
|
1752
|
+
if (kind === "file-access") {
|
|
1753
|
+
const candidate = String(toolInput.path ?? toolInput.file_path ?? "");
|
|
1754
|
+
if (guardFileAccess(candidate)) {
|
|
1755
|
+
deny(`Blocked sensitive path: ${candidate}`);
|
|
1756
|
+
process.exitCode = 1;
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const command = String(toolInput.command ?? "");
|
|
1762
|
+
if (guardBash(command) || DANGEROUS_COMMANDS.some((token) => command.includes(token))) {
|
|
1763
|
+
deny(`Blocked dangerous command: ${command}`);
|
|
1764
|
+
process.exitCode = 1;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// src/commands/init.ts
|
|
1769
|
+
import path14 from "path";
|
|
1770
|
+
import fs11 from "fs-extra";
|
|
1771
|
+
|
|
1772
|
+
// src/utils/exec.ts
|
|
1773
|
+
import { execa } from "execa";
|
|
1774
|
+
async function runCommand(command, args = [], options = {}) {
|
|
1775
|
+
try {
|
|
1776
|
+
const result = await execa(command, args, {
|
|
1777
|
+
reject: false,
|
|
1778
|
+
...options
|
|
1779
|
+
});
|
|
1780
|
+
return {
|
|
1781
|
+
command,
|
|
1782
|
+
args,
|
|
1783
|
+
stdout: String(result.stdout ?? ""),
|
|
1784
|
+
stderr: String(result.stderr ?? ""),
|
|
1785
|
+
exitCode: result.exitCode ?? 0
|
|
1786
|
+
};
|
|
1787
|
+
} catch (error2) {
|
|
1788
|
+
const message = error2 instanceof Error ? error2.message : String(error2);
|
|
1789
|
+
throw new Error(`Failed to run command: ${command} ${args.join(" ")} (${message})`);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
async function runGit(args, options = {}) {
|
|
1793
|
+
return runCommand("git", args, options);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// src/recommender/recommend.ts
|
|
1797
|
+
var UNSUPPORTED = [
|
|
1798
|
+
"python",
|
|
1799
|
+
"django",
|
|
1800
|
+
"go",
|
|
1801
|
+
"rust",
|
|
1802
|
+
"java",
|
|
1803
|
+
"spring",
|
|
1804
|
+
"kotlin",
|
|
1805
|
+
"swift",
|
|
1806
|
+
"android",
|
|
1807
|
+
"flutter",
|
|
1808
|
+
"dart",
|
|
1809
|
+
"c++",
|
|
1810
|
+
"perl",
|
|
1811
|
+
"defi",
|
|
1812
|
+
"trading"
|
|
1813
|
+
];
|
|
1814
|
+
var SENSITIVE2 = [".env", "secrets", "certs", "customer-data", "exports", ".pem", ".key"];
|
|
1815
|
+
var ECOSYSTEM_GROUPS = {
|
|
1816
|
+
laravel: ["laravel-app", "laravel-nova-app"],
|
|
1817
|
+
wordpress: ["wordpress-site", "wordpress-bedrock-site", "wordpress-vanilla-site"],
|
|
1818
|
+
vendure: ["vendure-app", "vendure-plugin"],
|
|
1819
|
+
nestjs: ["nestjs-api"],
|
|
1820
|
+
nextjs: ["next-app"],
|
|
1821
|
+
react: ["react-app", "next-app", "design-system"],
|
|
1822
|
+
vue: ["vue-app"],
|
|
1823
|
+
dotnet: ["dotnet-service"],
|
|
1824
|
+
nx: ["nx-monorepo"],
|
|
1825
|
+
turbo: ["turbo-monorepo"]
|
|
1826
|
+
};
|
|
1827
|
+
var ECOSYSTEM_PRIMARY_BACKENDS = /* @__PURE__ */ new Set(["laravel", "wordpress", "vendure", "nestjs", "dotnet"]);
|
|
1828
|
+
var ECOSYSTEM_COMPATIBLE_BACKENDS = {
|
|
1829
|
+
vendure: /* @__PURE__ */ new Set(["vendure", "nestjs"]),
|
|
1830
|
+
nestjs: /* @__PURE__ */ new Set(["nestjs"]),
|
|
1831
|
+
laravel: /* @__PURE__ */ new Set(["laravel"]),
|
|
1832
|
+
wordpress: /* @__PURE__ */ new Set(["wordpress"]),
|
|
1833
|
+
dotnet: /* @__PURE__ */ new Set(["dotnet"])
|
|
1834
|
+
};
|
|
1835
|
+
async function recommend(root, context) {
|
|
1836
|
+
const items = await loadCatalog(root);
|
|
1837
|
+
const setupAnswers = await readJson(hausPath(root, "setup-answers.json")) ?? {};
|
|
1838
|
+
const sources = await readJson(hausPath(root, "sources-report.json")) ?? {};
|
|
1839
|
+
const stackSet = buildStackSet(context);
|
|
1840
|
+
const depSet = new Set(context.dependencies.map((d) => d.toLowerCase()));
|
|
1841
|
+
const roleSet = new Set(context.repoRoles.map((r) => r.toLowerCase()));
|
|
1842
|
+
const repoEcosystems = inferRepoEcosystems(context.repoRoles);
|
|
1843
|
+
const dominantBackendEcosystem = pickDominantBackend(repoEcosystems);
|
|
1844
|
+
const recommended = [];
|
|
1845
|
+
const skipped = [];
|
|
1846
|
+
const goals = Object.values(setupAnswers).join(" ").toLowerCase();
|
|
1847
|
+
const sourceTrust = new Map((sources.items ?? []).map((x) => [x.id, x.status ?? "candidate"]));
|
|
1848
|
+
const changedFiles = await readChangedFiles(root);
|
|
1849
|
+
const securityRiskCount = context.securityRisks?.length ?? 0;
|
|
1850
|
+
for (const item of items) {
|
|
1851
|
+
const blob = `${item.id} ${item.tags.join(" ")}`.toLowerCase();
|
|
1852
|
+
if (UNSUPPORTED.some((x) => blob.includes(x))) {
|
|
1853
|
+
skipped.push({
|
|
1854
|
+
id: item.id,
|
|
1855
|
+
reason: "Unsupported stack policy",
|
|
1856
|
+
skipReasons: [
|
|
1857
|
+
{
|
|
1858
|
+
code: "unsupported-policy",
|
|
1859
|
+
message: "Unsupported stack policy",
|
|
1860
|
+
penalty: 100
|
|
1861
|
+
}
|
|
1862
|
+
]
|
|
1863
|
+
});
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
if (item.source === "curated") {
|
|
1867
|
+
const rs = item.reviewStatus;
|
|
1868
|
+
if (!rs || rs !== "approved") {
|
|
1869
|
+
skipped.push({
|
|
1870
|
+
id: item.id,
|
|
1871
|
+
reason: `Curated item not approved (reviewStatus=${rs ?? "unset"})`,
|
|
1872
|
+
skipReasons: [
|
|
1873
|
+
{
|
|
1874
|
+
code: "curated-not-approved",
|
|
1875
|
+
message: `Curated item requires reviewStatus:approved (got ${rs ?? "unset"})`,
|
|
1876
|
+
penalty: 100,
|
|
1877
|
+
signal: `reviewStatus:${rs ?? "unset"}`
|
|
1878
|
+
}
|
|
1879
|
+
]
|
|
1880
|
+
});
|
|
1881
|
+
continue;
|
|
1882
|
+
}
|
|
1883
|
+
if (item.riskLevel === "blocked") {
|
|
1884
|
+
skipped.push({
|
|
1885
|
+
id: item.id,
|
|
1886
|
+
reason: "Curated item risk level is blocked",
|
|
1887
|
+
skipReasons: [
|
|
1888
|
+
{
|
|
1889
|
+
code: "curated-risk-blocked",
|
|
1890
|
+
message: "Curated item riskLevel is blocked",
|
|
1891
|
+
penalty: 100,
|
|
1892
|
+
signal: "riskLevel:blocked"
|
|
1893
|
+
}
|
|
1894
|
+
]
|
|
1895
|
+
});
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
const isDefaultBaseline = item.default === true;
|
|
1900
|
+
const reasons = [];
|
|
1901
|
+
const skipReasons = [];
|
|
1902
|
+
let score = 0;
|
|
1903
|
+
const pushReason = (code, message, weight, signal) => {
|
|
1904
|
+
score += weight;
|
|
1905
|
+
reasons.push({ code, message, weight, signal });
|
|
1906
|
+
};
|
|
1907
|
+
const pushSkipReason = (code, message, penalty, signal) => {
|
|
1908
|
+
score -= penalty;
|
|
1909
|
+
skipReasons.push({ code, message, penalty, signal });
|
|
1910
|
+
};
|
|
1911
|
+
if (isDefaultBaseline) {
|
|
1912
|
+
pushReason("default-baseline", "catalog default baseline", 25, "policy:default");
|
|
1913
|
+
}
|
|
1914
|
+
const roleMatch = item.repoRoles.find((r) => roleSet.has(r.toLowerCase()));
|
|
1915
|
+
if (roleMatch) {
|
|
1916
|
+
pushReason("repo-role-match", "repo role match", 40, `role:${roleMatch}`);
|
|
1917
|
+
}
|
|
1918
|
+
const tagMatch = item.tags.find((t) => stackSet.has(t.toLowerCase()));
|
|
1919
|
+
if (tagMatch) {
|
|
1920
|
+
pushReason("stack-match", "stack/dependency match", 30, `tag:${tagMatch}`);
|
|
1921
|
+
}
|
|
1922
|
+
const goalMatch = item.tags.find((t) => goals.includes(t) || goals.includes(t.replace(/-/g, " ")));
|
|
1923
|
+
if (goalMatch) {
|
|
1924
|
+
pushReason("goal-match", "guided goal match", 15, `goal:${goalMatch}`);
|
|
1925
|
+
}
|
|
1926
|
+
if (item.tags.includes(context.packageManager) || item.tags.includes(`${context.packageManager}4`) || item.tags.includes(`${context.packageManager}89`)) {
|
|
1927
|
+
pushReason("package-manager-match", "package manager match", 10, `packageManager:${context.packageManager}`);
|
|
1928
|
+
}
|
|
1929
|
+
const configSignal = item.tags.find((t) => context.warnings.join(" ").toLowerCase().includes(t.toLowerCase()));
|
|
1930
|
+
if (configSignal) {
|
|
1931
|
+
pushReason("config-signal-match", "config signal match", 20, `warning:${configSignal}`);
|
|
1932
|
+
}
|
|
1933
|
+
const changedMatch = changedFiles.find((f) => f.includes(item.id.split(".").pop() ?? ""));
|
|
1934
|
+
if (changedMatch) {
|
|
1935
|
+
pushReason("changed-file-match", "changed file match", 10, `changedFile:${changedMatch}`);
|
|
1936
|
+
}
|
|
1937
|
+
if (item.id === "haus.nx21-monorepo-patterns" && !roleSet.has("nx-monorepo")) {
|
|
1938
|
+
skipped.push({
|
|
1939
|
+
id: item.id,
|
|
1940
|
+
reason: "Required role missing: nx-monorepo",
|
|
1941
|
+
skipReasons: [
|
|
1942
|
+
{
|
|
1943
|
+
code: "required-role-missing",
|
|
1944
|
+
message: "Required role missing: nx-monorepo",
|
|
1945
|
+
penalty: 100,
|
|
1946
|
+
signal: "role:nx-monorepo"
|
|
1947
|
+
}
|
|
1948
|
+
]
|
|
1949
|
+
});
|
|
1950
|
+
continue;
|
|
1951
|
+
}
|
|
1952
|
+
if (item.id === "haus.turbo-monorepo-patterns" && !roleSet.has("turbo-monorepo")) {
|
|
1953
|
+
skipped.push({
|
|
1954
|
+
id: item.id,
|
|
1955
|
+
reason: "Required role missing: turbo-monorepo",
|
|
1956
|
+
skipReasons: [
|
|
1957
|
+
{
|
|
1958
|
+
code: "required-role-missing",
|
|
1959
|
+
message: "Required role missing: turbo-monorepo",
|
|
1960
|
+
penalty: 100,
|
|
1961
|
+
signal: "role:turbo-monorepo"
|
|
1962
|
+
}
|
|
1963
|
+
]
|
|
1964
|
+
});
|
|
1965
|
+
continue;
|
|
1966
|
+
}
|
|
1967
|
+
const requiresAny = item.requiresAny ?? [];
|
|
1968
|
+
if (requiresAny.length > 0) {
|
|
1969
|
+
const satisfied = matchRequiresAny(requiresAny, {
|
|
1970
|
+
stackSet,
|
|
1971
|
+
depSet,
|
|
1972
|
+
roleSet
|
|
1973
|
+
});
|
|
1974
|
+
if (!satisfied.matched) {
|
|
1975
|
+
const description = describeRequiresAny(requiresAny);
|
|
1976
|
+
skipped.push({
|
|
1977
|
+
id: item.id,
|
|
1978
|
+
reason: `requiresAny unsatisfied: needs ${description}`,
|
|
1979
|
+
skipReasons: [
|
|
1980
|
+
{
|
|
1981
|
+
code: "requires-any-unsatisfied",
|
|
1982
|
+
message: `requiresAny unsatisfied: needs ${description}`,
|
|
1983
|
+
penalty: 100,
|
|
1984
|
+
signal: description
|
|
1985
|
+
}
|
|
1986
|
+
]
|
|
1987
|
+
});
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
if (!reasons.some((r) => r.code === "stack-match")) {
|
|
1991
|
+
pushReason("requires-any-match", "requires-any signal match", 25, satisfied.signal);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
if (item.ecosystem && dominantBackendEcosystem && isBackendEcosystem(item.ecosystem)) {
|
|
1995
|
+
const compat = ECOSYSTEM_COMPATIBLE_BACKENDS[dominantBackendEcosystem] ?? /* @__PURE__ */ new Set([dominantBackendEcosystem]);
|
|
1996
|
+
if (!compat.has(item.ecosystem)) {
|
|
1997
|
+
pushSkipReason(
|
|
1998
|
+
"ecosystem-conflict",
|
|
1999
|
+
`ecosystem conflict: rule ecosystem=${item.ecosystem} but repo dominant backend=${dominantBackendEcosystem}`,
|
|
2000
|
+
40,
|
|
2001
|
+
`ecosystem:${item.ecosystem}->${dominantBackendEcosystem}`
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
if (SENSITIVE2.some((x) => blob.includes(x))) {
|
|
2006
|
+
pushSkipReason("sensitive-policy", "Sensitive content policy block", 100);
|
|
2007
|
+
}
|
|
2008
|
+
const trust = sourceTrust.get(item.source);
|
|
2009
|
+
if (trust === "candidate" || trust === "rejected") {
|
|
2010
|
+
pushSkipReason("source-trust", "Source trust policy block", 100);
|
|
2011
|
+
}
|
|
2012
|
+
if (item.source && item.source !== "haus" && trust !== "approved") {
|
|
2013
|
+
pushSkipReason("source-approval", "Source not approved", 100);
|
|
2014
|
+
}
|
|
2015
|
+
if (securityRiskCount > 0 && !isDefaultBaseline && (item.tags.includes("security") || item.id.includes("security"))) {
|
|
2016
|
+
pushSkipReason("security-risk-penalty", "Security-tagged item penalized by active risk signals", 20);
|
|
2017
|
+
}
|
|
2018
|
+
const positiveReasonCodes = new Set(reasons.map((r) => r.code).filter((c) => c !== "default-baseline"));
|
|
2019
|
+
const hasRoleSignal = positiveReasonCodes.has("repo-role-match");
|
|
2020
|
+
const hasDepOrStackSignal = positiveReasonCodes.has("stack-match") || positiveReasonCodes.has("requires-any-match");
|
|
2021
|
+
if (hasRoleSignal && !hasDepOrStackSignal && !isDefaultBaseline && requiresAny.length === 0) {
|
|
2022
|
+
pushSkipReason(
|
|
2023
|
+
"role-only-bleed-guard",
|
|
2024
|
+
"role match without dep/stack signal (role-only bleed)",
|
|
2025
|
+
25,
|
|
2026
|
+
roleMatch ? `role:${roleMatch}` : void 0
|
|
2027
|
+
);
|
|
2028
|
+
}
|
|
2029
|
+
const minScore = isDefaultBaseline ? 1 : 40;
|
|
2030
|
+
if (score >= minScore) {
|
|
2031
|
+
const confidenceLevel = computeConfidenceLevel({
|
|
2032
|
+
isDefaultBaseline,
|
|
2033
|
+
reasons,
|
|
2034
|
+
hasEcosystemConflict: skipReasons.some((s) => s.code === "ecosystem-conflict"),
|
|
2035
|
+
score
|
|
2036
|
+
});
|
|
2037
|
+
const confidence = confidenceLevelToNumber(confidenceLevel, score);
|
|
2038
|
+
recommended.push({
|
|
2039
|
+
id: item.id,
|
|
2040
|
+
type: item.type,
|
|
2041
|
+
reason: reasons.length ? reasons.map((x) => x.message).join(", ") : `score=${score}`,
|
|
2042
|
+
reasons,
|
|
2043
|
+
confidence,
|
|
2044
|
+
confidenceLevel,
|
|
2045
|
+
selectionMode: isDefaultBaseline && reasons.every((r) => r.code === "default-baseline") ? "baseline" : "matched",
|
|
2046
|
+
install: true,
|
|
2047
|
+
score,
|
|
2048
|
+
scoreBreakdown: {
|
|
2049
|
+
bonuses: reasons,
|
|
2050
|
+
penalties: skipReasons,
|
|
2051
|
+
finalScore: score
|
|
2052
|
+
},
|
|
2053
|
+
tags: item.tags,
|
|
2054
|
+
ecosystem: item.ecosystem
|
|
2055
|
+
});
|
|
2056
|
+
} else {
|
|
2057
|
+
if (skipReasons.length === 0) {
|
|
2058
|
+
skipReasons.push({
|
|
2059
|
+
code: "no-role-stack-match",
|
|
2060
|
+
message: "No role/stack match",
|
|
2061
|
+
penalty: 0
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
2064
|
+
const primary = skipReasons[0];
|
|
2065
|
+
skipped.push({ id: item.id, reason: primary.message, skipReasons });
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
recommended.sort((a, b) => a.id.localeCompare(b.id));
|
|
2069
|
+
skipped.sort((a, b) => a.id.localeCompare(b.id));
|
|
2070
|
+
const estimatedContextTokens = recommended.length * 320;
|
|
2071
|
+
const selectedRules = recommended.length;
|
|
2072
|
+
const skippedRules = skipped.length;
|
|
2073
|
+
const estimatedTokenReductionPct = Math.max(
|
|
2074
|
+
0,
|
|
2075
|
+
Math.round(skippedRules / Math.max(selectedRules + skippedRules, 1) * 100)
|
|
2076
|
+
);
|
|
2077
|
+
return {
|
|
2078
|
+
mode: context.mode,
|
|
2079
|
+
recommended,
|
|
2080
|
+
skipped,
|
|
2081
|
+
warnings: mergeRecommendationWarnings(context),
|
|
2082
|
+
estimatedContextTokens,
|
|
2083
|
+
selectedRules,
|
|
2084
|
+
skippedRules,
|
|
2085
|
+
estimatedTokenReductionPct
|
|
2086
|
+
};
|
|
2087
|
+
}
|
|
2088
|
+
function buildStackSet(context) {
|
|
2089
|
+
return new Set([...context.repoRoles, ...Object.values(context.detectedStacks).flat()].map((x) => x.toLowerCase()));
|
|
2090
|
+
}
|
|
2091
|
+
function inferRepoEcosystems(roles) {
|
|
2092
|
+
const ecosystems = /* @__PURE__ */ new Set();
|
|
2093
|
+
for (const [eco, roleList] of Object.entries(ECOSYSTEM_GROUPS)) {
|
|
2094
|
+
if (roleList.some((r) => roles.includes(r))) ecosystems.add(eco);
|
|
2095
|
+
}
|
|
2096
|
+
return [...ecosystems];
|
|
2097
|
+
}
|
|
2098
|
+
function pickDominantBackend(ecosystems) {
|
|
2099
|
+
for (const eco of ecosystems) {
|
|
2100
|
+
if (ECOSYSTEM_PRIMARY_BACKENDS.has(eco)) return eco;
|
|
2101
|
+
}
|
|
2102
|
+
return void 0;
|
|
2103
|
+
}
|
|
2104
|
+
function isBackendEcosystem(eco) {
|
|
2105
|
+
return ECOSYSTEM_PRIMARY_BACKENDS.has(eco);
|
|
2106
|
+
}
|
|
2107
|
+
function matchRequiresAny(clauses, ctx) {
|
|
2108
|
+
for (const clause of clauses) {
|
|
2109
|
+
if ("stack" in clause) {
|
|
2110
|
+
if (ctx.stackSet.has(clause.stack.toLowerCase())) {
|
|
2111
|
+
return { matched: true, signal: `stack:${clause.stack}` };
|
|
2112
|
+
}
|
|
2113
|
+
} else if ("dependency" in clause) {
|
|
2114
|
+
if (ctx.depSet.has(clause.dependency.toLowerCase())) {
|
|
2115
|
+
return { matched: true, signal: `dependency:${clause.dependency}` };
|
|
2116
|
+
}
|
|
2117
|
+
} else if ("packageNamePattern" in clause) {
|
|
2118
|
+
const pattern = clause.packageNamePattern.toLowerCase();
|
|
2119
|
+
const prefix = pattern.endsWith("*") ? pattern.slice(0, -1) : pattern;
|
|
2120
|
+
for (const dep of ctx.depSet) {
|
|
2121
|
+
if (pattern.endsWith("*") ? dep.startsWith(prefix) : dep === pattern) {
|
|
2122
|
+
return {
|
|
2123
|
+
matched: true,
|
|
2124
|
+
signal: `packageNamePattern:${clause.packageNamePattern}`
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
} else if ("role" in clause) {
|
|
2129
|
+
if (ctx.roleSet.has(clause.role.toLowerCase())) {
|
|
2130
|
+
return { matched: true, signal: `role:${clause.role}` };
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
return { matched: false };
|
|
2135
|
+
}
|
|
2136
|
+
function describeRequiresAny(clauses) {
|
|
2137
|
+
return clauses.map((c) => {
|
|
2138
|
+
if ("stack" in c) return `stack=${c.stack}`;
|
|
2139
|
+
if ("dependency" in c) return `dependency=${c.dependency}`;
|
|
2140
|
+
if ("packageNamePattern" in c) return `packageNamePattern=${c.packageNamePattern}`;
|
|
2141
|
+
if ("role" in c) return `role=${c.role}`;
|
|
2142
|
+
return "unknown";
|
|
2143
|
+
}).join(" | ");
|
|
2144
|
+
}
|
|
2145
|
+
function computeConfidenceLevel(args) {
|
|
2146
|
+
const { isDefaultBaseline, reasons, hasEcosystemConflict, score } = args;
|
|
2147
|
+
const positiveCodes = new Set(reasons.map((r) => r.code));
|
|
2148
|
+
positiveCodes.delete("default-baseline");
|
|
2149
|
+
const distinctSignals = positiveCodes.size;
|
|
2150
|
+
const strongCount = (positiveCodes.has("repo-role-match") ? 1 : 0) + (positiveCodes.has("stack-match") ? 1 : 0) + (positiveCodes.has("requires-any-match") ? 1 : 0);
|
|
2151
|
+
if (hasEcosystemConflict) return "low";
|
|
2152
|
+
if (isDefaultBaseline && distinctSignals === 0) return "medium";
|
|
2153
|
+
if (strongCount >= 2 && score >= 70) return "high";
|
|
2154
|
+
if (strongCount >= 1 && distinctSignals >= 2 && score >= 50) return "medium";
|
|
2155
|
+
if (distinctSignals === 1) return "low";
|
|
2156
|
+
return distinctSignals >= 2 ? "medium" : "low";
|
|
2157
|
+
}
|
|
2158
|
+
function confidenceLevelToNumber(level, score) {
|
|
2159
|
+
const base = level === "high" ? 0.85 : level === "medium" ? 0.6 : 0.3;
|
|
2160
|
+
const bonus = Math.min(0.1, Math.max(0, score - 40) / 1e3);
|
|
2161
|
+
return Number(Math.min(0.99, base + bonus).toFixed(2));
|
|
2162
|
+
}
|
|
2163
|
+
function mergeRecommendationWarnings(context) {
|
|
2164
|
+
const riskLines = (context.securityRisks?.length ?? 0) > 0 ? [`Scan reported security signals: ${context.securityRisks.join("; ")}`] : [];
|
|
2165
|
+
return [.../* @__PURE__ */ new Set([...context.warnings, ...riskLines])];
|
|
2166
|
+
}
|
|
2167
|
+
async function readChangedFiles(root) {
|
|
2168
|
+
if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
|
|
2169
|
+
try {
|
|
2170
|
+
const result = await runGit(["diff", "--name-only"], { cwd: root });
|
|
2171
|
+
if (result.exitCode !== 0) {
|
|
2172
|
+
return [];
|
|
2173
|
+
}
|
|
2174
|
+
return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean).sort();
|
|
2175
|
+
} catch {
|
|
2176
|
+
return [];
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
// src/utils/prompts.ts
|
|
2181
|
+
import { stdin as input, stdout as output } from "process";
|
|
2182
|
+
import readline from "readline/promises";
|
|
2183
|
+
async function ask(question) {
|
|
2184
|
+
const rl = readline.createInterface({ input, output });
|
|
2185
|
+
try {
|
|
2186
|
+
const answer = await rl.question(`${question}
|
|
2187
|
+
> `);
|
|
2188
|
+
return answer.trim();
|
|
2189
|
+
} finally {
|
|
2190
|
+
rl.close();
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
async function confirm(question) {
|
|
2194
|
+
const answer = (await ask(`${question} [y/N]`)).toLowerCase();
|
|
2195
|
+
return answer === "y" || answer === "yes";
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// src/commands/setup-project.ts
|
|
2199
|
+
var GUIDED_QUESTIONS = [
|
|
2200
|
+
"What is this project for?",
|
|
2201
|
+
"Is it for a client, internal Haus work, or experimentation?",
|
|
2202
|
+
"What should Claude help with most?",
|
|
2203
|
+
"Is this project connected to other repositories?",
|
|
2204
|
+
"Are there parts of the project Claude should avoid touching?",
|
|
2205
|
+
"Are there client-specific rules or sensitive areas?",
|
|
2206
|
+
"Do you want a minimal, standard, or strict setup?"
|
|
2207
|
+
];
|
|
2208
|
+
async function runSetupProject(options) {
|
|
2209
|
+
const root = process.cwd();
|
|
2210
|
+
let mode = options.guided ? "guided" : "fast";
|
|
2211
|
+
if (!options.guided && !options.fast && !options.json) {
|
|
2212
|
+
log("How do you want to set this project up?");
|
|
2213
|
+
log("1. Guided setup - I'll ask a few simple questions, then scan the project.");
|
|
2214
|
+
log("2. Fast setup - I'll only scan the project and recommend defaults.");
|
|
2215
|
+
const choice = await ask("Choose 1 or 2");
|
|
2216
|
+
mode = choice === "1" ? "guided" : "fast";
|
|
2217
|
+
}
|
|
2218
|
+
if (mode === "guided") {
|
|
2219
|
+
const existing = await readJson(hausPath(root, "setup-answers.json")) ?? {};
|
|
2220
|
+
const merged = {};
|
|
2221
|
+
for (const question of GUIDED_QUESTIONS) {
|
|
2222
|
+
if (options.json) {
|
|
2223
|
+
merged[question] = existing[question] ?? "pending-user-answer";
|
|
2224
|
+
continue;
|
|
2225
|
+
}
|
|
2226
|
+
const answer = await ask(question);
|
|
2227
|
+
merged[question] = answer || existing[question] || "no-answer";
|
|
2228
|
+
}
|
|
2229
|
+
await writeJson(hausPath(root, "setup-answers.json"), merged);
|
|
2230
|
+
}
|
|
2231
|
+
const scanResult = await scanProject(root, mode);
|
|
2232
|
+
if (options.json) {
|
|
2233
|
+
log(JSON.stringify(scanResult, null, 2));
|
|
2234
|
+
} else {
|
|
2235
|
+
log("Haus scan complete");
|
|
2236
|
+
log(`Roles: ${scanResult.repoRoles.join(", ") || "unknown"}`);
|
|
2237
|
+
log(`Package manager: ${scanResult.packageManager}`);
|
|
2238
|
+
}
|
|
2239
|
+
const context = await readContextOrScan(root);
|
|
2240
|
+
const recommendation = await recommend(root, context);
|
|
2241
|
+
await writeJson(hausPath(root, "recommendation.json"), recommendation);
|
|
2242
|
+
const hookSettings = await loadClaudeHooksSettings();
|
|
2243
|
+
await writeJson(hausPath(root, "recommended-hooks.json"), flattenRecommendedHooks(hookSettings));
|
|
2244
|
+
await writeJson(hausPath(root, "recommended-rules.json"), [
|
|
2245
|
+
{ id: "haus.rule.context-minimal", enabled: true },
|
|
2246
|
+
{ id: "haus.rule.security", enabled: true }
|
|
2247
|
+
]);
|
|
2248
|
+
if (options.json) {
|
|
2249
|
+
log(JSON.stringify(recommendation, null, 2));
|
|
2250
|
+
} else {
|
|
2251
|
+
log("Haus recommendation ready");
|
|
2252
|
+
log(`Recommended: ${recommendation.recommended.length}`);
|
|
2253
|
+
log(`Skipped: ${recommendation.skipped.length}`);
|
|
2254
|
+
}
|
|
2255
|
+
const hooks = await verifyProjectSettingsHooksContract(root);
|
|
2256
|
+
const warningLines = [.../* @__PURE__ */ new Set([...context.warnings, ...recommendation.warnings ?? []])];
|
|
2257
|
+
log(`Repo: ${context.repoName}`);
|
|
2258
|
+
for (const warning of warningLines) log(`- WARN: ${warning}`);
|
|
2259
|
+
if (hooks.skipped) {
|
|
2260
|
+
log(`- HOOKS: (skipped) ${hooks.message}`);
|
|
2261
|
+
} else if (!hooks.ok) {
|
|
2262
|
+
log(`- HOOKS FAIL: ${hooks.message}`);
|
|
2263
|
+
process.exitCode = 1;
|
|
2264
|
+
} else {
|
|
2265
|
+
log(`- HOOKS OK: ${hooks.message}`);
|
|
2266
|
+
}
|
|
2267
|
+
if (options.json) return;
|
|
2268
|
+
const approved = await confirm("Approve and write Claude files now?");
|
|
2269
|
+
if (!approved) {
|
|
2270
|
+
log("Setup reviewed. No files written.");
|
|
2271
|
+
log("Next step: run `haus apply --write` when ready.");
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
const files = await writeClaudeFiles(root, false);
|
|
2275
|
+
log("Applied files:");
|
|
2276
|
+
files.forEach((f) => log(`- ${displayPath(root, f)}`));
|
|
2277
|
+
const hooksAfter = await verifyProjectSettingsHooksContract(root);
|
|
2278
|
+
if (hooksAfter.skipped) {
|
|
2279
|
+
log(`- HOOKS: (skipped) ${hooksAfter.message}`);
|
|
2280
|
+
} else if (!hooksAfter.ok) {
|
|
2281
|
+
log(`- HOOKS FAIL: ${hooksAfter.message}`);
|
|
2282
|
+
process.exitCode = 1;
|
|
2283
|
+
} else {
|
|
2284
|
+
log(`- HOOKS OK: ${hooksAfter.message}`);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// src/commands/init.ts
|
|
2289
|
+
async function runInit(options) {
|
|
2290
|
+
const root = process.cwd();
|
|
2291
|
+
const hausDir = path14.join(root, ".haus-workflow");
|
|
2292
|
+
const alreadyInit = await fs11.pathExists(hausDir);
|
|
2293
|
+
if (alreadyInit) {
|
|
2294
|
+
log("Haus AI already initialized in this project.");
|
|
2295
|
+
log("Run `haus setup-project` to reconfigure.");
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
log("Welcome to Haus AI. Initializing this project for the first time.");
|
|
2299
|
+
await runSetupProject(options);
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// src/install/apply.ts
|
|
2303
|
+
import crypto2 from "crypto";
|
|
2304
|
+
import path17 from "path";
|
|
2305
|
+
import fs13 from "fs-extra";
|
|
2306
|
+
|
|
2307
|
+
// src/install/header.ts
|
|
2308
|
+
var MD_PREFIX = "<!-- HAUS-MANAGED";
|
|
2309
|
+
var MD_SUFFIX = " -->";
|
|
2310
|
+
function parseAttrs(raw) {
|
|
2311
|
+
const idMatch = /\bid=(\S+)/.exec(raw);
|
|
2312
|
+
const vMatch = /\bv=(\S+)/.exec(raw);
|
|
2313
|
+
const srcMatch = /\bsource=(\S+)/.exec(raw);
|
|
2314
|
+
if (!idMatch || !vMatch || !srcMatch) return void 0;
|
|
2315
|
+
return { stableId: idMatch[1], schemaVersion: vMatch[1], source: srcMatch[1] };
|
|
2316
|
+
}
|
|
2317
|
+
function parseMarkdownHeader(content) {
|
|
2318
|
+
const firstLine = content.split("\n")[0] ?? "";
|
|
2319
|
+
if (!firstLine.startsWith(MD_PREFIX)) return void 0;
|
|
2320
|
+
return parseAttrs(firstLine);
|
|
2321
|
+
}
|
|
2322
|
+
function buildMarkdownHeader(h) {
|
|
2323
|
+
return `${MD_PREFIX} id=${h.stableId} v=${h.schemaVersion} source=${h.source}${MD_SUFFIX}`;
|
|
2324
|
+
}
|
|
2325
|
+
function stampMarkdown(content, h) {
|
|
2326
|
+
const header = buildMarkdownHeader(h);
|
|
2327
|
+
const existing = parseMarkdownHeader(content);
|
|
2328
|
+
if (existing) {
|
|
2329
|
+
const rest = content.slice(content.indexOf("\n") + 1);
|
|
2330
|
+
return `${header}
|
|
2331
|
+
${rest}`;
|
|
2332
|
+
}
|
|
2333
|
+
return `${header}
|
|
2334
|
+
${content}`;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// src/install/manifest.ts
|
|
2338
|
+
import os5 from "os";
|
|
2339
|
+
import path15 from "path";
|
|
2340
|
+
var MANIFEST_SCHEMA = "haus-install-manifest/1";
|
|
2341
|
+
function globalClaudeDir() {
|
|
2342
|
+
return path15.join(os5.homedir(), ".claude");
|
|
2343
|
+
}
|
|
2344
|
+
function hausManifestPath() {
|
|
2345
|
+
return path15.join(globalClaudeDir(), "haus", "install-manifest.json");
|
|
2346
|
+
}
|
|
2347
|
+
async function readManifest() {
|
|
2348
|
+
return readJson(hausManifestPath());
|
|
2349
|
+
}
|
|
2350
|
+
async function writeManifest(manifest) {
|
|
2351
|
+
await writeJson(hausManifestPath(), manifest);
|
|
2352
|
+
}
|
|
2353
|
+
function buildManifest(source, files, hooks) {
|
|
2354
|
+
return {
|
|
2355
|
+
_schema: MANIFEST_SCHEMA,
|
|
2356
|
+
source,
|
|
2357
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2358
|
+
files,
|
|
2359
|
+
hooks
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// src/install/settings-merge.ts
|
|
2364
|
+
import path16 from "path";
|
|
2365
|
+
import fs12 from "fs-extra";
|
|
2366
|
+
function settingsJsonPath() {
|
|
2367
|
+
return path16.join(globalClaudeDir(), "settings.json");
|
|
2368
|
+
}
|
|
2369
|
+
async function readSettings() {
|
|
2370
|
+
const parsed = await readJson(settingsJsonPath());
|
|
2371
|
+
return parsed ?? {};
|
|
2372
|
+
}
|
|
2373
|
+
async function writeSettings(settings) {
|
|
2374
|
+
await writeJson(settingsJsonPath(), settings);
|
|
2375
|
+
}
|
|
2376
|
+
function mergeHooks(settings, fragments) {
|
|
2377
|
+
const existing = settings._haus?.hooks ?? [];
|
|
2378
|
+
const existingCommands = settings._haus?.hookCommands ?? [];
|
|
2379
|
+
const existingSet = new Set(existing);
|
|
2380
|
+
const updated = { ...settings };
|
|
2381
|
+
updated.hooks = { ...settings.hooks ?? {} };
|
|
2382
|
+
const addedIds = [];
|
|
2383
|
+
const addedCommands = [];
|
|
2384
|
+
for (const fragment of fragments) {
|
|
2385
|
+
if (fragment.gate !== "keep") continue;
|
|
2386
|
+
if (existingSet.has(fragment.id)) continue;
|
|
2387
|
+
const event = fragment.event;
|
|
2388
|
+
if (!updated.hooks[event]) updated.hooks[event] = [];
|
|
2389
|
+
const entry = {
|
|
2390
|
+
hooks: [{ type: "command", command: fragment.command }]
|
|
2391
|
+
};
|
|
2392
|
+
if (fragment.matcher) entry.matcher = fragment.matcher;
|
|
2393
|
+
updated.hooks[event] = [...updated.hooks[event] ?? [], entry];
|
|
2394
|
+
addedIds.push(fragment.id);
|
|
2395
|
+
addedCommands.push(fragment.command);
|
|
2396
|
+
}
|
|
2397
|
+
updated._haus = {
|
|
2398
|
+
hooks: [...existing, ...addedIds],
|
|
2399
|
+
hookCommands: [...existingCommands, ...addedCommands]
|
|
2400
|
+
};
|
|
2401
|
+
return { settings: updated, addedIds };
|
|
2402
|
+
}
|
|
2403
|
+
function stripHausHooks(settings) {
|
|
2404
|
+
if (!settings._haus) return settings;
|
|
2405
|
+
const ownedCommands = new Set(settings._haus.hookCommands ?? []);
|
|
2406
|
+
const usePrefix = ownedCommands.size === 0;
|
|
2407
|
+
const updated = { ...settings };
|
|
2408
|
+
updated.hooks = {};
|
|
2409
|
+
for (const [event, entries] of Object.entries(settings.hooks ?? {})) {
|
|
2410
|
+
const kept = entries.filter((entry) => {
|
|
2411
|
+
const cmd = entry.hooks[0]?.command ?? "";
|
|
2412
|
+
return usePrefix ? !cmd.startsWith("haus ") : !ownedCommands.has(cmd);
|
|
2413
|
+
});
|
|
2414
|
+
if (kept.length > 0) updated.hooks[event] = kept;
|
|
2415
|
+
}
|
|
2416
|
+
const { _haus: _, ...rest } = updated;
|
|
2417
|
+
void _;
|
|
2418
|
+
return rest;
|
|
2419
|
+
}
|
|
2420
|
+
async function loadHooksFragment(fragmentPath) {
|
|
2421
|
+
let raw;
|
|
2422
|
+
try {
|
|
2423
|
+
raw = await fs12.readJson(fragmentPath);
|
|
2424
|
+
} catch {
|
|
2425
|
+
return [];
|
|
2426
|
+
}
|
|
2427
|
+
const data = raw;
|
|
2428
|
+
return Array.isArray(data?.hooks) ? data.hooks : [];
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// src/install/apply.ts
|
|
2432
|
+
var SCHEMA_VERSION3 = "1";
|
|
2433
|
+
function hashContent(content) {
|
|
2434
|
+
return `sha256-${crypto2.createHash("sha256").update(content).digest("hex")}`;
|
|
2435
|
+
}
|
|
2436
|
+
function sourceVersion() {
|
|
2437
|
+
try {
|
|
2438
|
+
const pkgPath = path17.join(packageRoot(), "package.json");
|
|
2439
|
+
const pkg = JSON.parse(fs13.readFileSync(pkgPath, "utf8"));
|
|
2440
|
+
return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
|
|
2441
|
+
} catch {
|
|
2442
|
+
return "haus@0.0.0";
|
|
2443
|
+
}
|
|
2444
|
+
}
|
|
2445
|
+
function globalSrcDir() {
|
|
2446
|
+
return path17.join(packageRoot(), "library", "global");
|
|
2447
|
+
}
|
|
2448
|
+
function collectSourceFiles(srcDir, claudeDir) {
|
|
2449
|
+
const entries = [];
|
|
2450
|
+
const skillsDir = path17.join(srcDir, "skills");
|
|
2451
|
+
if (fs13.pathExistsSync(skillsDir)) {
|
|
2452
|
+
for (const skillName of fs13.readdirSync(skillsDir)) {
|
|
2453
|
+
const skillFile = path17.join(skillsDir, skillName, "SKILL.md");
|
|
2454
|
+
if (fs13.pathExistsSync(skillFile)) {
|
|
2455
|
+
entries.push({
|
|
2456
|
+
stableId: `skill.${skillName}`,
|
|
2457
|
+
srcRelPath: path17.join("library", "global", "skills", skillName, "SKILL.md"),
|
|
2458
|
+
destPath: path17.join(claudeDir, "skills", skillName, "SKILL.md")
|
|
2459
|
+
});
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
const agentsDir = path17.join(srcDir, "agents");
|
|
2464
|
+
if (fs13.pathExistsSync(agentsDir)) {
|
|
2465
|
+
for (const agentFile of fs13.readdirSync(agentsDir)) {
|
|
2466
|
+
if (!agentFile.endsWith(".md")) continue;
|
|
2467
|
+
const agentName = agentFile.replace(/\.md$/, "");
|
|
2468
|
+
entries.push({
|
|
2469
|
+
stableId: `agent.${agentName}`,
|
|
2470
|
+
srcRelPath: path17.join("library", "global", "agents", agentFile),
|
|
2471
|
+
destPath: path17.join(claudeDir, "agents", agentFile)
|
|
2472
|
+
});
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
return entries;
|
|
2476
|
+
}
|
|
2477
|
+
async function applyInstall(options = {}) {
|
|
2478
|
+
const { dryRun = false, force = false, check = false } = options;
|
|
2479
|
+
const claudeDir = globalClaudeDir();
|
|
2480
|
+
const srcDir = globalSrcDir();
|
|
2481
|
+
const source = sourceVersion();
|
|
2482
|
+
const existingManifest = await readManifest();
|
|
2483
|
+
const manifestByDest = new Map(existingManifest?.files.map((f) => [f.destPath, f]) ?? []);
|
|
2484
|
+
const sourceFiles = collectSourceFiles(srcDir, claudeDir);
|
|
2485
|
+
const result = {
|
|
2486
|
+
created: [],
|
|
2487
|
+
updated: [],
|
|
2488
|
+
skipped: [],
|
|
2489
|
+
deleted: [],
|
|
2490
|
+
hookIds: [],
|
|
2491
|
+
drift: false
|
|
2492
|
+
};
|
|
2493
|
+
const manifestFiles = [];
|
|
2494
|
+
for (const entry of sourceFiles) {
|
|
2495
|
+
const srcPath = path17.join(packageRoot(), entry.srcRelPath);
|
|
2496
|
+
const rawContent = await readText(srcPath);
|
|
2497
|
+
if (rawContent === void 0) {
|
|
2498
|
+
warn(`Source file not found: ${entry.srcRelPath}`);
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
const stamped = stampMarkdown(rawContent, {
|
|
2502
|
+
stableId: entry.stableId,
|
|
2503
|
+
schemaVersion: SCHEMA_VERSION3,
|
|
2504
|
+
source
|
|
2505
|
+
});
|
|
2506
|
+
const newHash = hashContent(stamped);
|
|
2507
|
+
const existing = manifestByDest.get(entry.destPath);
|
|
2508
|
+
if (check) {
|
|
2509
|
+
if (existing && existing.hash !== newHash) {
|
|
2510
|
+
result.drift = true;
|
|
2511
|
+
result.skipped.push(entry.destPath);
|
|
2512
|
+
}
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
const destExists = fs13.pathExistsSync(entry.destPath);
|
|
2516
|
+
if (destExists) {
|
|
2517
|
+
const currentContent = await readText(entry.destPath);
|
|
2518
|
+
if (currentContent !== void 0) {
|
|
2519
|
+
const hasHeader = parseMarkdownHeader(currentContent) !== void 0;
|
|
2520
|
+
if (!hasHeader) {
|
|
2521
|
+
warn(`Refusing to overwrite user-owned file: ${entry.destPath}`);
|
|
2522
|
+
result.skipped.push(entry.destPath);
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
2525
|
+
if (existing && hashContent(currentContent) !== existing.hash && !force) {
|
|
2526
|
+
warn(`User edited haus file (skipping): ${entry.destPath} \u2014 use --force to overwrite`);
|
|
2527
|
+
result.skipped.push(entry.destPath);
|
|
2528
|
+
manifestFiles.push(existing);
|
|
2529
|
+
continue;
|
|
2530
|
+
}
|
|
2531
|
+
if (existing && existing.hash === newHash && !force) {
|
|
2532
|
+
result.skipped.push(entry.destPath);
|
|
2533
|
+
manifestFiles.push({ ...existing, hash: newHash });
|
|
2534
|
+
continue;
|
|
2535
|
+
}
|
|
2536
|
+
if (!dryRun) await writeText(entry.destPath, stamped);
|
|
2537
|
+
result.updated.push(entry.destPath);
|
|
2538
|
+
}
|
|
2539
|
+
} else {
|
|
2540
|
+
if (!dryRun) await writeText(entry.destPath, stamped);
|
|
2541
|
+
result.created.push(entry.destPath);
|
|
2542
|
+
}
|
|
2543
|
+
manifestFiles.push({
|
|
2544
|
+
stableId: entry.stableId,
|
|
2545
|
+
destPath: entry.destPath,
|
|
2546
|
+
srcRelPath: entry.srcRelPath,
|
|
2547
|
+
hash: newHash,
|
|
2548
|
+
schemaVersion: SCHEMA_VERSION3
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
const fragmentPath = path17.join(srcDir, "settings-fragments", "hooks.json");
|
|
2552
|
+
const fragments = await loadHooksFragment(fragmentPath);
|
|
2553
|
+
const settings = await readSettings();
|
|
2554
|
+
const { settings: mergedSettings, addedIds } = mergeHooks(settings, fragments);
|
|
2555
|
+
result.hookIds = addedIds;
|
|
2556
|
+
if (!check && existingManifest) {
|
|
2557
|
+
const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
|
|
2558
|
+
for (const entry of existingManifest.files) {
|
|
2559
|
+
if (currentDestPaths.has(entry.destPath)) continue;
|
|
2560
|
+
if (!fs13.pathExistsSync(entry.destPath)) continue;
|
|
2561
|
+
const content = await readText(entry.destPath);
|
|
2562
|
+
if (!content) continue;
|
|
2563
|
+
const hasHeader = parseMarkdownHeader(content) !== void 0;
|
|
2564
|
+
const currentHash = hashContent(content);
|
|
2565
|
+
if (hasHeader && currentHash === entry.hash) {
|
|
2566
|
+
if (!dryRun) await fs13.remove(entry.destPath);
|
|
2567
|
+
result.deleted.push(entry.destPath);
|
|
2568
|
+
} else {
|
|
2569
|
+
warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
|
|
2570
|
+
result.skipped.push(entry.destPath);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
if (!dryRun && !check) {
|
|
2575
|
+
await writeSettings(mergedSettings);
|
|
2576
|
+
const manifest = buildManifest(source, manifestFiles, [...existingManifest?.hooks ?? [], ...addedIds]);
|
|
2577
|
+
await writeManifest(manifest);
|
|
2578
|
+
}
|
|
2579
|
+
return result;
|
|
2580
|
+
}
|
|
2581
|
+
function printApplyResult(result, dryRun) {
|
|
2582
|
+
const prefix = dryRun ? "[dry-run] " : "";
|
|
2583
|
+
if (result.created.length) {
|
|
2584
|
+
log(`${prefix}Created:`);
|
|
2585
|
+
result.created.forEach((p) => log(` + ${p}`));
|
|
2586
|
+
}
|
|
2587
|
+
if (result.updated.length) {
|
|
2588
|
+
log(`${prefix}Updated:`);
|
|
2589
|
+
result.updated.forEach((p) => log(` ~ ${p}`));
|
|
2590
|
+
}
|
|
2591
|
+
if (result.deleted.length) {
|
|
2592
|
+
log(`${prefix}Deleted (orphaned):`);
|
|
2593
|
+
result.deleted.forEach((p) => log(` x ${p}`));
|
|
2594
|
+
}
|
|
2595
|
+
if (result.skipped.length) {
|
|
2596
|
+
log(`${prefix}Skipped:`);
|
|
2597
|
+
result.skipped.forEach((p) => log(` - ${p}`));
|
|
2598
|
+
}
|
|
2599
|
+
if (result.hookIds.length) {
|
|
2600
|
+
log(`${prefix}Hooks added: ${result.hookIds.join(", ")}`);
|
|
2601
|
+
}
|
|
2602
|
+
if (result.drift) {
|
|
2603
|
+
warn("Install drift detected \u2014 run `haus install` to sync.");
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2607
|
+
// src/commands/install.ts
|
|
2608
|
+
async function runInstall(options) {
|
|
2609
|
+
try {
|
|
2610
|
+
const result = await applyInstall({
|
|
2611
|
+
dryRun: options.dryRun,
|
|
2612
|
+
force: options.force,
|
|
2613
|
+
check: options.check
|
|
2614
|
+
});
|
|
2615
|
+
printApplyResult(result, options.dryRun ?? false);
|
|
2616
|
+
if (options.check && result.drift) {
|
|
2617
|
+
process.exitCode = 1;
|
|
2618
|
+
} else if (!options.check && !options.dryRun) {
|
|
2619
|
+
const total = result.created.length + result.updated.length;
|
|
2620
|
+
log(`haus install complete (${total} file(s) written, ${result.hookIds.length} hook(s) added)`);
|
|
2621
|
+
}
|
|
2622
|
+
} catch (err) {
|
|
2623
|
+
error(`haus install failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2624
|
+
process.exitCode = 1;
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
// src/memory/memory-store.ts
|
|
2629
|
+
var FILES = ["project-learnings.md", "decisions.md", "recurring-issues.md", "client-context.md"];
|
|
2630
|
+
async function ensureMemory(root) {
|
|
2631
|
+
await Promise.all(
|
|
2632
|
+
FILES.map(async (name) => {
|
|
2633
|
+
const file = hausPath(root, "memory", name);
|
|
2634
|
+
const current = await readText(file);
|
|
2635
|
+
if (!current) await writeText(file, `# ${name}
|
|
2636
|
+
`);
|
|
2637
|
+
})
|
|
2638
|
+
);
|
|
2639
|
+
const indexFile = hausPath(root, "memory", "index.json");
|
|
2640
|
+
const index = await readJson(indexFile);
|
|
2641
|
+
if (!index) await writeJson(indexFile, { files: [...FILES] });
|
|
2642
|
+
}
|
|
2643
|
+
async function readMemory(root) {
|
|
2644
|
+
await ensureMemory(root);
|
|
2645
|
+
const blocks = await Promise.all(FILES.map((name) => readText(hausPath(root, "memory", name))));
|
|
2646
|
+
return blocks.filter(Boolean).join("\n");
|
|
2647
|
+
}
|
|
2648
|
+
async function appendLearning(root, line) {
|
|
2649
|
+
await ensureMemory(root);
|
|
2650
|
+
const file = hausPath(root, "memory", "project-learnings.md");
|
|
2651
|
+
const current = await readText(file) ?? "# project-learnings.md\n";
|
|
2652
|
+
await writeText(file, `${current}
|
|
2653
|
+
- ${line}
|
|
2654
|
+
`);
|
|
2655
|
+
}
|
|
2656
|
+
|
|
2657
|
+
// src/memory/redact-memory.ts
|
|
2658
|
+
function redactMemory(text) {
|
|
2659
|
+
return text.replace(/(api[_-]?key|token|secret|password)\s*[:=]\s*\S+/gi, "$1=[REDACTED]").replace(/-----BEGIN [A-Z ]+-----[\s\S]*?-----END [A-Z ]+-----/g, "[REDACTED-KEY]");
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
// src/commands/memory.ts
|
|
2663
|
+
async function runMemory(subcommand, options) {
|
|
2664
|
+
const root = process.cwd();
|
|
2665
|
+
if (subcommand === "inject" && options.fromHook && !await isHookEnabled(root, "memoryInject")) {
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
await ensureMemory(root);
|
|
2669
|
+
if (subcommand === "status") {
|
|
2670
|
+
log("Memory ready at .haus-workflow/memory");
|
|
2671
|
+
return;
|
|
2672
|
+
}
|
|
2673
|
+
if (subcommand === "add") {
|
|
2674
|
+
if (!options.text) throw new Error("memory add requires text");
|
|
2675
|
+
await appendLearning(root, redactMemory(options.text));
|
|
2676
|
+
log("Memory added");
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
if (subcommand === "inject") {
|
|
2680
|
+
const text = redactMemory(await readMemory(root));
|
|
2681
|
+
if (!text.trim()) {
|
|
2682
|
+
log("No relevant Haus memory found.");
|
|
2683
|
+
return;
|
|
2684
|
+
}
|
|
2685
|
+
const compact = `Task: ${options.task ?? "n/a"}
|
|
2686
|
+
${text}`.slice(0, options.fromHook ? 1200 : 4e3);
|
|
2687
|
+
log(compact);
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
log("Promotion proposal: review memory and move stable rules into .claude/rules manually.");
|
|
2691
|
+
}
|
|
2692
|
+
|
|
2693
|
+
// src/commands/recommend.ts
|
|
2694
|
+
async function runRecommend(options) {
|
|
2695
|
+
const root = process.cwd();
|
|
2696
|
+
const context = await readContextOrScan(root);
|
|
2697
|
+
const result = await recommend(root, context);
|
|
2698
|
+
await writeJson(hausPath(root, "recommendation.json"), result);
|
|
2699
|
+
const hookSettings = await loadClaudeHooksSettings();
|
|
2700
|
+
await writeJson(hausPath(root, "recommended-hooks.json"), flattenRecommendedHooks(hookSettings));
|
|
2701
|
+
await writeJson(hausPath(root, "recommended-rules.json"), [
|
|
2702
|
+
{ id: "haus.rule.context-minimal", enabled: true },
|
|
2703
|
+
{ id: "haus.rule.security", enabled: true }
|
|
2704
|
+
]);
|
|
2705
|
+
if (options.json) {
|
|
2706
|
+
log(JSON.stringify(result, null, 2));
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
log("Haus recommendation ready");
|
|
2710
|
+
log(`Recommended: ${result.recommended.length}`);
|
|
2711
|
+
log(`Skipped: ${result.skipped.length}`);
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// src/commands/refresh.ts
|
|
2715
|
+
async function runRefresh() {
|
|
2716
|
+
const result = await scanProject(process.cwd(), "fast");
|
|
2717
|
+
log("Haus scan complete");
|
|
2718
|
+
log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
|
|
2719
|
+
log(`Package manager: ${result.packageManager}`);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// src/commands/scan.ts
|
|
2723
|
+
async function runScan(options) {
|
|
2724
|
+
const mode = options.mode ?? "fast";
|
|
2725
|
+
const result = await scanProject(process.cwd(), mode);
|
|
2726
|
+
if (options.json) {
|
|
2727
|
+
log(JSON.stringify(result, null, 2));
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
log("Haus scan complete");
|
|
2731
|
+
log(`Roles: ${result.repoRoles.join(", ") || "unknown"}`);
|
|
2732
|
+
log(`Package manager: ${result.packageManager}`);
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
// src/commands/undo.ts
|
|
2736
|
+
import path18 from "path";
|
|
2737
|
+
import fs14 from "fs-extra";
|
|
2738
|
+
var CLAUDE_DIR = ".claude";
|
|
2739
|
+
async function runUndo(options) {
|
|
2740
|
+
const root = process.cwd();
|
|
2741
|
+
const targets = [path18.join(root, CLAUDE_DIR), path18.join(root, HAUS_DIR)];
|
|
2742
|
+
const existing = targets.filter((p) => fs14.existsSync(p));
|
|
2743
|
+
if (existing.length === 0) {
|
|
2744
|
+
log("Nothing to remove: no .claude/ or .haus-workflow/ in this directory.");
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
if (!options.yes) {
|
|
2748
|
+
const ok = await confirm(
|
|
2749
|
+
`Remove ${existing.map((p) => path18.relative(root, p)).join(" and ")}? This cannot be undone.`
|
|
2750
|
+
);
|
|
2751
|
+
if (!ok) {
|
|
2752
|
+
log("Cancelled.");
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
for (const p of existing) {
|
|
2757
|
+
await fs14.remove(p);
|
|
2758
|
+
log(`Removed ${path18.relative(root, p)}`);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// src/install/uninstall.ts
|
|
2763
|
+
import crypto3 from "crypto";
|
|
2764
|
+
import path19 from "path";
|
|
2765
|
+
import fs15 from "fs-extra";
|
|
2766
|
+
async function runUninstall(options = {}) {
|
|
2767
|
+
const { force = false } = options;
|
|
2768
|
+
const manifest = await readManifest();
|
|
2769
|
+
const result = { deleted: [], skipped: [], hooksStripped: false };
|
|
2770
|
+
if (!manifest) {
|
|
2771
|
+
warn("No install manifest found \u2014 nothing to uninstall.");
|
|
2772
|
+
return result;
|
|
2773
|
+
}
|
|
2774
|
+
for (const entry of manifest.files) {
|
|
2775
|
+
const exists = fs15.pathExistsSync(entry.destPath);
|
|
2776
|
+
if (!exists) continue;
|
|
2777
|
+
const content = await readText(entry.destPath);
|
|
2778
|
+
if (content === void 0) continue;
|
|
2779
|
+
const header = parseMarkdownHeader(content);
|
|
2780
|
+
if (!header) {
|
|
2781
|
+
warn(`Skipping user-owned file (no HAUS-MANAGED header): ${entry.destPath}`);
|
|
2782
|
+
result.skipped.push(entry.destPath);
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
const currentHash = `sha256-${crypto3.createHash("sha256").update(content).digest("hex")}`;
|
|
2786
|
+
if (currentHash !== entry.hash && !force) {
|
|
2787
|
+
warn(`Skipping user-edited haus file (hash mismatch): ${entry.destPath} \u2014 use --force to delete`);
|
|
2788
|
+
result.skipped.push(entry.destPath);
|
|
2789
|
+
continue;
|
|
2790
|
+
}
|
|
2791
|
+
await fs15.remove(entry.destPath);
|
|
2792
|
+
await pruneEmptyDir(path19.dirname(entry.destPath));
|
|
2793
|
+
result.deleted.push(entry.destPath);
|
|
2794
|
+
}
|
|
2795
|
+
const settings = await readSettings();
|
|
2796
|
+
const stripped = stripHausHooks(settings);
|
|
2797
|
+
await writeSettings(stripped);
|
|
2798
|
+
result.hooksStripped = true;
|
|
2799
|
+
const hausDir = path19.join(globalClaudeDir(), "haus");
|
|
2800
|
+
const manifestPath = hausManifestPath();
|
|
2801
|
+
if (fs15.pathExistsSync(manifestPath)) {
|
|
2802
|
+
await fs15.remove(manifestPath);
|
|
2803
|
+
}
|
|
2804
|
+
if (fs15.pathExistsSync(hausDir)) {
|
|
2805
|
+
const remaining = await fs15.readdir(hausDir);
|
|
2806
|
+
if (remaining.length === 0) await fs15.remove(hausDir);
|
|
2807
|
+
}
|
|
2808
|
+
return result;
|
|
2809
|
+
}
|
|
2810
|
+
function printUninstallResult(result) {
|
|
2811
|
+
if (result.deleted.length) {
|
|
2812
|
+
log("Deleted:");
|
|
2813
|
+
result.deleted.forEach((p) => log(` - ${p}`));
|
|
2814
|
+
}
|
|
2815
|
+
if (result.skipped.length) {
|
|
2816
|
+
log("Skipped (user-owned or mismatch):");
|
|
2817
|
+
result.skipped.forEach((p) => log(` ! ${p}`));
|
|
2818
|
+
}
|
|
2819
|
+
if (result.hooksStripped) {
|
|
2820
|
+
log("Haus hook entries removed from ~/.claude/settings.json");
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
async function pruneEmptyDir(dir) {
|
|
2824
|
+
try {
|
|
2825
|
+
const entries = await fs15.readdir(dir);
|
|
2826
|
+
if (entries.length === 0) await fs15.remove(dir);
|
|
2827
|
+
} catch {
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
// src/commands/uninstall.ts
|
|
2832
|
+
async function runUninstallCommand(options) {
|
|
2833
|
+
try {
|
|
2834
|
+
const result = await runUninstall({ force: options.force });
|
|
2835
|
+
printUninstallResult(result);
|
|
2836
|
+
log("haus uninstall complete");
|
|
2837
|
+
} catch (err) {
|
|
2838
|
+
error(`haus uninstall failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2839
|
+
process.exitCode = 1;
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// src/commands/update.ts
|
|
2844
|
+
import path21 from "path";
|
|
2845
|
+
|
|
2846
|
+
// src/update/diff-generated-files.ts
|
|
2847
|
+
function diffGeneratedFiles() {
|
|
2848
|
+
return "Generated files may change in .claude/* and .haus-workflow/haus.lock.json. Review git diff before apply.";
|
|
2849
|
+
}
|
|
2850
|
+
function summarizeLockDiff(before, after) {
|
|
2851
|
+
if (before === after) return "No lockfile textual changes.";
|
|
2852
|
+
const unified = createUnifiedDiff(".haus-workflow/haus.lock.json", before, after);
|
|
2853
|
+
const counts = summarizeDiff(unified);
|
|
2854
|
+
try {
|
|
2855
|
+
const prev = JSON.parse(before || "[]");
|
|
2856
|
+
const next = JSON.parse(after || "[]");
|
|
2857
|
+
const prevIds = new Set(prev.map((x) => x.id));
|
|
2858
|
+
const nextIds = new Set(next.map((x) => x.id));
|
|
2859
|
+
const added = [...nextIds].filter((id) => !prevIds.has(id));
|
|
2860
|
+
const removed = [...prevIds].filter((id) => !nextIds.has(id));
|
|
2861
|
+
if (added.length === 0 && removed.length === 0)
|
|
2862
|
+
return `Lock changed: +${counts.additions} -${counts.deletions} lines`;
|
|
2863
|
+
return `Lock item changes: +${added.length} -${removed.length} (lines +${counts.additions} -${counts.deletions})`;
|
|
2864
|
+
} catch {
|
|
2865
|
+
return `Lock item changes unavailable. Text diff lines: +${counts.additions} -${counts.deletions}`;
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
// src/update/lockfile.ts
|
|
2870
|
+
import { mkdir, readFile as readFile2, copyFile } from "fs/promises";
|
|
2871
|
+
import path20 from "path";
|
|
2872
|
+
async function checkLock(root) {
|
|
2873
|
+
const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
|
|
2874
|
+
const hasValidVersions = lock.every((item) => !item.version || normalizeVersion(item.version) !== null);
|
|
2875
|
+
return { ok: lock.length > 0 && hasValidVersions, count: lock.length };
|
|
2876
|
+
}
|
|
2877
|
+
async function applyLock(root) {
|
|
2878
|
+
const lockPath = hausPath(root, "haus.lock.json");
|
|
2879
|
+
let before = "[]";
|
|
2880
|
+
try {
|
|
2881
|
+
before = await readFile2(lockPath, "utf8");
|
|
2882
|
+
} catch {
|
|
2883
|
+
before = "[]";
|
|
2884
|
+
}
|
|
2885
|
+
const lock = await readJson(lockPath) ?? [];
|
|
2886
|
+
try {
|
|
2887
|
+
const backupDir = hausPath(root, "backups");
|
|
2888
|
+
await mkdir(backupDir, { recursive: true });
|
|
2889
|
+
await copyFile(lockPath, path20.join(backupDir, `haus.lock.${Date.now()}.json`));
|
|
2890
|
+
} catch {
|
|
2891
|
+
}
|
|
2892
|
+
const enriched = await Promise.all(
|
|
2893
|
+
lock.map(async (x) => {
|
|
2894
|
+
const paths = Array.isArray(x.paths) ? x.paths.map(String) : [];
|
|
2895
|
+
const { hash: _oldHash, ...stableFields } = x;
|
|
2896
|
+
const newHash = await hashInstalledPaths(root, paths);
|
|
2897
|
+
return { ...stableFields, paths, hash: newHash };
|
|
2898
|
+
})
|
|
2899
|
+
);
|
|
2900
|
+
await writeJson(lockPath, enriched);
|
|
2901
|
+
const after = `${JSON.stringify(enriched, null, 2)}
|
|
2902
|
+
`;
|
|
2903
|
+
return { before, after };
|
|
2904
|
+
}
|
|
2905
|
+
function diffLock(before, after) {
|
|
2906
|
+
if (!hasTextChanged(before, after)) return "No lockfile changes.";
|
|
2907
|
+
return createUnifiedDiff(".haus-workflow/haus.lock.json", before, after);
|
|
2908
|
+
}
|
|
2909
|
+
async function hasLocalOverrides(root) {
|
|
2910
|
+
try {
|
|
2911
|
+
await readFile2(path20.join(root, ".claude", "settings.json"), "utf8");
|
|
2912
|
+
return true;
|
|
2913
|
+
} catch {
|
|
2914
|
+
return false;
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// src/commands/update.ts
|
|
2919
|
+
var NPM_PACKAGE_NAME = "@haus-tech/haus-workflow";
|
|
2920
|
+
async function checkNpmVersion(currentVersion) {
|
|
2921
|
+
try {
|
|
2922
|
+
const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(NPM_PACKAGE_NAME)}/latest`, {
|
|
2923
|
+
signal: AbortSignal.timeout(8e3)
|
|
2924
|
+
});
|
|
2925
|
+
if (!res.ok) return;
|
|
2926
|
+
const data = await res.json();
|
|
2927
|
+
const latest = data?.version;
|
|
2928
|
+
if (!latest || !normalizeVersion(latest) || !normalizeVersion(currentVersion)) return;
|
|
2929
|
+
if (compareVersions(latest, currentVersion) > 0) {
|
|
2930
|
+
log(`npm update available: ${currentVersion} \u2192 ${latest}`);
|
|
2931
|
+
log(`Run: npm install -g ${NPM_PACKAGE_NAME}`);
|
|
2932
|
+
} else {
|
|
2933
|
+
log(`npm package up to date: ${currentVersion}`);
|
|
2934
|
+
}
|
|
2935
|
+
} catch {
|
|
2936
|
+
}
|
|
2937
|
+
}
|
|
2938
|
+
async function runUpdate(options) {
|
|
2939
|
+
const root = process.cwd();
|
|
2940
|
+
if (options.check) {
|
|
2941
|
+
const status = await checkLock(root);
|
|
2942
|
+
log(
|
|
2943
|
+
JSON.stringify(
|
|
2944
|
+
{
|
|
2945
|
+
...status,
|
|
2946
|
+
localOverrides: await hasLocalOverrides(root),
|
|
2947
|
+
summary: diffGeneratedFiles()
|
|
2948
|
+
},
|
|
2949
|
+
null,
|
|
2950
|
+
2
|
|
2951
|
+
)
|
|
2952
|
+
);
|
|
2953
|
+
if (!status.ok) process.exitCode = 1;
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
const pkgJson = await readJson(path21.join(packageRoot(), "package.json"));
|
|
2957
|
+
const currentVersion = pkgJson?.version ?? "0.0.0";
|
|
2958
|
+
await checkNpmVersion(currentVersion);
|
|
2959
|
+
if (await hasLocalOverrides(root)) {
|
|
2960
|
+
log("Local .claude overrides detected. Preserving local files; only lockfile updated.");
|
|
2961
|
+
}
|
|
2962
|
+
const { before, after } = await applyLock(root);
|
|
2963
|
+
log(diffLock(before, after));
|
|
2964
|
+
log(summarizeLockDiff(before, after));
|
|
2965
|
+
log("Syncing remote catalog...");
|
|
2966
|
+
const sync = await syncRemoteCatalog();
|
|
2967
|
+
if (sync.newItems.length > 0) {
|
|
2968
|
+
log(`Catalog updated: ${sync.newItems.length} new item(s): ${sync.newItems.join(", ")}`);
|
|
2969
|
+
log("Run `haus recommend && haus apply --write` to install new skills.");
|
|
2970
|
+
} else if (sync.unchanged > 0) {
|
|
2971
|
+
log(`Catalog up to date (${sync.unchanged} item(s) unchanged).`);
|
|
2972
|
+
}
|
|
2973
|
+
if (sync.failed.length > 0) {
|
|
2974
|
+
warn(`Failed to fetch ${sync.failed.length} item(s): ${sync.failed.join(", ")}`);
|
|
2975
|
+
}
|
|
2976
|
+
log("Update applied with backup in .haus-workflow/backups/. Run haus doctor.");
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// src/commands/validate-catalog.ts
|
|
2980
|
+
import path23 from "path";
|
|
2981
|
+
|
|
2982
|
+
// src/catalog/allowed-stacks.ts
|
|
2983
|
+
import path22 from "path";
|
|
2984
|
+
async function readAllowedStacks(root) {
|
|
2985
|
+
const data = await readJson(path22.join(root, "library", "catalog", "allowed-stacks.json"));
|
|
2986
|
+
return data?.stacks ?? [];
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
// src/commands/validate-catalog.ts
|
|
2990
|
+
var FORBIDDEN2 = [
|
|
2991
|
+
"python",
|
|
2992
|
+
"django",
|
|
2993
|
+
"go",
|
|
2994
|
+
"rust",
|
|
2995
|
+
"java",
|
|
2996
|
+
"spring",
|
|
2997
|
+
"kotlin",
|
|
2998
|
+
"swift",
|
|
2999
|
+
"android",
|
|
3000
|
+
"flutter",
|
|
3001
|
+
"dart",
|
|
3002
|
+
"c++",
|
|
3003
|
+
"perl",
|
|
3004
|
+
"defi",
|
|
3005
|
+
"trading"
|
|
3006
|
+
];
|
|
3007
|
+
async function auditForbiddenStacks(items) {
|
|
3008
|
+
const failures = [];
|
|
3009
|
+
for (const item of items) {
|
|
3010
|
+
const tags = Array.isArray(item.tags) ? item.tags : [];
|
|
3011
|
+
const text = `${item.id} ${tags.join(" ")}`.toLowerCase();
|
|
3012
|
+
for (const word of FORBIDDEN2) {
|
|
3013
|
+
if (text.includes(word)) failures.push(`${item.id}: unsupported stack/tag "${word}"`);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
return failures;
|
|
3017
|
+
}
|
|
3018
|
+
async function auditManifestStructure(items) {
|
|
3019
|
+
const failures = [];
|
|
3020
|
+
const seenIds = /* @__PURE__ */ new Map();
|
|
3021
|
+
const seenPaths = /* @__PURE__ */ new Map();
|
|
3022
|
+
for (let i = 0; i < items.length; i++) {
|
|
3023
|
+
const item = items[i];
|
|
3024
|
+
if (!item.id) {
|
|
3025
|
+
failures.push(`item[${i}]: missing id`);
|
|
3026
|
+
continue;
|
|
3027
|
+
}
|
|
3028
|
+
if (!item.type) {
|
|
3029
|
+
failures.push(`${item.id}: missing type`);
|
|
3030
|
+
continue;
|
|
3031
|
+
}
|
|
3032
|
+
if (!item.source) {
|
|
3033
|
+
failures.push(`${item.id}: missing source`);
|
|
3034
|
+
}
|
|
3035
|
+
if (!item.title) {
|
|
3036
|
+
failures.push(`${item.id}: missing title`);
|
|
3037
|
+
}
|
|
3038
|
+
const prev = seenIds.get(item.id);
|
|
3039
|
+
if (prev !== void 0) {
|
|
3040
|
+
failures.push(`${item.id}: duplicate id (first at index ${prev})`);
|
|
3041
|
+
} else {
|
|
3042
|
+
seenIds.set(item.id, i);
|
|
3043
|
+
}
|
|
3044
|
+
if (item.type === "skill" || item.type === "agent") {
|
|
3045
|
+
if (!item.path) {
|
|
3046
|
+
failures.push(`${item.id}: missing path`);
|
|
3047
|
+
} else {
|
|
3048
|
+
const norm = item.path.replace(/\\/g, "/");
|
|
3049
|
+
const existing = seenPaths.get(norm);
|
|
3050
|
+
if (existing) {
|
|
3051
|
+
failures.push(`${item.id}: path "${norm}" already used by ${existing}`);
|
|
3052
|
+
} else {
|
|
3053
|
+
seenPaths.set(norm, item.id);
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
const isHaus = item.source === "haus";
|
|
3057
|
+
const isCuratedApproved = item.source === "curated" && item.reviewStatus === "approved";
|
|
3058
|
+
if (!isHaus && !isCuratedApproved) {
|
|
3059
|
+
failures.push(`${item.id}: source must be "haus" or curated with reviewStatus "approved"`);
|
|
3060
|
+
}
|
|
3061
|
+
for (const ref of item.references ?? []) {
|
|
3062
|
+
if (/^http:\/\//i.test(ref)) {
|
|
3063
|
+
failures.push(`${item.id}: reference uses insecure http:// URL: ${ref}`);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
return failures;
|
|
3069
|
+
}
|
|
3070
|
+
async function runValidateCatalog(manifestPath) {
|
|
3071
|
+
if (!manifestPath) {
|
|
3072
|
+
error("Usage: haus validate-catalog <path/to/manifest.json>");
|
|
3073
|
+
process.exitCode = 1;
|
|
3074
|
+
return;
|
|
3075
|
+
}
|
|
3076
|
+
const abs = path23.resolve(process.cwd(), manifestPath);
|
|
3077
|
+
const data = await readJson(abs);
|
|
3078
|
+
if (!data?.items) {
|
|
3079
|
+
error(`Could not read catalog manifest at ${abs}`);
|
|
3080
|
+
process.exitCode = 1;
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
const items = data.items;
|
|
3084
|
+
const [structureFailures, stackFailures] = await Promise.all([
|
|
3085
|
+
auditManifestStructure(items),
|
|
3086
|
+
auditForbiddenStacks(items)
|
|
3087
|
+
]);
|
|
3088
|
+
const allowed = new Set((await readAllowedStacks(packageRoot())).map((x) => x.toLowerCase()));
|
|
3089
|
+
const tagFailures = [];
|
|
3090
|
+
if (allowed.size > 0) {
|
|
3091
|
+
for (const item of items) {
|
|
3092
|
+
for (const tag of Array.isArray(item.tags) ? item.tags : []) {
|
|
3093
|
+
if (!allowed.has(tag.toLowerCase()) && !tag.includes("-patterns") && tag !== "haus" && tag !== "security" && tag !== "quality" && tag !== "review" && tag !== "workflow") {
|
|
3094
|
+
tagFailures.push(`${item.id}: tag not in allowlist: "${tag}"`);
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
const allFailures = [...structureFailures, ...stackFailures, ...tagFailures];
|
|
3100
|
+
if (allFailures.length) {
|
|
3101
|
+
allFailures.forEach((f) => error(f));
|
|
3102
|
+
process.exitCode = 1;
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
log(`Catalog valid. ${items.length} items checked.`);
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
// src/commands/workspace.ts
|
|
3109
|
+
import path24 from "path";
|
|
3110
|
+
import YAML from "yaml";
|
|
3111
|
+
async function runWorkspace(action) {
|
|
3112
|
+
if (action === "init") {
|
|
3113
|
+
await writeText(
|
|
3114
|
+
"haus.workspace.yaml",
|
|
3115
|
+
`client: unknown
|
|
3116
|
+
repos:
|
|
3117
|
+
- name: current
|
|
3118
|
+
path: .
|
|
3119
|
+
role: auto
|
|
3120
|
+
relationships: []
|
|
3121
|
+
`
|
|
3122
|
+
);
|
|
3123
|
+
log("Workspace initialized.");
|
|
3124
|
+
return;
|
|
3125
|
+
}
|
|
3126
|
+
const configText = await readText("haus.workspace.yaml");
|
|
3127
|
+
if (!configText) {
|
|
3128
|
+
error("Missing haus.workspace.yaml. Run `haus workspace init` first.");
|
|
3129
|
+
process.exitCode = 1;
|
|
3130
|
+
return;
|
|
3131
|
+
}
|
|
3132
|
+
const config = YAML.parse(configText);
|
|
3133
|
+
const repos = config.repos ?? [];
|
|
3134
|
+
if (repos.length === 0) {
|
|
3135
|
+
error("No repos configured in haus.workspace.yaml.");
|
|
3136
|
+
process.exitCode = 1;
|
|
3137
|
+
return;
|
|
3138
|
+
}
|
|
3139
|
+
const summaries = [];
|
|
3140
|
+
const ownership = {};
|
|
3141
|
+
for (const repo of repos) {
|
|
3142
|
+
const repoRoot = path24.resolve(process.cwd(), repo.path);
|
|
3143
|
+
const result = await scanProject(repoRoot, "fast");
|
|
3144
|
+
summaries.push({
|
|
3145
|
+
name: repo.name,
|
|
3146
|
+
path: repo.path,
|
|
3147
|
+
roles: result.repoRoles,
|
|
3148
|
+
packageManager: result.packageManager,
|
|
3149
|
+
deps: result.dependencies
|
|
3150
|
+
});
|
|
3151
|
+
for (const dep of result.dependencies) {
|
|
3152
|
+
ownership[dep] ??= [];
|
|
3153
|
+
ownership[dep].push(repo.name);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
await writeJson(".haus-workflow/workspace-summary.json", {
|
|
3157
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3158
|
+
repos: summaries
|
|
3159
|
+
});
|
|
3160
|
+
await writeJson(".haus-workflow/dependency-ownership-map.json", ownership);
|
|
3161
|
+
await writeText(
|
|
3162
|
+
".haus-workflow/cross-repo-summary.md",
|
|
3163
|
+
`# Cross Repo Summary
|
|
3164
|
+
|
|
3165
|
+
${summaries.map(
|
|
3166
|
+
(repo) => `- ${repo.name} (${repo.path}) roles: ${repo.roles.join(", ") || "unknown"}; package manager: ${repo.packageManager}`
|
|
3167
|
+
).join("\n")}
|
|
3168
|
+
`
|
|
3169
|
+
);
|
|
3170
|
+
log(
|
|
3171
|
+
"Workspace scan complete. Wrote .haus-workflow/workspace-summary.json, cross-repo-summary.md, dependency-ownership-map.json"
|
|
3172
|
+
);
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
// src/cli.ts
|
|
3176
|
+
function cliVersion() {
|
|
3177
|
+
try {
|
|
3178
|
+
const pkgPath = path25.join(packageRoot(), "package.json");
|
|
3179
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
3180
|
+
return pkg.version ?? "0.0.0";
|
|
3181
|
+
} catch {
|
|
3182
|
+
return "0.0.0";
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
var program = new Command();
|
|
3186
|
+
function validateRuntimeNodeVersion() {
|
|
3187
|
+
try {
|
|
3188
|
+
const pkgPath = path25.join(packageRoot(), "package.json");
|
|
3189
|
+
const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
|
|
3190
|
+
const requiredRange = pkg.engines?.node;
|
|
3191
|
+
if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
|
|
3192
|
+
throw new Error(`Node ${process.version} does not satisfy required range ${requiredRange}`);
|
|
3193
|
+
}
|
|
3194
|
+
} catch (err) {
|
|
3195
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3196
|
+
error(message);
|
|
3197
|
+
process.exit(1);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
validateRuntimeNodeVersion();
|
|
3201
|
+
program.name("haus").description("Haus AI workflow CLI").version(cliVersion());
|
|
3202
|
+
program.command("scan").option("--json").action(runScan);
|
|
3203
|
+
program.command("recommend").option("--json").action(runRecommend);
|
|
3204
|
+
program.command("setup-project").option("--guided").option("--fast").option("--json").action(runSetupProject);
|
|
3205
|
+
program.command("doctor").option("--hooks", "Verify .claude/settings.json matches the hook contract").action(runDoctor);
|
|
3206
|
+
program.command("apply").option("--dry-run").option("--write").action(runApply);
|
|
3207
|
+
program.command("undo").option("-y, --yes", "Skip confirmation").action(runUndo);
|
|
3208
|
+
program.command("explain-recommendation").option("--json").action(runExplainRecommendation);
|
|
3209
|
+
program.command("context").option("--task <task>").option("--from-hook").option("--json").option("--verbose").action(runContext);
|
|
3210
|
+
program.command("init").option("--fast").option("--json").action(runInit);
|
|
3211
|
+
program.command("refresh").action(runRefresh);
|
|
3212
|
+
program.command("catalog-audit").action(runCatalogAudit);
|
|
3213
|
+
program.command("validate-catalog").argument("[manifest]").action(runValidateCatalog);
|
|
3214
|
+
program.command("update").option("--check").action(runUpdate);
|
|
3215
|
+
program.command("install").option("--dry-run").option("--force").option("--check", "Exit non-zero if any HAUS-MANAGED file is out of date").action(runInstall);
|
|
3216
|
+
program.command("uninstall").option("--force").action(runUninstallCommand);
|
|
3217
|
+
var memory = program.command("memory");
|
|
3218
|
+
memory.command("status").action(() => runMemory("status", {}));
|
|
3219
|
+
memory.command("add <text>").action((text) => runMemory("add", { text }));
|
|
3220
|
+
memory.command("inject").option("--task <task>").option("--from-hook").action((opts) => runMemory("inject", opts));
|
|
3221
|
+
memory.command("promote").action(() => runMemory("promote", {}));
|
|
3222
|
+
var guard = program.command("guard");
|
|
3223
|
+
guard.command("file-access").option("--from-hook").action((opts) => runGuard("file-access", opts));
|
|
3224
|
+
guard.command("bash").option("--from-hook").action((opts) => runGuard("bash", opts));
|
|
3225
|
+
var workspace = program.command("workspace");
|
|
3226
|
+
workspace.command("init").action(() => runWorkspace("init"));
|
|
3227
|
+
workspace.command("scan").action(() => runWorkspace("scan"));
|
|
3228
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
3229
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3230
|
+
error(message);
|
|
3231
|
+
process.exitCode = 1;
|
|
3232
|
+
});
|