@haus-tech/haus-workflow 1.1.2 → 1.2.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/dist/cli.js CHANGED
@@ -60,15 +60,15 @@ var init_exec = __esm({
60
60
 
61
61
  // src/cli.ts
62
62
  import { readFileSync as readFileSync4 } from "node:fs";
63
- import path45 from "node:path";
63
+ import path52 from "node:path";
64
64
  import { Command } from "commander";
65
65
 
66
66
  // src/commands/apply.ts
67
- import path17 from "node:path";
67
+ import path22 from "node:path";
68
68
  import checkbox from "@inquirer/checkbox";
69
69
 
70
70
  // src/catalog/load-catalog.ts
71
- import path4 from "node:path";
71
+ import path9 from "node:path";
72
72
 
73
73
  // src/utils/fs.ts
74
74
  import crypto from "node:crypto";
@@ -211,10 +211,9 @@ function packageRoot() {
211
211
  return path2.resolve(path2.dirname(file), "../..");
212
212
  }
213
213
 
214
- // src/catalog/remote-catalog.ts
215
- import os2 from "node:os";
216
- import path3 from "node:path";
217
- import fs2 from "fs-extra";
214
+ // src/catalog/remote-catalog/github-tree.ts
215
+ import path6 from "node:path";
216
+ import fs4 from "fs-extra";
218
217
 
219
218
  // src/utils/logger.ts
220
219
  var log = (msg, ...args) => {
@@ -234,275 +233,287 @@ var SUPERPOWERS_SHARED_CATALOG_REL = "skills/superpowers/shared";
234
233
  var CATALOG_REF = process.env.HAUS_CATALOG_REF;
235
234
  var CATALOG_CACHE_SUBDIR = ".claude/haus/catalog-cache";
236
235
 
237
- // library/catalog/validation-rules.json
238
- var validation_rules_default = {
239
- forbiddenTags: [
240
- "python",
241
- "django",
242
- "go",
243
- "rust",
244
- "java",
245
- "spring",
246
- "kotlin",
247
- "swift",
248
- "android",
249
- "flutter",
250
- "dart",
251
- "c++",
252
- "perl",
253
- "defi",
254
- "trading"
255
- ],
256
- requiredSkillFrontmatter: ["description"],
257
- riskyInstallPatterns: [
258
- { source: "\\bnpx\\s+-y\\b", flags: "i" },
259
- { source: "\\bnpx\\s+--yes\\b", flags: "i" },
260
- { source: "\\byarn\\s+dlx\\b", flags: "i" },
261
- { source: "\\bpnpm\\s+dlx\\b", flags: "i" }
262
- ],
263
- allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
264
- anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
265
- npxTsxOnlyExemptSources: ["curated"],
266
- httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
267
- placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
268
- allowedStacks: [
269
- "haus",
270
- "security",
271
- "quality",
272
- "frontend",
273
- "backend",
274
- "testing",
275
- "review",
276
- "workflow",
277
- "reference-pack",
278
- "core-skill",
279
- "workflow-skill",
280
- "stack-skill",
281
- "review-skill",
282
- "agent",
283
- "hook",
284
- "rule",
285
- "react",
286
- "typescript",
287
- "php",
288
- "csharp",
289
- "vendure",
290
- "vendure3",
291
- "nestjs",
292
- "graphql",
293
- "nx",
294
- "turbo",
295
- "nextjs",
296
- "react19",
297
- "typescript5",
298
- "vite8",
299
- "tanstack-query",
300
- "tanstack-router",
301
- "radix",
302
- "radix-ui",
303
- "shadcn",
304
- "shadcn-ui",
305
- "tailwind",
306
- "tailwindcss",
307
- "scss",
308
- "scss-modules",
309
- "vue",
310
- "expressjs",
311
- "laravel",
312
- "laravel-nova",
313
- "wordpress",
314
- "bedrock",
315
- "elementor-pro",
316
- "acf-pro",
317
- "jetengine",
318
- "dotnet",
319
- "oidc",
320
- "azure-ad",
321
- "bankid",
322
- "crypto",
323
- "postgresql",
324
- "mariadb",
325
- "mssql",
326
- "elasticsearch",
327
- "yarn4",
328
- "pnpm89",
329
- "playwright",
330
- "testing-library",
331
- "phpunit",
332
- "storybook",
333
- "vitest",
334
- "jest",
335
- "redis",
336
- "sanity",
337
- "strapi",
338
- "prisma",
339
- "cms",
340
- "database",
341
- "mysql",
342
- "saml2",
343
- "next-auth",
344
- "auth",
345
- "expo",
346
- "react-native",
347
- "mobile",
348
- "i18next",
349
- "i18n",
350
- "bullmq",
351
- "queue",
352
- "sentry",
353
- "observability",
354
- "tooling",
355
- "prettier",
356
- "eslint",
357
- "missing-prettier",
358
- "missing-eslint",
359
- "docker",
360
- "pm2",
361
- "stripe",
362
- "qliro",
363
- "supabase",
364
- "payments",
365
- "myid",
366
- "cgi",
367
- "collection2",
368
- "deployer-php"
369
- ],
370
- alwaysAllowedTags: [
371
- "haus",
372
- "security",
373
- "quality",
374
- "review",
375
- "workflow",
376
- "baseline",
377
- "project-instructions"
378
- ],
379
- patternTagSuffixes: ["-patterns"]
380
- };
381
-
382
- // src/catalog/validation-rules.ts
383
- var toRegExp = (r) => new RegExp(r.source, r.flags);
384
- var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
385
- var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatter;
386
- var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
387
- var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
388
- var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
389
- var NPX_TSX_ONLY_EXEMPT_SOURCES = validation_rules_default.npxTsxOnlyExemptSources ?? [];
390
- function isNpxTsxOnlyExempt(_itemType, itemSource) {
391
- return Boolean(itemSource && NPX_TSX_ONLY_EXEMPT_SOURCES.includes(itemSource));
236
+ // src/catalog/path-safety.ts
237
+ import path3 from "node:path";
238
+ function isSafeCatalogPath(itemPath) {
239
+ if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
240
+ const normalized = path3.normalize(itemPath);
241
+ return !normalized.startsWith("..") && !normalized.includes("/..");
392
242
  }
393
- function buildItemPathSourceMap(items) {
394
- const map = /* @__PURE__ */ new Map();
395
- for (const item of items) {
396
- if (!item.path) continue;
397
- map.set(String(item.path).replace(/\\/g, "/"), item.source);
398
- }
399
- return map;
243
+ function isSafeRelativeFilePath(rel) {
244
+ if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
245
+ if (path3.isAbsolute(rel)) return false;
246
+ const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
247
+ return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
400
248
  }
401
- function resolveMarkdownItemSource(relPath, pathSourceMap) {
402
- const norm = relPath.replace(/\\/g, "/");
403
- let cursor = norm;
404
- while (true) {
405
- if (pathSourceMap.has(cursor)) return pathSourceMap.get(cursor);
406
- const slash = cursor.lastIndexOf("/");
407
- if (slash === -1) break;
408
- cursor = cursor.slice(0, slash);
249
+ function safeJoin(base, itemPath) {
250
+ if (!isSafeCatalogPath(itemPath)) return null;
251
+ const resolved = path3.resolve(base, itemPath);
252
+ return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
253
+ }
254
+
255
+ // src/catalog/remote-catalog/http.ts
256
+ import path4 from "node:path";
257
+ import fs2 from "fs-extra";
258
+ async function fetchGuarded(url, extract) {
259
+ try {
260
+ const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
261
+ if (!res.ok) {
262
+ warn(`Catalog fetch HTTP ${res.status}: ${url}`);
263
+ return null;
264
+ }
265
+ return await extract(res);
266
+ } catch (e) {
267
+ warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
268
+ return null;
409
269
  }
410
- return void 0;
411
270
  }
412
- var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
413
- var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
414
- var ALLOWED_STACKS = validation_rules_default.allowedStacks;
415
- var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
416
- var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
417
- var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
418
- function isTagAllowed(tag) {
419
- const lower = tag.toLowerCase();
420
- if (ALLOWED_SET.has(lower)) return true;
421
- return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
271
+ async function fetchText(url) {
272
+ return fetchGuarded(url, (res) => res.text());
422
273
  }
423
- function auditDisallowedTags(items) {
424
- const failures = [];
425
- for (const item of items) {
426
- if (!item.id) continue;
427
- for (const tag of Array.isArray(item.tags) ? item.tags : []) {
428
- if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
429
- }
274
+ async function fetchBytes(url) {
275
+ return fetchGuarded(url, async (res) => Buffer.from(await res.arrayBuffer()));
276
+ }
277
+ async function writeTextIfChanged(dest, text) {
278
+ if (await fs2.pathExists(dest)) {
279
+ const local = await fs2.readFile(dest, "utf8");
280
+ if (local === text) return "unchanged";
281
+ await fs2.writeFile(dest, text, "utf8");
282
+ return "updated";
430
283
  }
431
- return failures;
284
+ await fs2.ensureDir(path4.dirname(dest));
285
+ await fs2.writeFile(dest, text, "utf8");
286
+ return "created";
432
287
  }
433
288
 
434
- // src/catalog/forbidden-content.ts
435
- var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
436
- function escapeRegExp(value) {
437
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
289
+ // src/catalog/remote-catalog/ref.ts
290
+ import os2 from "node:os";
291
+ import path5 from "node:path";
292
+ import fs3 from "fs-extra";
293
+ function isTestMode() {
294
+ return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
438
295
  }
439
- function extractUseWhenSection(text) {
440
- const marker = "## Use when";
441
- const idx = text.toLowerCase().indexOf(marker.toLowerCase());
442
- if (idx < 0) return "";
443
- const tail = text.slice(idx + marker.length);
444
- const next = tail.search(/\n##\s+/);
445
- return next < 0 ? tail : tail.slice(0, next);
296
+ function getCacheDir() {
297
+ return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path5.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
446
298
  }
447
- function isYamlBlockScalarHeader(rest) {
448
- return /^[>|][-+]?(\d+)?(?:\s+#.*)?$/.test(rest);
299
+ var cachedCatalogRef;
300
+ var inFlightCatalogRef;
301
+ function getBundledCatalogRef() {
302
+ try {
303
+ const manifestPath2 = path5.join(packageRoot(), "library/catalog/manifest.json");
304
+ const raw = fs3.readFileSync(manifestPath2, "utf8");
305
+ const data = JSON.parse(raw);
306
+ if (typeof data.version === "string" && data.version) {
307
+ return data.version.startsWith("v") ? data.version : `v${data.version}`;
308
+ }
309
+ } catch {
310
+ }
311
+ return void 0;
449
312
  }
450
- function extractFrontmatterValue(text, key) {
451
- const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
452
- if (!m) return "";
453
- const lines = m[1].split(/\r?\n/);
454
- const keyRe = new RegExp(`^${escapeRegExp(key)}:[ \\t]*`);
455
- const idx = lines.findIndex((l) => keyRe.test(l));
456
- if (idx < 0) return "";
457
- const rest = lines[idx].replace(keyRe, "").trim();
458
- if (!rest) return "";
459
- if (!isYamlBlockScalarHeader(rest)) {
460
- return rest.replace(/^["']|["']$/g, "").trim();
313
+ function getResolvedCatalogRef() {
314
+ const resolved = cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
315
+ if (!resolved) {
316
+ warn(
317
+ "Could not determine catalog ref from cache, env, or bundled snapshot \u2014 falling back to main (moving target)."
318
+ );
319
+ return "main";
461
320
  }
462
- const body = [];
463
- for (let j = idx + 1; j < lines.length; j++) {
464
- const line2 = lines[j];
465
- if (/^[a-zA-Z_][\w.-]*:[ \t]/.test(line2)) break;
466
- if (line2.trim() === "") continue;
467
- if (/^\s+/.test(line2)) body.push(line2.trimStart());
468
- else break;
321
+ return resolved;
322
+ }
323
+ function isCatalogRefResolved() {
324
+ return cachedCatalogRef !== void 0 || process.env["HAUS_CATALOG_REF"] !== void 0;
325
+ }
326
+ var CATALOG_TAGS_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog/tags";
327
+ function parseSemverTag(tag) {
328
+ const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag);
329
+ if (!match) return null;
330
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
331
+ }
332
+ function compareSemver(a, b) {
333
+ for (let i = 0; i < 3; i += 1) {
334
+ if (a[i] !== b[i]) return a[i] - b[i];
469
335
  }
470
- return body.join(" ").replace(/\s+/g, " ").trim();
336
+ return 0;
471
337
  }
472
- function extractFrontmatterDescription(text) {
473
- return extractFrontmatterValue(text, "description");
338
+ function githubApiHeaders() {
339
+ const headers = { Accept: "application/vnd.github+json" };
340
+ const auth = process.env["HAUS_GITHUB_TOKEN"] ?? process.env["GITHUB_TOKEN"];
341
+ if (auth) headers["Authorization"] = `Bearer ${auth}`;
342
+ return headers;
474
343
  }
475
- function auditForbiddenTagsInText(text, label) {
476
- const body = `${extractFrontmatterDescription(text)}
477
- ${extractUseWhenSection(text)}`;
478
- if (!body.trim()) return [];
479
- const failures = [];
480
- for (const word of PROSE_FORBIDDEN_TAGS) {
481
- const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
482
- if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
344
+ async function fetchLatestCatalogTag() {
345
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
346
+ try {
347
+ const res = await fetch(CATALOG_TAGS_API_URL, {
348
+ signal: AbortSignal.timeout(5e3),
349
+ headers: githubApiHeaders()
350
+ });
351
+ if (!res.ok) return null;
352
+ const tags = await res.json();
353
+ const valid = tags.map((tag) => {
354
+ const name = typeof tag.name === "string" ? tag.name : "";
355
+ const semver2 = parseSemverTag(name);
356
+ return semver2 ? { name, semver: semver2 } : null;
357
+ }).filter(
358
+ (entry) => entry !== null
359
+ );
360
+ if (valid.length === 0) return null;
361
+ valid.sort((a, b) => compareSemver(b.semver, a.semver));
362
+ return valid[0].name;
363
+ } catch {
364
+ return null;
483
365
  }
484
- return failures;
366
+ }
367
+ async function resolveCatalogRef(opts) {
368
+ const env = opts?.env ?? process.env;
369
+ if (env["HAUS_CATALOG_REF"]) {
370
+ if (env["HAUS_CATALOG_REF"] === "main") {
371
+ warn(
372
+ "HAUS_CATALOG_REF=main is set \u2014 fetching from the moving main branch. Pin to a release tag for reproducible installs."
373
+ );
374
+ }
375
+ return env["HAUS_CATALOG_REF"];
376
+ }
377
+ const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
378
+ const tag = await fetchLatest();
379
+ if (tag !== null) return tag;
380
+ const fallback = opts?.fallbackRef;
381
+ if (fallback) {
382
+ warn(
383
+ `Tag resolution failed \u2014 using cached ref ${fallback}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
384
+ );
385
+ return fallback;
386
+ }
387
+ const bundled = getBundledCatalogRef();
388
+ if (bundled) {
389
+ warn(
390
+ `Tag resolution failed \u2014 using bundled snapshot ref ${bundled}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
391
+ );
392
+ return bundled;
393
+ }
394
+ warn(
395
+ "Tag resolution failed and no fallback ref is available. Set HAUS_CATALOG_REF explicitly to avoid fetching from main."
396
+ );
397
+ return "main";
398
+ }
399
+ async function remoteBase() {
400
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
401
+ return process.env["HAUS_CATALOG_REMOTE_BASE"];
402
+ }
403
+ if (cachedCatalogRef === void 0) {
404
+ if (!inFlightCatalogRef) {
405
+ inFlightCatalogRef = resolveCatalogRef({ fallbackRef: getBundledCatalogRef() }).then(
406
+ (ref) => {
407
+ cachedCatalogRef = ref;
408
+ return ref;
409
+ }
410
+ );
411
+ }
412
+ await inFlightCatalogRef;
413
+ }
414
+ return `${CATALOG_REPO_URL}/${cachedCatalogRef}`;
485
415
  }
486
416
 
487
- // src/catalog/ingest-catalog.ts
488
- function validateCatalogItem(item, content2) {
489
- const label = item.id;
490
- const checkNonTsxNpx = !isNpxTsxOnlyExempt(item.type, item.source);
491
- const lines = content2.split(/\r?\n/);
492
- for (let i = 0; i < lines.length; i++) {
493
- const line2 = lines[i] ?? "";
494
- if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
495
- return { ok: false, reason: `${label}: risky install pattern at line ${i + 1}` };
417
+ // src/catalog/remote-catalog/github-tree.ts
418
+ var cachedBlobPaths;
419
+ var inFlightBlobPaths;
420
+ function sanitizeRelativeFilePaths(files, label) {
421
+ const safe = [];
422
+ for (const rel of files) {
423
+ if (!isSafeRelativeFilePath(rel)) {
424
+ warn(`Rejected unsafe path in ${label}: ${rel}`);
425
+ return null;
496
426
  }
497
- if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
498
- return { ok: false, reason: `${label}: disallowed npx at line ${i + 1}` };
427
+ safe.push(rel);
428
+ }
429
+ return safe;
430
+ }
431
+ async function listFilesRecursive(dir, base = dir) {
432
+ const out = [];
433
+ let entries;
434
+ try {
435
+ if (!await fs4.pathExists(dir)) return out;
436
+ entries = await fs4.readdir(dir, { withFileTypes: true });
437
+ } catch {
438
+ return out;
439
+ }
440
+ for (const entry of entries) {
441
+ const full = path6.join(dir, entry.name);
442
+ if (entry.isDirectory()) {
443
+ out.push(...await listFilesRecursive(full, base));
444
+ } else if (entry.isFile()) {
445
+ out.push(path6.relative(base, full).replace(/\\/g, "/"));
499
446
  }
500
447
  }
501
- const tagFailures = auditForbiddenTagsInText(content2, label);
502
- if (tagFailures.length > 0) {
503
- return { ok: false, reason: tagFailures[0] };
448
+ return out.sort();
449
+ }
450
+ async function listMockPrefixFiles(base, prefix) {
451
+ const text = await fetchText(`${base}/__haus_tree__/${encodeURIComponent(prefix)}`);
452
+ if (text === null) return null;
453
+ try {
454
+ const parsed = JSON.parse(text);
455
+ if (!Array.isArray(parsed) || !parsed.every((e) => typeof e === "string")) return null;
456
+ return parsed;
457
+ } catch {
458
+ return null;
504
459
  }
505
- return { ok: true };
460
+ }
461
+ async function fetchGitHubRecursiveBlobPaths(ref) {
462
+ try {
463
+ const headers = githubApiHeaders();
464
+ const commitRes = await fetch(`${CATALOG_GITHUB_API_URL}/commits/${encodeURIComponent(ref)}`, {
465
+ signal: AbortSignal.timeout(15e3),
466
+ headers
467
+ });
468
+ if (!commitRes.ok) return null;
469
+ const commit = await commitRes.json();
470
+ const treeSha = commit.commit.tree.sha;
471
+ const treeRes = await fetch(`${CATALOG_GITHUB_API_URL}/git/trees/${treeSha}?recursive=1`, {
472
+ signal: AbortSignal.timeout(3e4),
473
+ headers
474
+ });
475
+ if (!treeRes.ok) return null;
476
+ const tree = await treeRes.json();
477
+ if (tree.truncated) {
478
+ warn("Catalog GitHub tree listing was truncated \u2014 refusing partial cache sync");
479
+ return null;
480
+ }
481
+ return tree.tree.filter((e) => e.type === "blob").map((e) => e.path);
482
+ } catch {
483
+ return null;
484
+ }
485
+ }
486
+ async function fetchCatalogBlobPaths(_base) {
487
+ if (cachedBlobPaths) return cachedBlobPaths;
488
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
489
+ if (!inFlightBlobPaths) {
490
+ inFlightBlobPaths = (async () => {
491
+ const ref = getResolvedCatalogRef();
492
+ const paths = await fetchGitHubRecursiveBlobPaths(ref);
493
+ if (paths) cachedBlobPaths = paths;
494
+ inFlightBlobPaths = void 0;
495
+ return paths;
496
+ })();
497
+ }
498
+ return inFlightBlobPaths;
499
+ }
500
+ async function listFilesUnderCatalogPrefix(prefix, base) {
501
+ const normalized = prefix.replace(/\\/g, "/").replace(/\/+$/, "");
502
+ const prefixSlash = `${normalized}/`;
503
+ let relFiles;
504
+ if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
505
+ relFiles = await listMockPrefixFiles(base, normalized);
506
+ } else {
507
+ const blobs = await fetchCatalogBlobPaths(base);
508
+ if (!blobs) return null;
509
+ relFiles = blobs.filter((p) => p.startsWith(prefixSlash)).map((p) => p.slice(prefixSlash.length)).sort();
510
+ }
511
+ if (!relFiles) return null;
512
+ return sanitizeRelativeFilePaths(relFiles, normalized);
513
+ }
514
+ function _resetBlobPathCacheForNewSync() {
515
+ cachedBlobPaths = void 0;
516
+ inFlightBlobPaths = void 0;
506
517
  }
507
518
 
508
519
  // src/catalog/manifest-item-fields.ts
@@ -534,6 +545,42 @@ function validateReferences(itemId, references) {
534
545
  }
535
546
  return null;
536
547
  }
548
+ function validateFormerIds(items) {
549
+ const failures = [];
550
+ const currentIds = new Set(items.map((item) => item.id).filter(Boolean));
551
+ const claimedBy = /* @__PURE__ */ new Map();
552
+ for (const item of items) {
553
+ const itemId = item.id ?? "unknown";
554
+ const formerIds = item.formerIds;
555
+ if (formerIds === void 0) continue;
556
+ if (Array.isArray(formerIds) && formerIds.length === 0) continue;
557
+ if (!isStringArray(formerIds)) {
558
+ failures.push(`${itemId}: formerIds must be a string array`);
559
+ continue;
560
+ }
561
+ const seenInItem = /* @__PURE__ */ new Set();
562
+ for (const formerId of formerIds) {
563
+ if (seenInItem.has(formerId)) {
564
+ failures.push(`${itemId}: duplicate formerId "${formerId}"`);
565
+ continue;
566
+ }
567
+ seenInItem.add(formerId);
568
+ if (formerId === itemId) {
569
+ failures.push(`${itemId}: formerIds must not include own current id`);
570
+ }
571
+ if (currentIds.has(formerId) && formerId !== itemId) {
572
+ failures.push(`${itemId}: formerId "${formerId}" conflicts with another item's current id`);
573
+ }
574
+ const previousOwner = claimedBy.get(formerId);
575
+ if (previousOwner !== void 0) {
576
+ failures.push(`formerId "${formerId}" claimed by both ${previousOwner} and ${itemId}`);
577
+ } else {
578
+ claimedBy.set(formerId, itemId);
579
+ }
580
+ }
581
+ }
582
+ return failures;
583
+ }
537
584
  function validateCuratedProvenance(item) {
538
585
  if (item.source !== "curated") return null;
539
586
  if (!isNonEmptyString(item.reviewStatus)) {
@@ -648,264 +695,332 @@ function parseManifest(json) {
648
695
  if (typeof item.tokenEstimate !== "number" || !Number.isFinite(item.tokenEstimate)) {
649
696
  return { ok: false, error: `${item.id}: tokenEstimate must be a finite number` };
650
697
  }
698
+ if (item.formerIds !== void 0) {
699
+ if (Array.isArray(item.formerIds) && item.formerIds.length === 0) {
700
+ } else if (!isStringArray2(item.formerIds)) {
701
+ return { ok: false, error: `${item.id}: formerIds must be a string array` };
702
+ }
703
+ }
651
704
  items.push(raw);
652
705
  }
706
+ const formerIdFailures = validateFormerIds(items);
707
+ if (formerIdFailures.length > 0) {
708
+ return { ok: false, error: formerIdFailures[0] };
709
+ }
653
710
  return { ok: true, manifest: { version: root.version, items } };
654
711
  }
655
712
 
656
- // src/catalog/remote-catalog.ts
657
- function isTestMode() {
658
- return process.env["HAUS_TEST_MODE"] === "1" || process.env["NODE_ENV"] === "test";
659
- }
660
- function getCacheDir() {
661
- return process.env["HAUS_CATALOG_CACHE_DIR_OVERRIDE"] ?? path3.join(os2.homedir(), CATALOG_CACHE_SUBDIR);
662
- }
663
- var cachedCatalogRef;
664
- var cachedBlobPaths;
665
- function getBundledCatalogRef() {
666
- try {
667
- const manifestPath2 = path3.join(packageRoot(), "library/catalog/manifest.json");
668
- const raw = fs2.readFileSync(manifestPath2, "utf8");
669
- const data = JSON.parse(raw);
670
- if (typeof data.version === "string" && data.version) {
671
- return data.version.startsWith("v") ? data.version : `v${data.version}`;
672
- }
673
- } catch {
674
- }
675
- return void 0;
676
- }
677
- function getResolvedCatalogRef() {
678
- const resolved = cachedCatalogRef ?? process.env["HAUS_CATALOG_REF"] ?? getBundledCatalogRef();
679
- if (!resolved) {
680
- warn(
681
- "Could not determine catalog ref from cache, env, or bundled snapshot \u2014 falling back to main (moving target)."
682
- );
683
- return "main";
684
- }
685
- return resolved;
686
- }
687
- function isCatalogRefResolved() {
688
- return cachedCatalogRef !== void 0 || process.env["HAUS_CATALOG_REF"] !== void 0;
689
- }
690
- async function resolveCatalogRef(opts) {
691
- const env = opts?.env ?? process.env;
692
- if (env["HAUS_CATALOG_REF"]) {
693
- if (env["HAUS_CATALOG_REF"] === "main") {
694
- warn(
695
- "HAUS_CATALOG_REF=main is set \u2014 fetching from the moving main branch. Pin to a release tag for reproducible installs."
696
- );
697
- }
698
- return env["HAUS_CATALOG_REF"];
699
- }
700
- const fetchLatest = opts?.fetchLatestTag ?? fetchLatestCatalogTag;
701
- const tag = await fetchLatest();
702
- if (tag !== null) return tag;
703
- const fallback = opts?.fallbackRef;
704
- if (fallback) {
705
- warn(
706
- `Tag resolution failed \u2014 using cached ref ${fallback}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
707
- );
708
- return fallback;
709
- }
710
- const bundled = getBundledCatalogRef();
711
- if (bundled) {
712
- warn(
713
- `Tag resolution failed \u2014 using bundled snapshot ref ${bundled}. To use latest, retry or set HAUS_CATALOG_REF explicitly.`
714
- );
715
- return bundled;
716
- }
717
- warn(
718
- "Tag resolution failed and no fallback ref is available. Set HAUS_CATALOG_REF explicitly to avoid fetching from main."
719
- );
720
- return "main";
721
- }
722
- async function remoteBase() {
723
- if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
724
- return process.env["HAUS_CATALOG_REMOTE_BASE"];
725
- }
726
- if (cachedCatalogRef === void 0) {
727
- cachedCatalogRef = await resolveCatalogRef({ fallbackRef: getBundledCatalogRef() });
728
- }
729
- return `${CATALOG_REPO_URL}/${cachedCatalogRef}`;
730
- }
731
- async function fetchText(url) {
732
- try {
733
- const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
734
- if (!res.ok) {
735
- warn(`Catalog fetch HTTP ${res.status}: ${url}`);
736
- return null;
737
- }
738
- return await res.text();
739
- } catch (e) {
740
- warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
741
- return null;
742
- }
743
- }
744
- async function fetchBytes(url) {
745
- try {
746
- const res = await fetch(url, { signal: AbortSignal.timeout(1e4) });
747
- if (!res.ok) {
748
- warn(`Catalog fetch HTTP ${res.status}: ${url}`);
749
- return null;
750
- }
751
- return Buffer.from(await res.arrayBuffer());
752
- } catch (e) {
753
- warn(`Catalog fetch failed (${e instanceof Error ? e.constructor.name : String(e)}): ${url}`);
754
- return null;
755
- }
756
- }
757
- async function fetchRemoteManifest() {
758
- const base = await remoteBase();
759
- const text = await fetchText(`${base}/manifest.json`);
760
- if (!text) return null;
761
- const parsed = parseManifest(text);
762
- if (!parsed.ok) {
763
- warn(`Remote manifest failed schema validation: ${parsed.error}`);
764
- return null;
765
- }
766
- if (!parsed.manifest.items.length) return null;
767
- return parsed.manifest;
768
- }
769
- async function writeTextIfChanged(dest, text) {
770
- if (await fs2.pathExists(dest)) {
771
- const local = await fs2.readFile(dest, "utf8");
772
- if (local === text) return "unchanged";
773
- await fs2.writeFile(dest, text, "utf8");
774
- return "updated";
775
- }
776
- await fs2.ensureDir(path3.dirname(dest));
777
- await fs2.writeFile(dest, text, "utf8");
778
- return "created";
713
+ // src/catalog/remote-catalog/manifest.ts
714
+ async function fetchRemoteManifest() {
715
+ const base = await remoteBase();
716
+ const text = await fetchText(`${base}/manifest.json`);
717
+ if (!text) return null;
718
+ const parsed = parseManifest(text);
719
+ if (!parsed.ok) {
720
+ warn(`Remote manifest failed schema validation: ${parsed.error}`);
721
+ return null;
722
+ }
723
+ if (!parsed.manifest.items.length) return null;
724
+ return parsed.manifest;
779
725
  }
726
+
727
+ // src/catalog/remote-catalog/workflow-template.ts
728
+ import path7 from "node:path";
729
+ import fs5 from "fs-extra";
780
730
  var WORKFLOW_TEMPLATE_REL = "templates/agentic-workflow-standard.md";
781
731
  async function readWorkflowTemplate(opts = {}) {
782
- const dest = path3.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
732
+ const dest = path7.join(getCacheDir(), WORKFLOW_TEMPLATE_REL);
783
733
  const base = await remoteBase();
784
734
  const text = await fetchText(`${base}/${WORKFLOW_TEMPLATE_REL}`);
785
735
  if (text === null) {
786
- if (await fs2.pathExists(dest)) return fs2.readFile(dest, "utf8");
736
+ if (await fs5.pathExists(dest)) return fs5.readFile(dest, "utf8");
787
737
  return null;
788
738
  }
789
739
  if (!opts.dryRun) {
790
740
  await writeTextIfChanged(dest, text);
791
- } else if (await fs2.pathExists(dest)) {
792
- return fs2.readFile(dest, "utf8");
793
741
  }
794
742
  return text;
795
743
  }
796
- function isSafeCatalogPath(itemPath) {
797
- if (!itemPath || path3.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
798
- const normalized = path3.normalize(itemPath);
799
- return !normalized.startsWith("..") && !normalized.includes("/..");
744
+
745
+ // src/catalog/remote-catalog/sync.ts
746
+ import path8 from "node:path";
747
+ import fs6 from "fs-extra";
748
+
749
+ // library/catalog/validation-rules.json
750
+ var validation_rules_default = {
751
+ forbiddenTags: [
752
+ "python",
753
+ "django",
754
+ "go",
755
+ "rust",
756
+ "java",
757
+ "spring",
758
+ "kotlin",
759
+ "swift",
760
+ "android",
761
+ "flutter",
762
+ "dart",
763
+ "c++",
764
+ "perl",
765
+ "defi",
766
+ "trading"
767
+ ],
768
+ requiredSkillFrontmatter: ["description"],
769
+ riskyInstallPatterns: [
770
+ { source: "\\bnpx\\s+-y\\b", flags: "i" },
771
+ { source: "\\bnpx\\s+--yes\\b", flags: "i" },
772
+ { source: "\\byarn\\s+dlx\\b", flags: "i" },
773
+ { source: "\\bpnpm\\s+dlx\\b", flags: "i" }
774
+ ],
775
+ allowedNpxPattern: { source: "\\bnpx\\s+tsx\\b", flags: "i" },
776
+ anyNpxPattern: { source: "\\bnpx\\s+\\S+", flags: "i" },
777
+ npxTsxOnlyExemptSources: ["curated"],
778
+ httpUrlPattern: { source: "^http:\\/\\/", flags: "i" },
779
+ placeholderPattern: { source: "\\bTODO\\b|\\bPLACEHOLDER\\b", flags: "i" },
780
+ allowedStacks: [
781
+ "haus",
782
+ "security",
783
+ "quality",
784
+ "frontend",
785
+ "backend",
786
+ "testing",
787
+ "review",
788
+ "workflow",
789
+ "reference-pack",
790
+ "core-skill",
791
+ "workflow-skill",
792
+ "stack-skill",
793
+ "review-skill",
794
+ "agent",
795
+ "hook",
796
+ "rule",
797
+ "react",
798
+ "typescript",
799
+ "php",
800
+ "csharp",
801
+ "vendure",
802
+ "vendure3",
803
+ "nestjs",
804
+ "graphql",
805
+ "nx",
806
+ "turbo",
807
+ "nextjs",
808
+ "react19",
809
+ "typescript5",
810
+ "vite8",
811
+ "tanstack-query",
812
+ "tanstack-router",
813
+ "radix",
814
+ "radix-ui",
815
+ "shadcn",
816
+ "shadcn-ui",
817
+ "tailwind",
818
+ "tailwindcss",
819
+ "scss",
820
+ "scss-modules",
821
+ "vue",
822
+ "expressjs",
823
+ "laravel",
824
+ "laravel-nova",
825
+ "wordpress",
826
+ "bedrock",
827
+ "elementor-pro",
828
+ "acf-pro",
829
+ "jetengine",
830
+ "dotnet",
831
+ "oidc",
832
+ "azure-ad",
833
+ "bankid",
834
+ "crypto",
835
+ "postgresql",
836
+ "mariadb",
837
+ "mssql",
838
+ "elasticsearch",
839
+ "yarn4",
840
+ "pnpm89",
841
+ "playwright",
842
+ "testing-library",
843
+ "phpunit",
844
+ "storybook",
845
+ "vitest",
846
+ "jest",
847
+ "redis",
848
+ "sanity",
849
+ "strapi",
850
+ "prisma",
851
+ "cms",
852
+ "database",
853
+ "mysql",
854
+ "saml2",
855
+ "next-auth",
856
+ "auth",
857
+ "expo",
858
+ "react-native",
859
+ "mobile",
860
+ "i18next",
861
+ "i18n",
862
+ "bullmq",
863
+ "queue",
864
+ "sentry",
865
+ "observability",
866
+ "tooling",
867
+ "prettier",
868
+ "eslint",
869
+ "missing-prettier",
870
+ "missing-eslint",
871
+ "docker",
872
+ "pm2",
873
+ "stripe",
874
+ "qliro",
875
+ "supabase",
876
+ "payments",
877
+ "myid",
878
+ "cgi",
879
+ "collection2",
880
+ "deployer-php"
881
+ ],
882
+ alwaysAllowedTags: [
883
+ "haus",
884
+ "security",
885
+ "quality",
886
+ "review",
887
+ "workflow",
888
+ "baseline",
889
+ "project-instructions"
890
+ ],
891
+ patternTagSuffixes: ["-patterns"]
892
+ };
893
+
894
+ // src/catalog/validation-rules.ts
895
+ var toRegExp = (r) => new RegExp(r.source, r.flags);
896
+ var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
897
+ var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatter;
898
+ var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
899
+ var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
900
+ var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
901
+ var NPX_TSX_ONLY_EXEMPT_SOURCES = validation_rules_default.npxTsxOnlyExemptSources ?? [];
902
+ function isNpxTsxOnlyExempt(_itemType, itemSource) {
903
+ return Boolean(itemSource && NPX_TSX_ONLY_EXEMPT_SOURCES.includes(itemSource));
800
904
  }
801
- function isSafeRelativeFilePath(rel) {
802
- if (!rel || rel.startsWith("/") || rel.includes("\\") || rel.includes("//")) return false;
803
- if (path3.isAbsolute(rel)) return false;
804
- const normalized = path3.posix.normalize(rel.replace(/\\/g, "/"));
805
- return normalized !== ".." && !normalized.startsWith("../") && !normalized.includes("/../");
905
+ function buildItemPathSourceMap(items) {
906
+ const map = /* @__PURE__ */ new Map();
907
+ for (const item of items) {
908
+ if (!item.path) continue;
909
+ map.set(String(item.path).replace(/\\/g, "/"), item.source);
910
+ }
911
+ return map;
806
912
  }
807
- function githubApiHeaders() {
808
- const headers = { Accept: "application/vnd.github+json" };
809
- const auth = process.env["HAUS_GITHUB_TOKEN"] ?? process.env["GITHUB_TOKEN"];
810
- if (auth) headers["Authorization"] = `Bearer ${auth}`;
811
- return headers;
913
+ function resolveMarkdownItemSource(relPath, pathSourceMap) {
914
+ const norm = relPath.replace(/\\/g, "/");
915
+ let cursor = norm;
916
+ while (true) {
917
+ if (pathSourceMap.has(cursor)) return pathSourceMap.get(cursor);
918
+ const slash = cursor.lastIndexOf("/");
919
+ if (slash === -1) break;
920
+ cursor = cursor.slice(0, slash);
921
+ }
922
+ return void 0;
812
923
  }
813
- function sanitizeRelativeFilePaths(files, label) {
814
- const safe = [];
815
- for (const rel of files) {
816
- if (!isSafeRelativeFilePath(rel)) {
817
- warn(`Rejected unsafe path in ${label}: ${rel}`);
818
- return null;
924
+ var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
925
+ var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
926
+ var ALLOWED_STACKS = validation_rules_default.allowedStacks;
927
+ var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
928
+ var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
929
+ var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
930
+ function isTagAllowed(tag) {
931
+ const lower = tag.toLowerCase();
932
+ if (ALLOWED_SET.has(lower)) return true;
933
+ return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
934
+ }
935
+ function auditDisallowedTags(items) {
936
+ const failures = [];
937
+ for (const item of items) {
938
+ if (!item.id) continue;
939
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
940
+ if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
819
941
  }
820
- safe.push(rel);
821
942
  }
822
- return safe;
943
+ return failures;
823
944
  }
824
- function safeJoin(base, itemPath) {
825
- if (!isSafeCatalogPath(itemPath)) return null;
826
- const resolved = path3.resolve(base, itemPath);
827
- return resolved.startsWith(base + path3.sep) || resolved === base ? resolved : null;
945
+
946
+ // src/catalog/forbidden-content.ts
947
+ var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
948
+ function escapeRegExp(value) {
949
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
828
950
  }
829
- var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
830
- function isMarkdownPath(rel) {
831
- return rel.toLowerCase().endsWith(".md");
951
+ function extractUseWhenSection(text) {
952
+ const marker = "## Use when";
953
+ const idx = text.toLowerCase().indexOf(marker.toLowerCase());
954
+ if (idx < 0) return "";
955
+ const tail = text.slice(idx + marker.length);
956
+ const next = tail.search(/\n##\s+/);
957
+ return next < 0 ? tail : tail.slice(0, next);
832
958
  }
833
- async function listFilesRecursive(dir, base = dir) {
834
- const out = [];
835
- let entries;
836
- try {
837
- if (!await fs2.pathExists(dir)) return out;
838
- entries = await fs2.readdir(dir, { withFileTypes: true });
839
- } catch {
840
- return out;
959
+ function isYamlBlockScalarHeader(rest) {
960
+ return /^[>|][-+]?(\d+)?(?:\s+#.*)?$/.test(rest);
961
+ }
962
+ function extractFrontmatterValue(text, key) {
963
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
964
+ if (!m) return "";
965
+ const lines = m[1].split(/\r?\n/);
966
+ const keyRe = new RegExp(`^${escapeRegExp(key)}:[ \\t]*`);
967
+ const idx = lines.findIndex((l) => keyRe.test(l));
968
+ if (idx < 0) return "";
969
+ const rest = lines[idx].replace(keyRe, "").trim();
970
+ if (!rest) return "";
971
+ if (!isYamlBlockScalarHeader(rest)) {
972
+ return rest.replace(/^["']|["']$/g, "").trim();
841
973
  }
842
- for (const entry of entries) {
843
- const full = path3.join(dir, entry.name);
844
- if (entry.isDirectory()) {
845
- out.push(...await listFilesRecursive(full, base));
846
- } else if (entry.isFile()) {
847
- out.push(path3.relative(base, full).replace(/\\/g, "/"));
848
- }
974
+ const body = [];
975
+ for (let j = idx + 1; j < lines.length; j++) {
976
+ const line2 = lines[j];
977
+ if (/^[a-zA-Z_][\w.-]*:[ \t]/.test(line2)) break;
978
+ if (line2.trim() === "") continue;
979
+ if (/^\s+/.test(line2)) body.push(line2.trimStart());
980
+ else break;
849
981
  }
850
- return out.sort();
982
+ return body.join(" ").replace(/\s+/g, " ").trim();
851
983
  }
852
- async function listMockPrefixFiles(base, prefix) {
853
- const text = await fetchText(`${base}/__haus_tree__/${encodeURIComponent(prefix)}`);
854
- if (text === null) return null;
855
- try {
856
- const parsed = JSON.parse(text);
857
- if (!Array.isArray(parsed) || !parsed.every((e) => typeof e === "string")) return null;
858
- return parsed;
859
- } catch {
860
- return null;
984
+ function extractFrontmatterDescription(text) {
985
+ return extractFrontmatterValue(text, "description");
986
+ }
987
+ function auditForbiddenTagsInText(text, label) {
988
+ const body = `${extractFrontmatterDescription(text)}
989
+ ${extractUseWhenSection(text)}`;
990
+ if (!body.trim()) return [];
991
+ const failures = [];
992
+ for (const word of PROSE_FORBIDDEN_TAGS) {
993
+ const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
994
+ if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
861
995
  }
996
+ return failures;
862
997
  }
863
- async function fetchGitHubRecursiveBlobPaths(ref) {
864
- try {
865
- const headers = githubApiHeaders();
866
- const commitRes = await fetch(`${CATALOG_GITHUB_API_URL}/commits/${encodeURIComponent(ref)}`, {
867
- signal: AbortSignal.timeout(15e3),
868
- headers
869
- });
870
- if (!commitRes.ok) return null;
871
- const commit = await commitRes.json();
872
- const treeSha = commit.commit.tree.sha;
873
- const treeRes = await fetch(`${CATALOG_GITHUB_API_URL}/git/trees/${treeSha}?recursive=1`, {
874
- signal: AbortSignal.timeout(3e4),
875
- headers
876
- });
877
- if (!treeRes.ok) return null;
878
- const tree = await treeRes.json();
879
- if (tree.truncated) {
880
- warn("Catalog GitHub tree listing was truncated \u2014 refusing partial cache sync");
881
- return null;
998
+
999
+ // src/catalog/ingest-catalog.ts
1000
+ function validateCatalogItem(item, content2) {
1001
+ const label = item.id;
1002
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(item.type, item.source);
1003
+ const lines = content2.split(/\r?\n/);
1004
+ for (let i = 0; i < lines.length; i++) {
1005
+ const line2 = lines[i] ?? "";
1006
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
1007
+ return { ok: false, reason: `${label}: risky install pattern at line ${i + 1}` };
1008
+ }
1009
+ if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
1010
+ return { ok: false, reason: `${label}: disallowed npx at line ${i + 1}` };
882
1011
  }
883
- return tree.tree.filter((e) => e.type === "blob").map((e) => e.path);
884
- } catch {
885
- return null;
886
1012
  }
887
- }
888
- async function fetchCatalogBlobPaths(_base) {
889
- if (cachedBlobPaths) return cachedBlobPaths;
890
- if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
891
- const ref = getResolvedCatalogRef();
892
- const paths = await fetchGitHubRecursiveBlobPaths(ref);
893
- if (paths) cachedBlobPaths = paths;
894
- return paths;
895
- }
896
- async function listFilesUnderCatalogPrefix(prefix, base) {
897
- const normalized = prefix.replace(/\\/g, "/").replace(/\/+$/, "");
898
- const prefixSlash = `${normalized}/`;
899
- let relFiles;
900
- if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) {
901
- relFiles = await listMockPrefixFiles(base, normalized);
902
- } else {
903
- const blobs = await fetchCatalogBlobPaths(base);
904
- if (!blobs) return null;
905
- relFiles = blobs.filter((p) => p.startsWith(prefixSlash)).map((p) => p.slice(prefixSlash.length)).sort();
1013
+ const tagFailures = auditForbiddenTagsInText(content2, label);
1014
+ if (tagFailures.length > 0) {
1015
+ return { ok: false, reason: tagFailures[0] };
906
1016
  }
907
- if (!relFiles) return null;
908
- return sanitizeRelativeFilePaths(relFiles, normalized);
1017
+ return { ok: true };
1018
+ }
1019
+
1020
+ // src/catalog/remote-catalog/sync.ts
1021
+ var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
1022
+ function isMarkdownPath(rel) {
1023
+ return rel.toLowerCase().endsWith(".md");
909
1024
  }
910
1025
  async function fetchPrefixFiles(catalogPrefix, relFiles, base, label) {
911
1026
  const fetched = [];
@@ -941,7 +1056,7 @@ function validateMarkdownFiles(item, fetched) {
941
1056
  return true;
942
1057
  }
943
1058
  async function directoryMatchesFetched(destDir, fetched) {
944
- if (!await fs2.pathExists(destDir)) return false;
1059
+ if (!await fs6.pathExists(destDir)) return false;
945
1060
  const existing = await listFilesRecursive(destDir);
946
1061
  const relSet = new Set(fetched.map((f) => f.rel));
947
1062
  if (existing.length !== fetched.length) return false;
@@ -949,30 +1064,30 @@ async function directoryMatchesFetched(destDir, fetched) {
949
1064
  if (!relSet.has(rel)) return false;
950
1065
  }
951
1066
  for (const file of fetched) {
952
- const dest = path3.join(destDir, file.rel);
953
- if (!await fs2.pathExists(dest)) return false;
1067
+ const dest = path8.join(destDir, file.rel);
1068
+ if (!await fs6.pathExists(dest)) return false;
954
1069
  if (file.kind === "text") {
955
- const local = await fs2.readFile(dest, "utf8");
1070
+ const local = await fs6.readFile(dest, "utf8");
956
1071
  if (local !== file.body) return false;
957
1072
  } else {
958
- const local = await fs2.readFile(dest);
1073
+ const local = await fs6.readFile(dest);
959
1074
  if (!local.equals(file.body)) return false;
960
1075
  }
961
1076
  }
962
1077
  return true;
963
1078
  }
964
1079
  async function writeFetchedDirectory(destDir, fetched) {
965
- if (await fs2.pathExists(destDir)) {
966
- await fs2.remove(destDir);
1080
+ if (await fs6.pathExists(destDir)) {
1081
+ await fs6.remove(destDir);
967
1082
  }
968
- await fs2.ensureDir(destDir);
1083
+ await fs6.ensureDir(destDir);
969
1084
  for (const file of fetched) {
970
- const dest = path3.join(destDir, file.rel);
971
- await fs2.ensureDir(path3.dirname(dest));
1085
+ const dest = path8.join(destDir, file.rel);
1086
+ await fs6.ensureDir(path8.dirname(dest));
972
1087
  if (file.kind === "text") {
973
- await fs2.writeFile(dest, file.body, "utf8");
1088
+ await fs6.writeFile(dest, file.body, "utf8");
974
1089
  } else {
975
- await fs2.writeFile(dest, file.body);
1090
+ await fs6.writeFile(dest, file.body);
976
1091
  }
977
1092
  }
978
1093
  }
@@ -1012,7 +1127,7 @@ async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts)
1012
1127
  }
1013
1128
  }
1014
1129
  }
1015
- const existed = await fs2.pathExists(destDir);
1130
+ const existed = await fs6.pathExists(destDir);
1016
1131
  if (await directoryMatchesFetched(destDir, fetched)) {
1017
1132
  return "unchanged";
1018
1133
  }
@@ -1129,7 +1244,7 @@ async function syncOneItem(item, base) {
1129
1244
  }
1130
1245
  }
1131
1246
  async function syncRemoteCatalog() {
1132
- cachedBlobPaths = void 0;
1247
+ _resetBlobPathCacheForNewSync();
1133
1248
  const manifest = await fetchRemoteManifest();
1134
1249
  if (!manifest) {
1135
1250
  warn("Remote catalog fetch failed \u2014 using bundled catalog");
@@ -1138,9 +1253,9 @@ async function syncRemoteCatalog() {
1138
1253
  const { version, items } = manifest;
1139
1254
  const cacheDir = getCacheDir();
1140
1255
  try {
1141
- await fs2.ensureDir(cacheDir);
1142
- await fs2.writeFile(
1143
- path3.join(cacheDir, "manifest.json"),
1256
+ await fs6.ensureDir(cacheDir);
1257
+ await fs6.writeFile(
1258
+ path8.join(cacheDir, "manifest.json"),
1144
1259
  `${JSON.stringify({ version, items }, null, 2)}
1145
1260
  `,
1146
1261
  "utf8"
@@ -1171,44 +1286,9 @@ async function syncRemoteCatalog() {
1171
1286
  }
1172
1287
  return { newItems, refreshed, unchanged, failed };
1173
1288
  }
1174
- var CATALOG_TAGS_API_URL = "https://api.github.com/repos/WeAreHausTech/haus-workflow-catalog/tags";
1175
- function parseSemverTag(tag) {
1176
- const match = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(tag);
1177
- if (!match) return null;
1178
- return [Number(match[1]), Number(match[2]), Number(match[3])];
1179
- }
1180
- function compareSemver(a, b) {
1181
- for (let i = 0; i < 3; i += 1) {
1182
- if (a[i] !== b[i]) return a[i] - b[i];
1183
- }
1184
- return 0;
1185
- }
1186
- async function fetchLatestCatalogTag() {
1187
- if (isTestMode() && process.env["HAUS_CATALOG_REMOTE_BASE"]) return null;
1188
- try {
1189
- const res = await fetch(CATALOG_TAGS_API_URL, {
1190
- signal: AbortSignal.timeout(5e3),
1191
- headers: githubApiHeaders()
1192
- });
1193
- if (!res.ok) return null;
1194
- const tags = await res.json();
1195
- const valid = tags.map((tag) => {
1196
- const name = typeof tag.name === "string" ? tag.name : "";
1197
- const semver2 = parseSemverTag(name);
1198
- return semver2 ? { name, semver: semver2 } : null;
1199
- }).filter(
1200
- (entry) => entry !== null
1201
- );
1202
- if (valid.length === 0) return null;
1203
- valid.sort((a, b) => compareSemver(b.semver, a.semver));
1204
- return valid[0].name;
1205
- } catch {
1206
- return null;
1207
- }
1208
- }
1209
1289
  async function getCacheManifestAge() {
1210
1290
  try {
1211
- const stat = await fs2.stat(path3.join(getCacheDir(), "manifest.json"));
1291
+ const stat = await fs6.stat(path8.join(getCacheDir(), "manifest.json"));
1212
1292
  return Date.now() - stat.mtimeMs;
1213
1293
  } catch {
1214
1294
  return null;
@@ -1222,30 +1302,30 @@ async function loadCatalogContext(root) {
1222
1302
  const data2 = await readJson(envPath);
1223
1303
  return {
1224
1304
  items: data2?.items ?? [],
1225
- contentRoot: path4.dirname(envPath),
1305
+ contentRoot: path9.dirname(envPath),
1226
1306
  source: "fixture"
1227
1307
  };
1228
1308
  }
1229
1309
  const cacheDir = getCacheDir();
1230
- const cacheManifestPath = path4.join(cacheDir, "manifest.json");
1310
+ const cacheManifestPath = path9.join(cacheDir, "manifest.json");
1231
1311
  const cacheData = await readJson(cacheManifestPath);
1232
1312
  if (cacheData?.items?.length) {
1233
1313
  return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
1234
1314
  }
1235
- const localManifest = path4.join(root, "library/catalog/manifest.json");
1315
+ const localManifest = path9.join(root, "library/catalog/manifest.json");
1236
1316
  const localData = await readJson(localManifest);
1237
1317
  if (localData?.items?.length) {
1238
1318
  return {
1239
1319
  items: localData.items,
1240
- contentRoot: path4.dirname(localManifest),
1320
+ contentRoot: path9.dirname(localManifest),
1241
1321
  source: "local"
1242
1322
  };
1243
1323
  }
1244
- const packageManifest = path4.join(packageRoot(), "library/catalog/manifest.json");
1324
+ const packageManifest = path9.join(packageRoot(), "library/catalog/manifest.json");
1245
1325
  const data = await readJson(packageManifest);
1246
1326
  return {
1247
1327
  items: data?.items ?? [],
1248
- contentRoot: path4.dirname(packageManifest),
1328
+ contentRoot: path9.dirname(packageManifest),
1249
1329
  source: "bundled"
1250
1330
  };
1251
1331
  }
@@ -1254,17 +1334,63 @@ async function loadCatalog(root) {
1254
1334
  return ctx.items;
1255
1335
  }
1256
1336
  function catalogItemContentPath(contentRoot, item) {
1257
- return path4.join(contentRoot, item.path);
1337
+ return path9.join(contentRoot, item.path);
1258
1338
  }
1259
1339
 
1260
1340
  // src/claude/write-claude-files.ts
1261
- import path14 from "node:path";
1262
- import fs14 from "fs-extra";
1341
+ import path19 from "node:path";
1342
+ import fs18 from "fs-extra";
1343
+
1344
+ // src/catalog/former-ids.ts
1345
+ function normalizeFormerIds(value) {
1346
+ if (value === void 0 || value === null) return [];
1347
+ if (!Array.isArray(value) || value.length === 0) return [];
1348
+ if (!value.every((entry) => typeof entry === "string")) return [];
1349
+ return value.filter((entry) => entry.length > 0);
1350
+ }
1351
+ function assertFormerIdsShape(itemId, formerIds) {
1352
+ if (formerIds === void 0) return;
1353
+ if (Array.isArray(formerIds) && formerIds.length === 0) return;
1354
+ if (!Array.isArray(formerIds) || !formerIds.every((entry) => typeof entry === "string")) {
1355
+ throw new Error(`${itemId}: formerIds must be a string array`);
1356
+ }
1357
+ }
1358
+ function buildFormerIdMap(items) {
1359
+ const map = /* @__PURE__ */ new Map();
1360
+ const currentIds = new Set(items.map((item) => item.id));
1361
+ for (const item of items) {
1362
+ assertFormerIdsShape(item.id, item.formerIds);
1363
+ const formerIds = item.formerIds ?? [];
1364
+ if (!Array.isArray(formerIds) || formerIds.length === 0) continue;
1365
+ for (const formerId of formerIds) {
1366
+ if (currentIds.has(formerId) && formerId !== item.id) {
1367
+ throw new Error(
1368
+ `formerId ${formerId} on ${item.id} conflicts with another item's current id`
1369
+ );
1370
+ }
1371
+ const existingOwner = map.get(formerId);
1372
+ if (existingOwner !== void 0) {
1373
+ throw new Error(
1374
+ `duplicate formerId ${formerId} claimed by both ${existingOwner} and ${item.id}`
1375
+ );
1376
+ }
1377
+ map.set(formerId, item.id);
1378
+ }
1379
+ }
1380
+ return map;
1381
+ }
1382
+ function findFormerIdMigrations(lockItems, manifestItems) {
1383
+ const formerIdMap = buildFormerIdMap(manifestItems);
1384
+ return lockItems.flatMap(({ id }) => {
1385
+ const newId = formerIdMap.get(id);
1386
+ return newId === void 0 ? [] : [{ oldId: id, newId }];
1387
+ });
1388
+ }
1263
1389
 
1264
1390
  // src/update/hash-installed.ts
1265
- import path5 from "node:path";
1391
+ import path10 from "node:path";
1266
1392
  import fg2 from "fast-glob";
1267
- import fs3 from "fs-extra";
1393
+ import fs7 from "fs-extra";
1268
1394
 
1269
1395
  // src/claude/haus-managed-header.ts
1270
1396
  function parseHausManagedAttrs(line2) {
@@ -1303,6 +1429,14 @@ function parseHausManagedHeader(line2) {
1303
1429
 
1304
1430
  // src/update/hash-installed.ts
1305
1431
  var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
1432
+ function digestFileContent(buf) {
1433
+ const asText = buf.toString("utf8");
1434
+ const roundTrip = Buffer.from(asText, "utf8");
1435
+ if (roundTrip.equals(buf)) {
1436
+ return hashText(normaliseLF(asText));
1437
+ }
1438
+ return hashText(buf);
1439
+ }
1306
1440
  async function hashInstalledPaths(root, relPaths) {
1307
1441
  if (relPaths.length === 0) {
1308
1442
  return hashText(EMPTY_LOCK_PATHS_TOKEN);
@@ -1310,21 +1444,21 @@ async function hashInstalledPaths(root, relPaths) {
1310
1444
  const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
1311
1445
  const fileDigests = [];
1312
1446
  for (const rel of normalized) {
1313
- const abs = path5.join(root, rel);
1314
- if (!await fs3.pathExists(abs)) continue;
1315
- const stat = await fs3.stat(abs);
1447
+ const abs = path10.join(root, rel);
1448
+ if (!await fs7.pathExists(abs)) continue;
1449
+ const stat = await fs7.stat(abs);
1316
1450
  if (stat.isFile()) {
1317
- const body = await fs3.readFile(abs, "utf8");
1318
- fileDigests.push({ rel, digest: hashText(normaliseLF(body)) });
1451
+ const body = await fs7.readFile(abs);
1452
+ fileDigests.push({ rel, digest: digestFileContent(body) });
1319
1453
  continue;
1320
1454
  }
1321
1455
  if (!stat.isDirectory()) continue;
1322
1456
  const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
1323
1457
  for (const sub of inner.sort()) {
1324
- const relFile = path5.join(rel, sub).replace(/\\/g, "/");
1325
- const absFile = path5.join(abs, sub);
1326
- const body = await fs3.readFile(absFile, "utf8");
1327
- fileDigests.push({ rel: relFile, digest: hashText(normaliseLF(body)) });
1458
+ const relFile = path10.join(rel, sub).replace(/\\/g, "/");
1459
+ const absFile = path10.join(abs, sub);
1460
+ const body = await fs7.readFile(absFile);
1461
+ fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
1328
1462
  }
1329
1463
  }
1330
1464
  if (fileDigests.length === 0) {
@@ -1335,7 +1469,7 @@ async function hashInstalledPaths(root, relPaths) {
1335
1469
  }
1336
1470
 
1337
1471
  // src/claude/managed-write.ts
1338
- import fs4 from "fs-extra";
1472
+ import fs8 from "fs-extra";
1339
1473
 
1340
1474
  // src/utils/diff.ts
1341
1475
  import { createTwoFilesPatch } from "diff";
@@ -1361,10 +1495,11 @@ function summarizeDiff(diffText) {
1361
1495
 
1362
1496
  // src/claude/managed-write.ts
1363
1497
  async function writeManagedText(root, filePath, nextText, dryRun) {
1364
- const prev = await fs4.pathExists(filePath) ? await fs4.readFile(filePath, "utf8") : "";
1498
+ const existed = await fs8.pathExists(filePath);
1499
+ const prev = existed ? await fs8.readFile(filePath, "utf8") : "";
1365
1500
  const printable = displayPath(root, filePath);
1366
1501
  if (dryRun) {
1367
- if (!prev) {
1502
+ if (!existed) {
1368
1503
  log(createUnifiedDiff(printable, "", nextText));
1369
1504
  } else if (hasTextChanged(prev, nextText)) {
1370
1505
  log(createUnifiedDiff(printable, prev, nextText));
@@ -1373,12 +1508,14 @@ async function writeManagedText(root, filePath, nextText, dryRun) {
1373
1508
  }
1374
1509
  return;
1375
1510
  }
1376
- if (hasTextChanged(prev, nextText) && prev.length > 0) {
1377
- const diffText = createUnifiedDiff(printable, prev, nextText);
1378
- const summary = summarizeDiff(diffText);
1379
- log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
1511
+ if (!existed || hasTextChanged(prev, nextText)) {
1512
+ if (existed) {
1513
+ const diffText = createUnifiedDiff(printable, prev, nextText);
1514
+ const summary = summarizeDiff(diffText);
1515
+ log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
1516
+ }
1517
+ await writeText(filePath, nextText);
1380
1518
  }
1381
- await writeText(filePath, nextText);
1382
1519
  }
1383
1520
  async function writeManagedJson(root, filePath, value, dryRun) {
1384
1521
  const nextText = `${JSON.stringify(value, null, 2)}
@@ -1393,18 +1530,18 @@ function buildAllowRules() {
1393
1530
  }
1394
1531
 
1395
1532
  // src/install/settings-merge.ts
1396
- import path7 from "node:path";
1397
- import fs5 from "fs-extra";
1533
+ import path12 from "node:path";
1534
+ import fs9 from "fs-extra";
1398
1535
 
1399
1536
  // src/install/manifest.ts
1400
1537
  import os3 from "node:os";
1401
- import path6 from "node:path";
1538
+ import path11 from "node:path";
1402
1539
  var MANIFEST_SCHEMA = "haus-install-manifest/1";
1403
1540
  function globalClaudeDir() {
1404
- return path6.join(os3.homedir(), ".claude");
1541
+ return path11.join(os3.homedir(), ".claude");
1405
1542
  }
1406
1543
  function hausManifestPath() {
1407
- return path6.join(globalClaudeDir(), "haus", "install-manifest.json");
1544
+ return path11.join(globalClaudeDir(), "haus", "install-manifest.json");
1408
1545
  }
1409
1546
  async function readManifest() {
1410
1547
  return readJson(hausManifestPath());
@@ -1424,7 +1561,7 @@ function buildManifest(source, files, hooks) {
1424
1561
 
1425
1562
  // src/install/settings-merge.ts
1426
1563
  function settingsJsonPath() {
1427
- return path7.join(globalClaudeDir(), "settings.json");
1564
+ return path12.join(globalClaudeDir(), "settings.json");
1428
1565
  }
1429
1566
  async function readSettings() {
1430
1567
  const filePath = settingsJsonPath();
@@ -1539,77 +1676,62 @@ function reconcileManagedRules(existing, prevTracked, newRules) {
1539
1676
  const removed = existing.filter((rule) => !newSet.has(rule));
1540
1677
  return { rules: [...userRules, ...tracked], tracked, added, removed };
1541
1678
  }
1679
+ function createRuleTier(permKey, trackedKey) {
1680
+ function merge(settings, rules) {
1681
+ const existing = settings.permissions?.[permKey] ?? [];
1682
+ const tracked = settings._haus?.[trackedKey] ?? [];
1683
+ const {
1684
+ rules: nextRules,
1685
+ tracked: nextTracked,
1686
+ added
1687
+ } = reconcileManagedRules(existing, tracked, rules);
1688
+ const updated = { ...settings };
1689
+ updated.permissions = { ...settings.permissions ?? {}, [permKey]: nextRules };
1690
+ updated._haus = {
1691
+ hooks: settings._haus?.hooks ?? [],
1692
+ ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
1693
+ ...permKey !== "deny" && settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
1694
+ ...permKey !== "allow" && settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {},
1695
+ ...permKey !== "ask" && settings._haus?.askRules ? { askRules: settings._haus.askRules } : {},
1696
+ [trackedKey]: nextTracked
1697
+ };
1698
+ return { settings: updated, addedRules: added };
1699
+ }
1700
+ function strip(settings) {
1701
+ const prevHaus = settings._haus;
1702
+ const ownedRules = prevHaus?.[trackedKey];
1703
+ if (!ownedRules || ownedRules.length === 0) return settings;
1704
+ const ownedSet = new Set(ownedRules);
1705
+ const updated = { ...settings };
1706
+ const remaining = (settings.permissions?.[permKey] ?? []).filter((rule) => !ownedSet.has(rule));
1707
+ const permissions = { ...settings.permissions ?? {} };
1708
+ if (remaining.length > 0) permissions[permKey] = remaining;
1709
+ else delete permissions[permKey];
1710
+ if (Object.keys(permissions).length > 0) updated.permissions = permissions;
1711
+ else delete updated.permissions;
1712
+ const haus = { ...prevHaus };
1713
+ delete haus[trackedKey];
1714
+ const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0 || (haus.askRules?.length ?? 0) > 0;
1715
+ if (stillTracking) updated._haus = haus;
1716
+ else delete updated._haus;
1717
+ return updated;
1718
+ }
1719
+ return { merge, strip };
1720
+ }
1721
+ var denyTier = createRuleTier("deny", "denyRules");
1722
+ var allowTier = createRuleTier("allow", "allowRules");
1723
+ var askTier = createRuleTier("ask", "askRules");
1542
1724
  function mergeDenyRules(settings, rules) {
1543
- const existingDeny = settings.permissions?.deny ?? [];
1544
- const trackedDeny = settings._haus?.denyRules ?? [];
1545
- const { rules: deny, tracked, added } = reconcileManagedRules(existingDeny, trackedDeny, rules);
1546
- const updated = { ...settings };
1547
- updated.permissions = {
1548
- ...settings.permissions ?? {},
1549
- deny
1550
- };
1551
- updated._haus = {
1552
- hooks: settings._haus?.hooks ?? [],
1553
- ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
1554
- denyRules: tracked,
1555
- ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {},
1556
- ...settings._haus?.askRules ? { askRules: settings._haus.askRules } : {}
1557
- };
1558
- return { settings: updated, addedRules: added };
1725
+ return denyTier.merge(settings, rules);
1559
1726
  }
1560
1727
  function mergeAllowRules(settings, rules) {
1561
- const existingAllow = settings.permissions?.allow ?? [];
1562
- const trackedAllow = settings._haus?.allowRules ?? [];
1563
- const { rules: allow, tracked, added } = reconcileManagedRules(existingAllow, trackedAllow, rules);
1564
- const updated = { ...settings };
1565
- updated.permissions = {
1566
- ...settings.permissions ?? {},
1567
- allow
1568
- };
1569
- updated._haus = {
1570
- hooks: settings._haus?.hooks ?? [],
1571
- ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
1572
- ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
1573
- allowRules: tracked,
1574
- ...settings._haus?.askRules ? { askRules: settings._haus.askRules } : {}
1575
- };
1576
- return { settings: updated, addedRules: added };
1728
+ return allowTier.merge(settings, rules);
1577
1729
  }
1578
1730
  function stripHausAllow(settings) {
1579
- const prevHaus = settings._haus;
1580
- if (!prevHaus?.allowRules || prevHaus.allowRules.length === 0) return settings;
1581
- const ownedSet = new Set(prevHaus.allowRules);
1582
- const updated = { ...settings };
1583
- const remainingAllow = (settings.permissions?.allow ?? []).filter((rule) => !ownedSet.has(rule));
1584
- const permissions = { ...settings.permissions ?? {} };
1585
- if (remainingAllow.length > 0) permissions.allow = remainingAllow;
1586
- else delete permissions.allow;
1587
- if (Object.keys(permissions).length > 0) updated.permissions = permissions;
1588
- else delete updated.permissions;
1589
- const haus = { ...prevHaus };
1590
- delete haus.allowRules;
1591
- const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0 || (haus.askRules?.length ?? 0) > 0;
1592
- if (stillTracking) updated._haus = haus;
1593
- else delete updated._haus;
1594
- return updated;
1731
+ return allowTier.strip(settings);
1595
1732
  }
1596
1733
  function stripHausDeny(settings) {
1597
- const prevHaus = settings._haus;
1598
- if (!prevHaus?.denyRules || prevHaus.denyRules.length === 0) return settings;
1599
- const ownedSet = new Set(prevHaus.denyRules);
1600
- const updated = { ...settings };
1601
- const remainingDeny = (settings.permissions?.deny ?? []).filter((rule) => !ownedSet.has(rule));
1602
- const permissions = { ...settings.permissions ?? {} };
1603
- if (remainingDeny.length > 0) permissions.deny = remainingDeny;
1604
- else delete permissions.deny;
1605
- if (Object.keys(permissions).length > 0) updated.permissions = permissions;
1606
- else delete updated.permissions;
1607
- const haus = { ...prevHaus };
1608
- delete haus.denyRules;
1609
- const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0 || (haus.askRules?.length ?? 0) > 0;
1610
- if (stillTracking) updated._haus = haus;
1611
- else delete updated._haus;
1612
- return updated;
1734
+ return denyTier.strip(settings);
1613
1735
  }
1614
1736
  function stripHausHooks(settings) {
1615
1737
  if (!settings._haus) return settings;
@@ -1631,45 +1753,15 @@ function stripHausHooks(settings) {
1631
1753
  return rest;
1632
1754
  }
1633
1755
  function mergeAskRules(settings, rules) {
1634
- const existingAsk = settings.permissions?.ask ?? [];
1635
- const trackedAsk = settings._haus?.askRules ?? [];
1636
- const { rules: ask2, tracked, added } = reconcileManagedRules(existingAsk, trackedAsk, rules);
1637
- const updated = { ...settings };
1638
- updated.permissions = {
1639
- ...settings.permissions ?? {},
1640
- ask: ask2
1641
- };
1642
- updated._haus = {
1643
- hooks: settings._haus?.hooks ?? [],
1644
- ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
1645
- ...settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
1646
- ...settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {},
1647
- askRules: tracked
1648
- };
1649
- return { settings: updated, addedRules: added };
1756
+ return askTier.merge(settings, rules);
1650
1757
  }
1651
1758
  function stripHausAsk(settings) {
1652
- const prevHaus = settings._haus;
1653
- if (!prevHaus?.askRules || prevHaus.askRules.length === 0) return settings;
1654
- const ownedSet = new Set(prevHaus.askRules);
1655
- const updated = { ...settings };
1656
- const remainingAsk = (settings.permissions?.ask ?? []).filter((rule) => !ownedSet.has(rule));
1657
- const permissions = { ...settings.permissions ?? {} };
1658
- if (remainingAsk.length > 0) permissions.ask = remainingAsk;
1659
- else delete permissions.ask;
1660
- if (Object.keys(permissions).length > 0) updated.permissions = permissions;
1661
- else delete updated.permissions;
1662
- const haus = { ...prevHaus };
1663
- delete haus.askRules;
1664
- const stillTracking = (haus.hooks?.length ?? 0) > 0 || (haus.hookCommands?.length ?? 0) > 0 || (haus.denyRules?.length ?? 0) > 0 || (haus.allowRules?.length ?? 0) > 0;
1665
- if (stillTracking) updated._haus = haus;
1666
- else delete updated._haus;
1667
- return updated;
1759
+ return askTier.strip(settings);
1668
1760
  }
1669
1761
  async function loadHooksFragment(fragmentPath) {
1670
1762
  let raw;
1671
1763
  try {
1672
- raw = await fs5.readJson(fragmentPath);
1764
+ raw = await fs9.readJson(fragmentPath);
1673
1765
  } catch {
1674
1766
  return [];
1675
1767
  }
@@ -1784,8 +1876,8 @@ function buildDenyRules() {
1784
1876
  for (const command of DENY_COMMANDS) {
1785
1877
  rules.push(`Bash(${command}:*)`);
1786
1878
  }
1787
- for (const path46 of DENY_PATHS) {
1788
- const pattern = DENY_DIRS.has(path46) ? `${path46}/**` : path46;
1879
+ for (const path53 of DENY_PATHS) {
1880
+ const pattern = DENY_DIRS.has(path53) ? `${path53}/**` : path53;
1789
1881
  for (const tool of FILE_TOOLS) {
1790
1882
  rules.push(`${tool}(${pattern})`);
1791
1883
  }
@@ -1857,9 +1949,9 @@ async function applyProjectSettingsMerge(root) {
1857
1949
  }
1858
1950
 
1859
1951
  // src/claude/superpowers-install.ts
1860
- import path8 from "node:path";
1952
+ import path13 from "node:path";
1861
1953
  import fg3 from "fast-glob";
1862
- import fs6 from "fs-extra";
1954
+ import fs10 from "fs-extra";
1863
1955
  var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
1864
1956
  var PATH_REWRITES = [
1865
1957
  ["skills/shared/", ".claude/skills/shared/"]
@@ -1874,41 +1966,41 @@ function rewriteSuperpowersMarkdown(text) {
1874
1966
  async function rewriteMarkdownTree(dir) {
1875
1967
  const files = await fg3("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
1876
1968
  for (const rel of files) {
1877
- const abs = path8.join(dir, rel);
1878
- const text = await fs6.readFile(abs, "utf8");
1969
+ const abs = path13.join(dir, rel);
1970
+ const text = await fs10.readFile(abs, "utf8");
1879
1971
  const rewritten = rewriteSuperpowersMarkdown(text);
1880
1972
  if (rewritten !== text) {
1881
- await fs6.writeFile(abs, rewritten, "utf8");
1973
+ await fs10.writeFile(abs, rewritten, "utf8");
1882
1974
  }
1883
1975
  }
1884
1976
  }
1885
1977
  async function installCatalogSkill(sourcePath, destination, opts) {
1886
1978
  if (opts.dryRun) return;
1887
- await fs6.ensureDir(path8.dirname(destination));
1888
- if (await fs6.pathExists(destination)) {
1889
- await fs6.remove(destination);
1979
+ await fs10.ensureDir(path13.dirname(destination));
1980
+ if (await fs10.pathExists(destination)) {
1981
+ await fs10.remove(destination);
1890
1982
  }
1891
- await fs6.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
1983
+ await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
1892
1984
  if (opts.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID) {
1893
1985
  await rewriteMarkdownTree(destination);
1894
1986
  }
1895
1987
  }
1896
1988
  async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
1897
- const source = path8.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
1898
- if (!await fs6.pathExists(source)) return null;
1989
+ const source = path13.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
1990
+ if (!await fs10.pathExists(source)) return null;
1899
1991
  const destination = claudePath(projectRoot, "skills", "shared");
1900
- if (dryRun) return path8.relative(projectRoot, destination);
1901
- await fs6.ensureDir(path8.dirname(destination));
1902
- if (await fs6.pathExists(destination)) {
1903
- await fs6.remove(destination);
1992
+ if (dryRun) return path13.relative(projectRoot, destination);
1993
+ await fs10.ensureDir(path13.dirname(destination));
1994
+ if (await fs10.pathExists(destination)) {
1995
+ await fs10.remove(destination);
1904
1996
  }
1905
- await fs6.copy(source, destination, { overwrite: true, errorOnExist: false });
1997
+ await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
1906
1998
  await rewriteMarkdownTree(destination);
1907
- return path8.relative(projectRoot, destination);
1999
+ return path13.relative(projectRoot, destination);
1908
2000
  }
1909
2001
 
1910
2002
  // src/claude/verify-hooks-contract.ts
1911
- import fs7 from "fs-extra";
2003
+ import fs11 from "fs-extra";
1912
2004
 
1913
2005
  // src/claude/load-hooks.ts
1914
2006
  var CANONICAL_HOOKS = {
@@ -1988,7 +2080,7 @@ async function assertPostApplySettingsHausContract(root) {
1988
2080
  }
1989
2081
  async function verifyProjectSettingsHooksContract(root) {
1990
2082
  const settingsPath = claudePath(root, "settings.json");
1991
- if (!await fs7.pathExists(settingsPath)) {
2083
+ if (!await fs11.pathExists(settingsPath)) {
1992
2084
  return {
1993
2085
  ok: true,
1994
2086
  skipped: true,
@@ -2015,8 +2107,8 @@ async function verifyProjectSettingsHooksContract(root) {
2015
2107
  }
2016
2108
 
2017
2109
  // src/claude/write-decisions-seed.ts
2018
- import path9 from "node:path";
2019
- import fs8 from "fs-extra";
2110
+ import path14 from "node:path";
2111
+ import fs12 from "fs-extra";
2020
2112
 
2021
2113
  // library/catalog/decisions-triggers.json
2022
2114
  var decisions_triggers_default = {
@@ -2059,17 +2151,17 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
2059
2151
  | --- | ----- | -------------- | ------ |
2060
2152
  `;
2061
2153
  async function writeDecisionsSeed(root, dryRun) {
2062
- const decisionsDir = path9.join(root, DECISIONS_TRIGGERS.decisionsDir);
2063
- const readmePath = path9.join(decisionsDir, "README.md");
2064
- if (await fs8.pathExists(readmePath)) return null;
2065
- if (!dryRun) await fs8.ensureDir(decisionsDir);
2154
+ const decisionsDir = path14.join(root, DECISIONS_TRIGGERS.decisionsDir);
2155
+ const readmePath = path14.join(decisionsDir, "README.md");
2156
+ if (await fs12.pathExists(readmePath)) return null;
2157
+ if (!dryRun) await fs12.ensureDir(decisionsDir);
2066
2158
  await writeManagedText(root, readmePath, SEED_README, dryRun);
2067
2159
  return readmePath;
2068
2160
  }
2069
2161
 
2070
2162
  // src/claude/write-prettierignore.ts
2071
- import path10 from "node:path";
2072
- import fs9 from "fs-extra";
2163
+ import path15 from "node:path";
2164
+ import fs13 from "fs-extra";
2073
2165
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
2074
2166
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
2075
2167
  var IGNORED_PATHS = [".haus-workflow/"];
@@ -2125,16 +2217,16 @@ ${block}
2125
2217
  `;
2126
2218
  }
2127
2219
  async function writePrettierIgnore(root, dryRun) {
2128
- const filePath = path10.join(root, ".prettierignore");
2129
- const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
2220
+ const filePath = path15.join(root, ".prettierignore");
2221
+ const prev = await fs13.pathExists(filePath) ? await fs13.readFile(filePath, "utf8") : "";
2130
2222
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
2131
2223
  await writeManagedText(root, filePath, next, dryRun);
2132
2224
  return filePath;
2133
2225
  }
2134
2226
 
2135
2227
  // src/claude/write-root-claude-md.ts
2136
- import path11 from "node:path";
2137
- import fs10 from "fs-extra";
2228
+ import path16 from "node:path";
2229
+ import fs14 from "fs-extra";
2138
2230
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
2139
2231
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
2140
2232
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
@@ -2165,12 +2257,20 @@ function findImportBlockRange(content2) {
2165
2257
  }
2166
2258
  function stripHausBlock(existing) {
2167
2259
  const range = findImportBlockRange(existing);
2168
- if (!range) return existing;
2169
- const before = existing.slice(0, range.beginStart);
2170
- const after = existing.slice(range.endEnd);
2171
- const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
2172
- return merged.length > 0 ? `${merged}
2260
+ if (range) {
2261
+ const before = existing.slice(0, range.beginStart);
2262
+ const after = existing.slice(range.endEnd);
2263
+ const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
2264
+ return merged.length > 0 ? `${merged}
2173
2265
  ` : "";
2266
+ }
2267
+ const loneBegin = findLineMarker2(existing, BLOCK_BEGIN);
2268
+ if (loneBegin) {
2269
+ const before = existing.slice(0, loneBegin.start).trimEnd();
2270
+ return before.length > 0 ? `${before}
2271
+ ` : "";
2272
+ }
2273
+ return existing;
2174
2274
  }
2175
2275
  function injectHausBlock(existing, block) {
2176
2276
  const range = findImportBlockRange(existing);
@@ -2200,21 +2300,21 @@ ${block}
2200
2300
  `;
2201
2301
  }
2202
2302
  async function writeRootClaudeMd(root, dryRun) {
2203
- const filePath = path11.join(root, "CLAUDE.md");
2303
+ const filePath = path16.join(root, "CLAUDE.md");
2204
2304
  const block = buildImportBlock();
2205
- const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
2305
+ const prev = await fs14.pathExists(filePath) ? await fs14.readFile(filePath, "utf8") : "";
2206
2306
  const next = injectHausBlock(prev, block);
2207
2307
  await writeManagedText(root, filePath, next, dryRun);
2208
2308
  return filePath;
2209
2309
  }
2210
2310
 
2211
2311
  // src/claude/write-workflow-config.ts
2212
- import path13 from "node:path";
2213
- import fs12 from "fs-extra";
2312
+ import path18 from "node:path";
2313
+ import fs16 from "fs-extra";
2214
2314
 
2215
2315
  // src/claude/derive-workflow-config.ts
2216
- import path12 from "node:path";
2217
- import fs11 from "fs-extra";
2316
+ import path17 from "node:path";
2317
+ import fs15 from "fs-extra";
2218
2318
  function binCmd(pm, bin, args) {
2219
2319
  const tail = args ? ` ${args}` : "";
2220
2320
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -2223,7 +2323,7 @@ function binCmd(pm, bin, args) {
2223
2323
  }
2224
2324
  async function deriveWorkflowConfig(root, ctx) {
2225
2325
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
2226
- const pkg = await readJson(path12.join(root, "package.json"));
2326
+ const pkg = await readJson(path17.join(root, "package.json"));
2227
2327
  const scripts = pkg?.scripts ?? {};
2228
2328
  const deps = new Set(ctx.dependencies);
2229
2329
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -2233,7 +2333,7 @@ async function deriveWorkflowConfig(root, ctx) {
2233
2333
  return null;
2234
2334
  };
2235
2335
  const hasDep = (name) => deps.has(name);
2236
- const exists = (rel) => fs11.pathExistsSync(path12.join(root, rel));
2336
+ const exists = (rel) => fs15.pathExistsSync(path17.join(root, rel));
2237
2337
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
2238
2338
  const hasCypress = hasDep("cypress");
2239
2339
  const preCommitTool = exists("lefthook.yml") || exists("lefthook.yaml") ? "lefthook" : exists(".husky") || hasDep("husky") || (scripts.prepare ?? "").includes("husky") ? "husky" : exists(".pre-commit-config.yaml") ? "pre-commit (Python framework)" : null;
@@ -2301,7 +2401,7 @@ var FALLBACK_CONTEXT = {
2301
2401
  async function writeWorkflowConfig(root, dryRun, opts = {}) {
2302
2402
  const destPath = hausPath(root, "workflow-config.md");
2303
2403
  const printable = displayPath(root, destPath);
2304
- const exists = await fs12.pathExists(destPath);
2404
+ const exists = await fs16.pathExists(destPath);
2305
2405
  if (exists && !opts.refill) {
2306
2406
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
2307
2407
  return null;
@@ -2309,11 +2409,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2309
2409
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2310
2410
  ...FALLBACK_CONTEXT,
2311
2411
  root,
2312
- repoName: path13.basename(root)
2412
+ repoName: path18.basename(root)
2313
2413
  };
2314
2414
  const values = await deriveWorkflowConfig(root, ctx);
2315
2415
  if (exists) {
2316
- const current = await fs12.readFile(destPath, "utf8");
2416
+ const current = await fs16.readFile(destPath, "utf8");
2317
2417
  const refilled = refillContent(current, values);
2318
2418
  if (refilled === current) {
2319
2419
  if (dryRun) log(printable + ": no blank fields to refill");
@@ -2335,7 +2435,23 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2335
2435
  }
2336
2436
 
2337
2437
  // src/claude/write-workflow.ts
2338
- import fs13 from "fs-extra";
2438
+ import fs17 from "fs-extra";
2439
+
2440
+ // src/claude/managed-tamper.ts
2441
+ function checkManagedTamper(body, storedHash, templateContent) {
2442
+ const bodyHash = hashText(normaliseLF(body));
2443
+ if (storedHash) {
2444
+ if (bodyHash !== storedHash) return { status: "tampered" };
2445
+ if (templateContent === null) return { status: "ok" };
2446
+ const currentTemplateHash2 = hashText(normaliseLF(templateContent));
2447
+ return storedHash !== currentTemplateHash2 ? { status: "stale" } : { status: "ok" };
2448
+ }
2449
+ if (templateContent === null) return { status: "legacy-ok" };
2450
+ const currentTemplateHash = hashText(normaliseLF(templateContent));
2451
+ return bodyHash === currentTemplateHash ? { status: "legacy-ok" } : { status: "legacy-diverged" };
2452
+ }
2453
+
2454
+ // src/claude/write-workflow.ts
2339
2455
  var STABLE_ID = "template.workflow";
2340
2456
  function makeWorkflowHeader(pkgVersion, contentHash) {
2341
2457
  return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
@@ -2354,8 +2470,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2354
2470
  ${templateContent}`;
2355
2471
  const destPath = hausPath(root, "WORKFLOW.md");
2356
2472
  const printable = displayPath(root, destPath);
2357
- if (await fs13.pathExists(destPath)) {
2358
- const existing = await fs13.readFile(destPath, "utf8");
2473
+ if (await fs17.pathExists(destPath)) {
2474
+ const existing = await fs17.readFile(destPath, "utf8");
2359
2475
  const firstLine = existing.split("\n")[0] ?? "";
2360
2476
  const parsed = parseHausManagedHeader(firstLine);
2361
2477
  if (!parsed) {
@@ -2373,32 +2489,25 @@ ${templateContent}`;
2373
2489
  return null;
2374
2490
  }
2375
2491
  const existingContent = existing.slice(firstLine.length + 1);
2376
- const bodyMatchesTemplate = hashText(normaliseLF(existingContent)) === contentHash;
2377
- if (parsed.hash) {
2378
- const bodyIntact = hashText(normaliseLF(existingContent)) === parsed.hash;
2379
- if (!bodyIntact && !force) {
2380
- warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
2381
- return null;
2382
- }
2383
- } else {
2384
- if (!bodyMatchesTemplate && !force) {
2385
- const migratedHeader = makeWorkflowHeader(
2386
- pkgVersion,
2387
- hashText(normaliseLF(existingContent))
2388
- );
2389
- const migrated = `${migratedHeader}
2492
+ const verdict = checkManagedTamper(existingContent, parsed.hash, templateContent);
2493
+ if (verdict.status === "tampered" && !force) {
2494
+ warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
2495
+ return null;
2496
+ }
2497
+ if (verdict.status === "legacy-diverged" && !force) {
2498
+ const migratedHeader = makeWorkflowHeader(pkgVersion, hashText(normaliseLF(existingContent)));
2499
+ const migrated = `${migratedHeader}
2390
2500
  ${existingContent}`;
2391
- if (hasTextChanged(existing, migrated)) {
2392
- if (dryRun) {
2393
- log(`${printable}: migrating legacy hash header (body preserved)`);
2394
- } else {
2395
- await writeText(destPath, migrated);
2396
- }
2397
- } else if (dryRun) {
2398
- log(`${printable}: unchanged`);
2501
+ if (hasTextChanged(existing, migrated)) {
2502
+ if (dryRun) {
2503
+ log(`${printable}: migrating legacy hash header (body preserved)`);
2504
+ } else {
2505
+ await writeText(destPath, migrated);
2399
2506
  }
2400
- return destPath;
2507
+ } else if (dryRun) {
2508
+ log(`${printable}: unchanged`);
2401
2509
  }
2510
+ return destPath;
2402
2511
  }
2403
2512
  if (!hasTextChanged(existing, next)) {
2404
2513
  if (dryRun) log(`${printable}: unchanged`);
@@ -2406,7 +2515,7 @@ ${existingContent}`;
2406
2515
  }
2407
2516
  }
2408
2517
  if (dryRun) {
2409
- const prev = await fs13.pathExists(destPath) ? await fs13.readFile(destPath, "utf8") : "";
2518
+ const prev = await fs17.pathExists(destPath) ? await fs17.readFile(destPath, "utf8") : "";
2410
2519
  if (!prev) {
2411
2520
  log(createUnifiedDiff(printable, "", next));
2412
2521
  } else {
@@ -2428,6 +2537,19 @@ function targetDirForType(type) {
2428
2537
  if (type === "skill") return "skills";
2429
2538
  return null;
2430
2539
  }
2540
+ async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say) {
2541
+ const target = claudePath(root, ...relPathSegments);
2542
+ if (!await fs18.pathExists(target)) return;
2543
+ const content2 = await fs18.readFile(target, "utf8");
2544
+ if (content2 !== stub && content2 !== `${stub}
2545
+ ` && content2 !== `${stub}\r
2546
+ `) return;
2547
+ if (dryRun) {
2548
+ say(`[dry-run] would remove stale ${displayPath(root, target)}`);
2549
+ } else {
2550
+ await fs18.remove(target);
2551
+ }
2552
+ }
2431
2553
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2432
2554
  const say = opts.quiet ? () => {
2433
2555
  } : log;
@@ -2441,7 +2563,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2441
2563
  estimatedTokenReductionPct: 0
2442
2564
  };
2443
2565
  const pkgRoot = packageRoot();
2444
- const hausVersion2 = (await readJson(path14.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2566
+ const hausVersion2 = (await readJson(path19.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2445
2567
  const coreFiles = [claudePath(root, "settings.json"), claudePath(root, "rules", "haus.md")];
2446
2568
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2447
2569
  const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
@@ -2465,34 +2587,20 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2465
2587
  await applyProjectSettingsMerge(root);
2466
2588
  await assertPostApplySettingsHausContract(root);
2467
2589
  }
2468
- const legacyReviewPath = claudePath(root, "commands", "haus-review.md");
2469
- if (await fs14.pathExists(legacyReviewPath)) {
2470
- const content2 = await fs14.readFile(legacyReviewPath, "utf8");
2471
- const stub = 'Run `haus context --task "code review"` then review diff.';
2472
- if (content2 === stub || content2 === `${stub}
2473
- ` || content2 === `${stub}\r
2474
- `) {
2475
- if (dryRun) {
2476
- say(`[dry-run] would remove stale ${displayPath(root, legacyReviewPath)}`);
2477
- } else {
2478
- await fs14.remove(legacyReviewPath);
2479
- }
2480
- }
2481
- }
2482
- const legacyDoctorPath = claudePath(root, "commands", "haus-doctor.md");
2483
- if (await fs14.pathExists(legacyDoctorPath)) {
2484
- const content2 = await fs14.readFile(legacyDoctorPath, "utf8");
2485
- const stub = "Run `haus doctor`.";
2486
- if (content2 === stub || content2 === `${stub}
2487
- ` || content2 === `${stub}\r
2488
- `) {
2489
- if (dryRun) {
2490
- say(`[dry-run] would remove stale ${displayPath(root, legacyDoctorPath)}`);
2491
- } else {
2492
- await fs14.remove(legacyDoctorPath);
2493
- }
2494
- }
2495
- }
2590
+ await removeLegacyManagedStub(
2591
+ root,
2592
+ ["commands", "haus-review.md"],
2593
+ 'Run `haus context --task "code review"` then review diff.',
2594
+ dryRun,
2595
+ say
2596
+ );
2597
+ await removeLegacyManagedStub(
2598
+ root,
2599
+ ["commands", "haus-doctor.md"],
2600
+ "Run `haus doctor`.",
2601
+ dryRun,
2602
+ say
2603
+ );
2496
2604
  await writeManagedText(
2497
2605
  root,
2498
2606
  claudePath(root, "rules", "haus.md"),
@@ -2516,20 +2624,13 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2516
2624
  ].join("\n"),
2517
2625
  dryRun
2518
2626
  );
2519
- const legacySecurityPath = claudePath(root, "rules", "security.md");
2520
- if (await fs14.pathExists(legacySecurityPath)) {
2521
- const content2 = await fs14.readFile(legacySecurityPath, "utf8");
2522
- const stub = "- Never read secrets.\n- Block dangerous shell commands.";
2523
- if (content2 === stub || content2 === `${stub}
2524
- ` || content2 === `${stub}\r
2525
- `) {
2526
- if (dryRun) {
2527
- say(`[dry-run] would remove stale ${displayPath(root, legacySecurityPath)}`);
2528
- } else {
2529
- await fs14.remove(legacySecurityPath);
2530
- }
2531
- }
2532
- }
2627
+ await removeLegacyManagedStub(
2628
+ root,
2629
+ ["rules", "security.md"],
2630
+ "- Never read secrets.\n- Block dangerous shell commands.",
2631
+ dryRun,
2632
+ say
2633
+ );
2533
2634
  const LEGACY_PRUNED_ARTIFACTS = [
2534
2635
  "config.json",
2535
2636
  "selected-context.json",
@@ -2541,19 +2642,68 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2541
2642
  ];
2542
2643
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
2543
2644
  const legacyPath = hausPath(root, rel);
2544
- if (await fs14.pathExists(legacyPath)) {
2645
+ if (await fs18.pathExists(legacyPath)) {
2545
2646
  if (dryRun) {
2546
2647
  say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2547
2648
  } else {
2548
- await fs14.remove(legacyPath);
2649
+ await fs18.remove(legacyPath);
2549
2650
  }
2550
2651
  }
2551
2652
  }
2552
2653
  const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
2553
2654
  const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
2655
+ const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
2656
+ const allMigrations = findFormerIdMigrations(
2657
+ prevLock.filter((entry) => Boolean(entry.id)),
2658
+ manifestItems
2659
+ );
2660
+ const migrations = allMigrations.filter(
2661
+ (migration) => selectedIds === void 0 || selectedIds.includes(migration.oldId) || selectedIds.includes(migration.newId)
2662
+ );
2663
+ const migrationByOldId = new Map(
2664
+ migrations.map((migration) => [migration.oldId, migration.newId])
2665
+ );
2666
+ const cleanupManifestById = new Map(manifestById);
2667
+ for (const migration of allMigrations) {
2668
+ const currentItem = manifestById.get(migration.newId);
2669
+ if (currentItem) cleanupManifestById.set(migration.oldId, currentItem);
2670
+ }
2671
+ for (const migration of migrations) {
2672
+ if (dryRun) {
2673
+ say(`[dry-run] would migrate ${migration.oldId} \u2192 ${migration.newId} (upstream rename)`);
2674
+ } else {
2675
+ warn(`migrated ${migration.oldId} \u2192 ${migration.newId} (upstream rename)`);
2676
+ }
2677
+ }
2678
+ await cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, opts.quiet);
2554
2679
  const installedPathsByItem = /* @__PURE__ */ new Map();
2555
2680
  const installedIds = /* @__PURE__ */ new Set();
2556
- const catalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
2681
+ const selectedCatalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
2682
+ const catalogItemsById = /* @__PURE__ */ new Map();
2683
+ for (const recommended of selectedCatalogItems) {
2684
+ const id = migrationByOldId.get(recommended.id) ?? recommended.id;
2685
+ const manifestItem = manifestById.get(id);
2686
+ catalogItemsById.set(id, {
2687
+ ...recommended,
2688
+ id,
2689
+ type: manifestItem?.type ?? recommended.type
2690
+ });
2691
+ }
2692
+ for (const migration of migrations) {
2693
+ if (catalogItemsById.has(migration.newId)) continue;
2694
+ const lockItem = prevLock.find((entry) => entry.id === migration.oldId);
2695
+ const manifestItem = manifestById.get(migration.newId);
2696
+ if (!lockItem || !manifestItem) continue;
2697
+ catalogItemsById.set(migration.newId, {
2698
+ id: migration.newId,
2699
+ type: manifestItem.type,
2700
+ reason: "migrated from former catalog id",
2701
+ reasons: [{ code: "former-id", message: `Renamed from ${migration.oldId}` }],
2702
+ selectionMode: "manual",
2703
+ install: true
2704
+ });
2705
+ }
2706
+ const catalogItems = [...catalogItemsById.values()];
2557
2707
  let curatedReviewStatusSkips = 0;
2558
2708
  let superpowersSharedInstalled = false;
2559
2709
  for (const item of catalogItems) {
@@ -2587,21 +2737,21 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2587
2737
  );
2588
2738
  continue;
2589
2739
  }
2590
- const destination = claudePath(root, target, path14.basename(sourcePath));
2591
- if (await fs14.pathExists(sourcePath)) {
2740
+ const destination = claudePath(root, target, path19.basename(sourcePath));
2741
+ if (await fs18.pathExists(sourcePath)) {
2592
2742
  if (dryRun) {
2593
- const exists = await fs14.pathExists(destination);
2743
+ const exists = await fs18.pathExists(destination);
2594
2744
  say(
2595
2745
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
2596
2746
  );
2597
2747
  } else if (item.type === "skill") {
2598
- const skillFiles = (await fs14.readdir(sourcePath, { recursive: true })).filter(
2748
+ const skillFiles = (await fs18.readdir(sourcePath, { recursive: true })).filter(
2599
2749
  (f) => typeof f === "string" && f.endsWith(".md")
2600
2750
  );
2601
2751
  let skillValid = true;
2602
2752
  for (const relFile of skillFiles) {
2603
- const mdPath = path14.join(sourcePath, relFile);
2604
- const mdContent = await fs14.readFile(mdPath, "utf8");
2753
+ const mdPath = path19.join(sourcePath, relFile);
2754
+ const mdContent = await fs18.readFile(mdPath, "utf8");
2605
2755
  const mdValidation = validateCatalogItem(
2606
2756
  {
2607
2757
  id: manifestItem.id,
@@ -2625,7 +2775,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2625
2775
  dryRun: false
2626
2776
  });
2627
2777
  } else {
2628
- const fileContent = await fs14.readFile(sourcePath, "utf8");
2778
+ const fileContent = await fs18.readFile(sourcePath, "utf8");
2629
2779
  const validation = validateCatalogItem(
2630
2780
  {
2631
2781
  id: manifestItem.id,
@@ -2639,17 +2789,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2639
2789
  warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
2640
2790
  continue;
2641
2791
  }
2642
- await fs14.ensureDir(path14.dirname(destination));
2643
- await fs14.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2792
+ await fs18.ensureDir(path19.dirname(destination));
2793
+ await fs18.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2644
2794
  }
2645
2795
  files.push(destination);
2646
- const relPaths = [path14.relative(root, destination)];
2796
+ const relPaths = [path19.relative(root, destination)];
2647
2797
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
2648
2798
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
2649
2799
  if (sharedRel) {
2650
2800
  superpowersSharedInstalled = true;
2651
2801
  relPaths.push(sharedRel);
2652
- files.push(path14.join(root, sharedRel));
2802
+ files.push(path19.join(root, sharedRel));
2653
2803
  }
2654
2804
  }
2655
2805
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -2666,12 +2816,11 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2666
2816
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
2667
2817
  );
2668
2818
  }
2669
- await cleanupStaleCatalogItems(root, manifestById, dryRun, opts.quiet);
2819
+ await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
2670
2820
  if (dryRun) return [...new Set(files)];
2671
2821
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2672
- const prevLock = await readJson(hausPath(root, "haus.lock.json"));
2673
2822
  const prevRefById = new Map(
2674
- (prevLock ?? []).filter((e) => e.id && e.catalogRef).map((e) => [e.id, e.catalogRef])
2823
+ prevLock.filter((e) => e.id && e.catalogRef).map((e) => [migrationByOldId.get(e.id) ?? e.id, e.catalogRef])
2675
2824
  );
2676
2825
  const lockCatalogRef = (itemId) => isCatalogRefResolved() ? getResolvedCatalogRef() : prevRefById.get(itemId) ?? getResolvedCatalogRef();
2677
2826
  const lock = await Promise.all(
@@ -2703,6 +2852,44 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2703
2852
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
2704
2853
  return [...new Set(files)];
2705
2854
  }
2855
+ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
2856
+ const say = quiet ? () => {
2857
+ } : log;
2858
+ for (const migration of migrations) {
2859
+ const entry = prevLock.find((candidate) => candidate.id === migration.oldId);
2860
+ if (!entry) continue;
2861
+ const relPaths = entry.paths ?? [];
2862
+ const existing = [];
2863
+ for (const rel of relPaths) {
2864
+ if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
2865
+ }
2866
+ if (existing.length === 0) continue;
2867
+ if (entry.hash === void 0) {
2868
+ warn(
2869
+ `Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.join(", ")}`
2870
+ );
2871
+ continue;
2872
+ }
2873
+ const currentHash = await hashInstalledPaths(root, existing);
2874
+ if (currentHash !== entry.hash) {
2875
+ warn(
2876
+ `Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.join(", ")}`
2877
+ );
2878
+ continue;
2879
+ }
2880
+ for (const rel of existing) {
2881
+ const abs = path19.join(root, rel);
2882
+ if (dryRun) {
2883
+ say(
2884
+ `[dry-run] would remove renamed ${displayPath(root, abs)} (${migration.oldId} \u2192 ${migration.newId})`
2885
+ );
2886
+ continue;
2887
+ }
2888
+ await fs18.remove(abs);
2889
+ await pruneEmptyDir(path19.dirname(abs));
2890
+ }
2891
+ }
2892
+ }
2706
2893
  async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2707
2894
  const say = quiet ? () => {
2708
2895
  } : log;
@@ -2718,7 +2905,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2718
2905
  if (relPaths.length === 0) continue;
2719
2906
  const existing = [];
2720
2907
  for (const rel of relPaths) {
2721
- if (await fs14.pathExists(path14.join(root, rel))) existing.push(rel);
2908
+ if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
2722
2909
  }
2723
2910
  if (existing.length === 0) continue;
2724
2911
  const pruneReason = deprecated ? "deprecated" : "stale";
@@ -2736,45 +2923,46 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2736
2923
  continue;
2737
2924
  }
2738
2925
  for (const rel of existing) {
2739
- const abs = path14.join(root, rel);
2926
+ const abs = path19.join(root, rel);
2740
2927
  if (dryRun) {
2741
2928
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2742
2929
  continue;
2743
2930
  }
2744
- await fs14.remove(abs);
2745
- await pruneEmptyDir(path14.dirname(abs));
2931
+ await fs18.remove(abs);
2932
+ await pruneEmptyDir(path19.dirname(abs));
2746
2933
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2747
2934
  }
2748
2935
  }
2749
2936
  }
2750
2937
 
2751
2938
  // src/refs/fetch-refs.ts
2752
- import path16 from "node:path";
2753
- import fs16 from "fs-extra";
2939
+ import path21 from "node:path";
2940
+ import fs20 from "fs-extra";
2754
2941
 
2755
2942
  // src/refs/cache-meta.ts
2756
- import path15 from "node:path";
2757
- import fs15 from "fs-extra";
2943
+ import path20 from "node:path";
2944
+ import fs19 from "fs-extra";
2758
2945
  var META_FILENAME = "cache-meta.json";
2759
2946
  async function readCacheMeta(cacheDir) {
2760
- const metaPath = path15.join(cacheDir, META_FILENAME);
2947
+ const metaPath = path20.join(cacheDir, META_FILENAME);
2761
2948
  try {
2762
- const raw = await fs15.readFile(metaPath, "utf8");
2949
+ const raw = await fs19.readFile(metaPath, "utf8");
2763
2950
  return JSON.parse(raw);
2764
2951
  } catch {
2765
2952
  return {};
2766
2953
  }
2767
2954
  }
2768
2955
  async function writeCacheMeta(cacheDir, meta) {
2769
- await fs15.ensureDir(cacheDir);
2770
- const metaPath = path15.join(cacheDir, META_FILENAME);
2771
- await fs15.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
2956
+ await fs19.ensureDir(cacheDir);
2957
+ const metaPath = path20.join(cacheDir, META_FILENAME);
2958
+ await fs19.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
2772
2959
  }
2773
2960
 
2774
2961
  // src/refs/fetch-refs.ts
2775
2962
  function urlToSlug(url) {
2776
2963
  const u = new URL(url);
2777
- return (u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2964
+ const protocolPrefix = u.protocol === "https:" ? "" : `${u.protocol.replace(":", "")}-`;
2965
+ return (protocolPrefix + u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2778
2966
  }
2779
2967
  function isLlmsTxtUrl(ref) {
2780
2968
  try {
@@ -2787,8 +2975,8 @@ function isLlmsTxtUrl(ref) {
2787
2975
  async function fetchSingleRef(url, cacheDir, meta) {
2788
2976
  const existing = meta[url];
2789
2977
  const headers = {};
2790
- const safeFile = existing?.file && existing.file === path16.basename(existing.file);
2791
- const cachedFileExists = safeFile && await fs16.pathExists(path16.join(cacheDir, existing.file));
2978
+ const safeFile = existing?.file && existing.file === path21.basename(existing.file);
2979
+ const cachedFileExists = safeFile && await fs20.pathExists(path21.join(cacheDir, existing.file));
2792
2980
  if (cachedFileExists) {
2793
2981
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
2794
2982
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -2799,8 +2987,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
2799
2987
  if (!res.ok) return { result: "failed" };
2800
2988
  const text = await res.text();
2801
2989
  const file = `${urlToSlug(url)}.md`;
2802
- await fs16.ensureDir(cacheDir);
2803
- await fs16.writeFile(path16.join(cacheDir, file), text, "utf8");
2990
+ await fs20.ensureDir(cacheDir);
2991
+ await fs20.writeFile(path21.join(cacheDir, file), text, "utf8");
2804
2992
  meta[url] = {
2805
2993
  url,
2806
2994
  etag: res.headers.get("etag") ?? void 0,
@@ -2823,8 +3011,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
2823
3011
  if (keepUrls.has(url)) continue;
2824
3012
  delete meta[url];
2825
3013
  removed++;
2826
- const safeFile = typeof entry.file === "string" && entry.file === path16.basename(entry.file);
2827
- if (safeFile) await fs16.remove(path16.join(cacheDir, entry.file)).catch(() => {
3014
+ const safeFile = typeof entry.file === "string" && entry.file === path21.basename(entry.file);
3015
+ if (safeFile) await fs20.remove(path21.join(cacheDir, entry.file)).catch(() => {
2828
3016
  });
2829
3017
  }
2830
3018
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -2846,10 +3034,10 @@ async function fetchRefsForItems(items, cacheDir) {
2846
3034
  const outcome = await fetchSingleRef(url, cacheDir, meta);
2847
3035
  if (outcome.result === "fetched") {
2848
3036
  summary.fetched++;
2849
- summary.cachedFiles[url] = path16.join(cacheDir, outcome.file);
3037
+ summary.cachedFiles[url] = path21.join(cacheDir, outcome.file);
2850
3038
  } else if (outcome.result === "unchanged") {
2851
3039
  summary.unchanged++;
2852
- if (meta[url]?.file) summary.cachedFiles[url] = path16.join(cacheDir, meta[url].file);
3040
+ if (meta[url]?.file) summary.cachedFiles[url] = path21.join(cacheDir, meta[url].file);
2853
3041
  } else {
2854
3042
  summary.failed++;
2855
3043
  summary.failedUrls.push(url);
@@ -2870,8 +3058,9 @@ function parseIdList(value) {
2870
3058
  }
2871
3059
 
2872
3060
  // src/commands/apply.ts
3061
+ var STALE_CACHE_THRESHOLD_DAYS = 7;
2873
3062
  async function cacheHasItems() {
2874
- const data = await readJson(path17.join(getCacheDir(), "manifest.json"));
3063
+ const data = await readJson(path22.join(getCacheDir(), "manifest.json"));
2875
3064
  return Array.isArray(data?.items) && data.items.length > 0;
2876
3065
  }
2877
3066
  function installableRecommendedItems(recommended) {
@@ -2944,18 +3133,31 @@ async function runApply(options) {
2944
3133
  }
2945
3134
  }
2946
3135
  }
2947
- if (!options.allowEmptyCache && !process.env["HAUS_FIXTURE_CATALOG"]) {
3136
+ if (!process.env["HAUS_FIXTURE_CATALOG"]) {
2948
3137
  const rec = await readJson(hausPath(root, "recommendation.json"));
2949
3138
  const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
2950
3139
  const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
2951
- if (catalogItemCount > 0 && !await cacheHasItems()) {
2952
- const message = "No catalog content found. Run `haus update` first.";
2953
- if (isDryRun) {
2954
- warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message}`);
2955
- } else {
2956
- error(message);
2957
- process.exitCode = 1;
2958
- return;
3140
+ if (catalogItemCount > 0) {
3141
+ const hasItems = await cacheHasItems();
3142
+ if (!hasItems && !options.allowEmptyCache) {
3143
+ const message = "No catalog content found. Run `haus update` first.";
3144
+ if (isDryRun) {
3145
+ warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message}`);
3146
+ } else {
3147
+ error(message);
3148
+ process.exitCode = 1;
3149
+ return;
3150
+ }
3151
+ } else if (hasItems) {
3152
+ const cacheAgeMs = await getCacheManifestAge();
3153
+ if (cacheAgeMs !== null) {
3154
+ const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
3155
+ if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
3156
+ warn(
3157
+ `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
3158
+ );
3159
+ }
3160
+ }
2959
3161
  }
2960
3162
  }
2961
3163
  }
@@ -3024,7 +3226,7 @@ async function runCatalogAudit() {
3024
3226
  // src/commands/clone.ts
3025
3227
  init_exec();
3026
3228
  import { existsSync as existsSync2 } from "node:fs";
3027
- import path18 from "node:path";
3229
+ import path23 from "node:path";
3028
3230
  var GIT_LOCATION_VARS = [
3029
3231
  "GIT_DIR",
3030
3232
  "GIT_WORK_TREE",
@@ -3050,9 +3252,9 @@ async function runClone(url, opts = {}) {
3050
3252
  process.exitCode = 1;
3051
3253
  return;
3052
3254
  }
3053
- const target = path18.resolve(opts.dir?.trim() || repoNameFromUrl(url));
3255
+ const target = path23.resolve(opts.dir?.trim() || repoNameFromUrl(url));
3054
3256
  if (existsSync2(target)) {
3055
- log(`\u2022 ${path18.basename(target)} already present at ${target} \u2014 skipped`);
3257
+ log(`\u2022 ${path23.basename(target)} already present at ${target} \u2014 skipped`);
3056
3258
  return;
3057
3259
  }
3058
3260
  if (opts.dryRun) {
@@ -3123,8 +3325,8 @@ async function resolveBaseRef(root) {
3123
3325
  }
3124
3326
 
3125
3327
  // src/decisions/check.ts
3126
- import path21 from "node:path";
3127
- import fs19 from "fs-extra";
3328
+ import path26 from "node:path";
3329
+ import fs23 from "fs-extra";
3128
3330
 
3129
3331
  // src/decisions/diff.ts
3130
3332
  init_exec();
@@ -3179,22 +3381,22 @@ function matchesAnyRegex(filePath, patterns) {
3179
3381
  }
3180
3382
 
3181
3383
  // src/decisions/paths.ts
3182
- import path19 from "node:path";
3183
- import fs17 from "fs-extra";
3384
+ import path24 from "node:path";
3385
+ import fs21 from "fs-extra";
3184
3386
  async function resolveDecisionsDir(root) {
3185
- const primary = path19.join(root, DECISIONS_TRIGGERS.decisionsDir);
3186
- if (await fs17.pathExists(primary)) return primary;
3187
- const legacy = path19.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3188
- if (await fs17.pathExists(legacy)) return legacy;
3387
+ const primary = path24.join(root, DECISIONS_TRIGGERS.decisionsDir);
3388
+ if (await fs21.pathExists(primary)) return primary;
3389
+ const legacy = path24.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3390
+ if (await fs21.pathExists(legacy)) return legacy;
3189
3391
  return primary;
3190
3392
  }
3191
3393
  function relativeDecisionPath(decisionsDir, root) {
3192
- return path19.relative(root, decisionsDir).replace(/\\/g, "/");
3394
+ return path24.relative(root, decisionsDir).replace(/\\/g, "/");
3193
3395
  }
3194
3396
 
3195
3397
  // src/decisions/validate.ts
3196
- import path20 from "node:path";
3197
- import fs18 from "fs-extra";
3398
+ import path25 from "node:path";
3399
+ import fs22 from "fs-extra";
3198
3400
  function validateDecisionContent(content2) {
3199
3401
  const errors = [];
3200
3402
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -3223,14 +3425,14 @@ function decisionNumberFromFile(name) {
3223
3425
  return match?.[1] ?? null;
3224
3426
  }
3225
3427
  async function validateIndexSync(decisionsDir, decisionFiles) {
3226
- const readmePath = path20.join(decisionsDir, "README.md");
3227
- if (!await fs18.pathExists(readmePath)) {
3428
+ const readmePath = path25.join(decisionsDir, "README.md");
3429
+ if (!await fs22.pathExists(readmePath)) {
3228
3430
  return ["docs/decisions/README.md is missing"];
3229
3431
  }
3230
3432
  const readme = await readText(readmePath) ?? "";
3231
3433
  const errors = [];
3232
3434
  for (const file of decisionFiles) {
3233
- const num = decisionNumberFromFile(path20.basename(file));
3435
+ const num = decisionNumberFromFile(path25.basename(file));
3234
3436
  if (num && !indexReferencesNumber(readme, num)) {
3235
3437
  errors.push(`README.md missing index row for ADR-${num}`);
3236
3438
  }
@@ -3284,22 +3486,22 @@ async function checkSatisfaction(input2) {
3284
3486
  const errors = [];
3285
3487
  const decisionsPrefix = `${input2.decisionsRel}/`;
3286
3488
  const newDecisionFiles = input2.changedFiles.filter(
3287
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path21.basename(f))
3489
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path26.basename(f))
3288
3490
  );
3289
3491
  if (newDecisionFiles.length === 0) {
3290
3492
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
3291
3493
  return errors;
3292
3494
  }
3293
- const decisionsDir = path21.join(input2.root, input2.decisionsRel);
3495
+ const decisionsDir = path26.join(input2.root, input2.decisionsRel);
3294
3496
  for (const rel of newDecisionFiles) {
3295
- const abs = path21.join(input2.root, rel);
3497
+ const abs = path26.join(input2.root, rel);
3296
3498
  const content2 = await readText(abs) ?? "";
3297
3499
  const valid = validateDecisionContent(content2);
3298
3500
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
3299
3501
  }
3300
3502
  const indexErrors = await validateIndexSync(
3301
3503
  decisionsDir,
3302
- newDecisionFiles.map((f) => path21.basename(f))
3504
+ newDecisionFiles.map((f) => path26.basename(f))
3303
3505
  );
3304
3506
  errors.push(...indexErrors);
3305
3507
  return errors;
@@ -3343,8 +3545,8 @@ ${opts.commitMessages ?? ""}`;
3343
3545
  changedFiles: stats.files
3344
3546
  });
3345
3547
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
3346
- const legacyExists = await fs19.pathExists(path21.join(root, legacyRel));
3347
- if (legacyExists && !await fs19.pathExists(decisionsDir)) {
3548
+ const legacyExists = await fs23.pathExists(path26.join(root, legacyRel));
3549
+ if (legacyExists && !await fs23.pathExists(decisionsDir)) {
3348
3550
  satisfactionErrors = await checkSatisfaction({
3349
3551
  root,
3350
3552
  decisionsRel: legacyRel,
@@ -3364,8 +3566,8 @@ ${opts.commitMessages ?? ""}`;
3364
3566
  return { triggered: true, satisfied: false, reasons, securityTriggered };
3365
3567
  }
3366
3568
  async function listDecisionNumbers(decisionsDir) {
3367
- if (!await fs19.pathExists(decisionsDir)) return [];
3368
- const entries = await fs19.readdir(decisionsDir);
3569
+ if (!await fs23.pathExists(decisionsDir)) return [];
3570
+ const entries = await fs23.readdir(decisionsDir);
3369
3571
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
3370
3572
  }
3371
3573
 
@@ -3407,7 +3609,7 @@ async function runDecisionsGuard(options = {}) {
3407
3609
  }
3408
3610
 
3409
3611
  // src/commands/decisions.ts
3410
- import path22 from "node:path";
3612
+ import path27 from "node:path";
3411
3613
 
3412
3614
  // src/decisions/next-number.ts
3413
3615
  async function nextDecisionNumber(root) {
@@ -3518,7 +3720,7 @@ async function runDecisions(sub, arg, options = {}) {
3518
3720
  return;
3519
3721
  }
3520
3722
  if (sub === "validate") {
3521
- const target = arg ? path22.resolve(root, arg) : void 0;
3723
+ const target = arg ? path27.resolve(root, arg) : void 0;
3522
3724
  if (!target) {
3523
3725
  error("usage: haus decisions validate <path-to-decision.md>");
3524
3726
  process.exitCode = 1;
@@ -3535,18 +3737,18 @@ async function runDecisions(sub, arg, options = {}) {
3535
3737
  }
3536
3738
 
3537
3739
  // src/commands/doctor.ts
3538
- import path28 from "node:path";
3539
- import fs22 from "fs-extra";
3740
+ import path34 from "node:path";
3741
+ import fs27 from "fs-extra";
3540
3742
 
3541
3743
  // src/decisions/doctor.ts
3542
- import path23 from "node:path";
3543
- import fs20 from "fs-extra";
3744
+ import path28 from "node:path";
3745
+ import fs24 from "fs-extra";
3544
3746
  async function auditDecisionsLayout(root) {
3545
3747
  const findings = [];
3546
- const primary = path23.join(root, DECISIONS_TRIGGERS.decisionsDir);
3547
- const legacy = path23.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3548
- const hasPrimary = await fs20.pathExists(primary);
3549
- const hasLegacy = await fs20.pathExists(legacy);
3748
+ const primary = path28.join(root, DECISIONS_TRIGGERS.decisionsDir);
3749
+ const legacy = path28.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3750
+ const hasPrimary = await fs24.pathExists(primary);
3751
+ const hasLegacy = await fs24.pathExists(legacy);
3550
3752
  if (!hasPrimary && hasLegacy) {
3551
3753
  findings.push({
3552
3754
  level: "advisory",
@@ -3559,8 +3761,8 @@ async function auditDecisionsLayout(root) {
3559
3761
  if (!hasPrimary) return findings;
3560
3762
  const decisionsDir = await resolveDecisionsDir(root);
3561
3763
  const rel = relativeDecisionPath(decisionsDir, root);
3562
- const readmePath = path23.join(decisionsDir, "README.md");
3563
- if (!await fs20.pathExists(readmePath)) {
3764
+ const readmePath = path28.join(decisionsDir, "README.md");
3765
+ if (!await fs24.pathExists(readmePath)) {
3564
3766
  findings.push({
3565
3767
  level: "advisory",
3566
3768
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -3570,7 +3772,7 @@ async function auditDecisionsLayout(root) {
3570
3772
  return findings;
3571
3773
  }
3572
3774
  const readme = await readText(readmePath);
3573
- const entries = (await fs20.readdir(decisionsDir)).filter(
3775
+ const entries = (await fs24.readdir(decisionsDir)).filter(
3574
3776
  (f) => f.endsWith(".md") && f !== "README.md"
3575
3777
  );
3576
3778
  for (const file of entries) {
@@ -3584,7 +3786,7 @@ async function auditDecisionsLayout(root) {
3584
3786
  });
3585
3787
  }
3586
3788
  }
3587
- const claude = await readText(path23.join(root, "CLAUDE.md"));
3789
+ const claude = await readText(path28.join(root, "CLAUDE.md"));
3588
3790
  if (claude && !claude.includes(`@${rel}/README.md`)) {
3589
3791
  findings.push({
3590
3792
  level: "advisory",
@@ -3614,8 +3816,12 @@ async function auditDecisionsLayout(root) {
3614
3816
  return findings;
3615
3817
  }
3616
3818
 
3819
+ // src/scanner/read-context.ts
3820
+ import path33 from "node:path";
3821
+ import fs26 from "fs-extra";
3822
+
3617
3823
  // src/scanner/scan-project.ts
3618
- import path27 from "node:path";
3824
+ import path32 from "node:path";
3619
3825
 
3620
3826
  // src/utils/versions.ts
3621
3827
  import semver from "semver";
@@ -3637,8 +3843,8 @@ function compareVersions(a, b) {
3637
3843
  }
3638
3844
 
3639
3845
  // src/scanner/detect-package-manager.ts
3640
- import path24 from "node:path";
3641
- import fs21 from "fs-extra";
3846
+ import path29 from "node:path";
3847
+ import fs25 from "fs-extra";
3642
3848
  function detectPackageManager(root, packageManagerField) {
3643
3849
  const field = String(packageManagerField ?? "").trim();
3644
3850
  if (field.startsWith("yarn@")) {
@@ -3656,9 +3862,9 @@ function detectPackageManager(root, packageManagerField) {
3656
3862
  if (satisfiesVersion(version, ">=9")) return "npm";
3657
3863
  return "unknown";
3658
3864
  }
3659
- if (fs21.existsSync(path24.join(root, "yarn.lock"))) return "yarn";
3660
- if (fs21.existsSync(path24.join(root, "pnpm-lock.yaml"))) return "pnpm";
3661
- if (fs21.existsSync(path24.join(root, "package-lock.json"))) return "npm";
3865
+ if (fs25.existsSync(path29.join(root, "yarn.lock"))) return "yarn";
3866
+ if (fs25.existsSync(path29.join(root, "pnpm-lock.yaml"))) return "pnpm";
3867
+ if (fs25.existsSync(path29.join(root, "package-lock.json"))) return "npm";
3662
3868
  return "unknown";
3663
3869
  }
3664
3870
 
@@ -3860,7 +4066,7 @@ function runDetection(ctx, rules = STACK_RULES) {
3860
4066
  }
3861
4067
 
3862
4068
  // src/scanner/detection.ts
3863
- import path25 from "node:path";
4069
+ import path30 from "node:path";
3864
4070
  var UNSUPPORTED_MARKERS = {
3865
4071
  "requirements.txt": "python",
3866
4072
  "pyproject.toml": "python",
@@ -3914,14 +4120,14 @@ function finalizeRoles(registryRoles, deps, files) {
3914
4120
  function collectUnsupportedSignals(files) {
3915
4121
  return [
3916
4122
  ...new Set(
3917
- files.map((f) => UNSUPPORTED_MARKERS[path25.basename(f)]).filter((s) => Boolean(s))
4123
+ files.map((f) => UNSUPPORTED_MARKERS[path30.basename(f)]).filter((s) => Boolean(s))
3918
4124
  )
3919
4125
  ].sort();
3920
4126
  }
3921
4127
 
3922
4128
  // src/scanner/render.ts
3923
4129
  import { readFile } from "node:fs/promises";
3924
- import path26 from "node:path";
4130
+ import path31 from "node:path";
3925
4131
  async function buildContentBlob(root, files) {
3926
4132
  const candidates = files.filter(
3927
4133
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -3929,7 +4135,7 @@ async function buildContentBlob(root, files) {
3929
4135
  const slice = candidates.slice(0, 300);
3930
4136
  const parts = await mapWithConcurrency(slice, async (rel) => {
3931
4137
  try {
3932
- return await readFile(path26.join(root, rel), "utf8");
4138
+ return await readFile(path31.join(root, rel), "utf8");
3933
4139
  } catch {
3934
4140
  return "";
3935
4141
  }
@@ -4016,8 +4222,8 @@ var SAFE_FILES = [
4016
4222
  "Gemfile"
4017
4223
  ];
4018
4224
  async function scanProject(root) {
4019
- const pkg = await readJson(path27.join(root, "package.json"));
4020
- const composer = await readJson(path27.join(root, "composer.json"));
4225
+ const pkg = await readJson(path32.join(root, "package.json"));
4226
+ const composer = await readJson(path32.join(root, "composer.json"));
4021
4227
  const files = await listFiles(root, SAFE_FILES);
4022
4228
  const safeFiles = files.filter((f) => !blocked(f));
4023
4229
  const deps = dependencySet(pkg, composer);
@@ -4044,7 +4250,7 @@ async function scanProject(root) {
4044
4250
  const context = {
4045
4251
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4046
4252
  root,
4047
- repoName: String(pkg?.name ?? path27.basename(root)),
4253
+ repoName: String(pkg?.name ?? path32.basename(root)),
4048
4254
  packageManager,
4049
4255
  repoRoles: roles,
4050
4256
  detectedStacks: stacks,
@@ -4061,9 +4267,16 @@ async function scanProject(root) {
4061
4267
  }
4062
4268
 
4063
4269
  // src/scanner/read-context.ts
4270
+ async function isCacheFresh(root, cachePath) {
4271
+ const pkgJsonPath = path33.join(root, "package.json");
4272
+ if (!await fs26.pathExists(pkgJsonPath)) return true;
4273
+ const [cacheStat, pkgStat] = await Promise.all([fs26.stat(cachePath), fs26.stat(pkgJsonPath)]);
4274
+ return cacheStat.mtimeMs >= pkgStat.mtimeMs;
4275
+ }
4064
4276
  async function readContextOrScan(root) {
4065
- const context = await readJson(hausPath(root, "context-map.json"));
4066
- if (context) return context;
4277
+ const cachePath = hausPath(root, "context-map.json");
4278
+ const context = await readJson(cachePath);
4279
+ if (context && await isCacheFresh(root, cachePath)) return context;
4067
4280
  const scan = await scanProject(root);
4068
4281
  return scan;
4069
4282
  }
@@ -4156,7 +4369,7 @@ async function runDoctor(options) {
4156
4369
  } else {
4157
4370
  ok(`- HOOKS OK: ${hooks.message}`);
4158
4371
  }
4159
- const rootClaudeMdPath = path28.join(root, "CLAUDE.md");
4372
+ const rootClaudeMdPath = path34.join(root, "CLAUDE.md");
4160
4373
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
4161
4374
  if (!rootClaudeMdContent) {
4162
4375
  flag(
@@ -4184,7 +4397,7 @@ async function runDoctor(options) {
4184
4397
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
4185
4398
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
4186
4399
  for (const target of importTargets) {
4187
- if (!await fs22.pathExists(hausPath(root, target))) {
4400
+ if (!await fs27.pathExists(hausPath(root, target))) {
4188
4401
  flag(
4189
4402
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
4190
4403
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -4195,7 +4408,7 @@ async function runDoctor(options) {
4195
4408
  }
4196
4409
  }
4197
4410
  const workflowPath = hausPath(root, "WORKFLOW.md");
4198
- const workflowExists = await fs22.pathExists(workflowPath);
4411
+ const workflowExists = await fs27.pathExists(workflowPath);
4199
4412
  if (!workflowExists) {
4200
4413
  flag(
4201
4414
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -4210,43 +4423,36 @@ async function runDoctor(options) {
4210
4423
  } else {
4211
4424
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
4212
4425
  const bodyContent = workflowContent.slice(firstLine.length + 1);
4213
- const onDiskBodyHash = hashText(normaliseLF(bodyContent));
4214
- if (storedHashMatch && onDiskBodyHash !== storedHashMatch[1]) {
4426
+ const cachePath = path34.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4427
+ const bundledPath = path34.join(
4428
+ packageRoot(),
4429
+ "library",
4430
+ "global",
4431
+ "templates",
4432
+ "agentic-workflow-standard.md"
4433
+ );
4434
+ const templatePath = await fs27.pathExists(cachePath) ? cachePath : bundledPath;
4435
+ const templateContent = await readText(templatePath);
4436
+ const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
4437
+ if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
4215
4438
  flag(
4216
4439
  "- .haus-workflow/WORKFLOW.md: modified locally (run `haus apply --write --force` to restore)",
4217
4440
  "The workflow standard file was edited after haus wrote it",
4218
4441
  "haus apply --write --force"
4219
4442
  );
4220
- } else {
4221
- const cachePath = path28.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4222
- const bundledPath = path28.join(
4223
- packageRoot(),
4224
- "library",
4225
- "global",
4226
- "templates",
4227
- "agentic-workflow-standard.md"
4443
+ } else if (verdict.status === "stale") {
4444
+ suggest(
4445
+ "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
4446
+ "The workflow standard is out of date",
4447
+ "haus apply --write"
4228
4448
  );
4229
- const templatePath = await fs22.pathExists(cachePath) ? cachePath : bundledPath;
4230
- const templateContent = await readText(templatePath);
4231
- if (storedHashMatch && templateContent) {
4232
- const currentHash = hashText(normaliseLF(templateContent));
4233
- if (storedHashMatch[1] !== currentHash) {
4234
- suggest(
4235
- "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
4236
- "The workflow standard is out of date",
4237
- "haus apply --write"
4238
- );
4239
- } else {
4240
- ok("- .haus-workflow/WORKFLOW.md: OK");
4241
- }
4242
- } else {
4243
- ok("- .haus-workflow/WORKFLOW.md: OK");
4244
- }
4449
+ } else {
4450
+ ok("- .haus-workflow/WORKFLOW.md: OK");
4245
4451
  }
4246
4452
  }
4247
4453
  }
4248
4454
  const workflowConfigPath = hausPath(root, "workflow-config.md");
4249
- const workflowConfigExists = await fs22.pathExists(workflowConfigPath);
4455
+ const workflowConfigExists = await fs27.pathExists(workflowConfigPath);
4250
4456
  if (!workflowConfigExists) {
4251
4457
  flag(
4252
4458
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -4254,7 +4460,7 @@ async function runDoctor(options) {
4254
4460
  "haus apply --write"
4255
4461
  );
4256
4462
  } else {
4257
- const cfg = await fs22.readFile(workflowConfigPath, "utf8");
4463
+ const cfg = await fs27.readFile(workflowConfigPath, "utf8");
4258
4464
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
4259
4465
  if (unfilled > 0) {
4260
4466
  suggest(
@@ -4267,7 +4473,7 @@ async function runDoctor(options) {
4267
4473
  }
4268
4474
  }
4269
4475
  if (workflowExists) {
4270
- const prettierIgnore = await readText(path28.join(root, ".prettierignore")) ?? "";
4476
+ const prettierIgnore = await readText(path34.join(root, ".prettierignore")) ?? "";
4271
4477
  if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
4272
4478
  flag(
4273
4479
  "- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
@@ -4301,7 +4507,21 @@ async function runDoctor(options) {
4301
4507
  ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
4302
4508
  }
4303
4509
  }
4304
- const pkgJson = await readJson(path28.join(packageRoot(), "package.json"));
4510
+ if (recommendation) {
4511
+ const lock = await readJson(hausPath(root, "haus.lock.json"));
4512
+ const recommendedIds = new Set(
4513
+ recommendation.recommended.map((r) => r.id)
4514
+ );
4515
+ const orphaned = (lock ?? []).map((entry) => entry.id).filter((id) => typeof id === "string" && !recommendedIds.has(id));
4516
+ if (orphaned.length > 0) {
4517
+ suggest(
4518
+ `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
4519
+ `${orphaned.length} installed item(s) are no longer recommended for this project`,
4520
+ "review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
4521
+ );
4522
+ }
4523
+ }
4524
+ const pkgJson = await readJson(path34.join(packageRoot(), "package.json"));
4305
4525
  const currentVersion = pkgJson?.version ?? "0.0.0";
4306
4526
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4307
4527
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4517,7 +4737,7 @@ function matchesDenyToken(command, denyPhrase) {
4517
4737
  if (denyPhrase === "sudo") {
4518
4738
  return /(?:^|[|;&]\s*)sudo\b/i.test(command);
4519
4739
  }
4520
- const normalizedCommand = command.toLowerCase().replace(/\s+/g, " ").trim();
4740
+ const normalizedCommand = command.replace(/["'`]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
4521
4741
  return normalizedCommand.includes(denyPhrase.toLowerCase());
4522
4742
  }
4523
4743
  function guardBash(command) {
@@ -4559,41 +4779,29 @@ async function runGuard(kind, _options) {
4559
4779
  }
4560
4780
 
4561
4781
  // src/commands/init.ts
4562
- import path29 from "node:path";
4563
- import fs23 from "fs-extra";
4564
-
4565
- // src/utils/prompts.ts
4566
- import { stdin as input, stdout as output } from "node:process";
4567
- import readline from "node:readline/promises";
4568
- async function ask(question) {
4569
- const rl = readline.createInterface({ input, output });
4570
- try {
4571
- const answer = await rl.question(`${question}
4572
- > `);
4573
- return answer.trim();
4574
- } finally {
4575
- rl.close();
4576
- }
4577
- }
4578
- async function confirm(question) {
4579
- const answer = (await ask(`${question} [y/N]`)).toLowerCase();
4580
- return answer === "y" || answer === "yes";
4581
- }
4782
+ import path35 from "node:path";
4783
+ import fs28 from "fs-extra";
4582
4784
 
4583
4785
  // src/recommender/git-signal.ts
4584
4786
  init_exec();
4585
- async function readChangedFiles(root) {
4586
- if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
4787
+ async function runGitLines(root, args) {
4587
4788
  try {
4588
- const result = await runGit(["diff", "--name-only"], { cwd: root, timeout: 3e3 });
4589
- if (result.exitCode !== 0) {
4590
- return [];
4591
- }
4592
- return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean).sort();
4789
+ const result = await runGit(args, { cwd: root, timeout: 3e3 });
4790
+ if (result.exitCode !== 0) return [];
4791
+ return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean);
4593
4792
  } catch {
4594
4793
  return [];
4595
4794
  }
4596
4795
  }
4796
+ async function readChangedFiles(root) {
4797
+ if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
4798
+ const [unstaged, staged, untracked] = await Promise.all([
4799
+ runGitLines(root, ["diff", "--name-only"]),
4800
+ runGitLines(root, ["diff", "--cached", "--name-only"]),
4801
+ runGitLines(root, ["ls-files", "--others", "--exclude-standard"])
4802
+ ]);
4803
+ return [.../* @__PURE__ */ new Set([...unstaged, ...staged, ...untracked])].sort();
4804
+ }
4597
4805
 
4598
4806
  // src/recommender/policies.ts
4599
4807
  var UNSUPPORTED = FORBIDDEN_TAGS;
@@ -4668,6 +4876,7 @@ async function recommend(root, context, opts = {}) {
4668
4876
  const recommended = [];
4669
4877
  const skipped = [];
4670
4878
  const sourceTrust = new Map(sources.items.map((x) => [x.source, x.status]));
4879
+ const formerIds = new Set(items.flatMap((item) => normalizeFormerIds(item.formerIds)));
4671
4880
  const changedFiles = await readChangedFiles(root);
4672
4881
  const skip = (id, code, message, signal) => {
4673
4882
  skipped.push({ id, reason: message, skipReasons: [{ code, message, signal }] });
@@ -4675,6 +4884,10 @@ async function recommend(root, context, opts = {}) {
4675
4884
  const roleSignal = (name) => scannerRoles.has(name.toLowerCase()) ? `role:${name}` : `deep:role:${name}`;
4676
4885
  const stackSignal = (name) => scannerStacks.has(name.toLowerCase()) ? `tag:${name}` : `deep:tag:${name}`;
4677
4886
  for (const item of items) {
4887
+ if (formerIds.has(item.id)) {
4888
+ skip(item.id, "former-id", "Catalog id was renamed and cannot be newly recommended");
4889
+ continue;
4890
+ }
4678
4891
  const normSource = typeof item.source === "string" ? item.source.trim() : "";
4679
4892
  if (!normSource) {
4680
4893
  skip(item.id, "invalid-source", "Item source is missing or empty");
@@ -4916,6 +5129,7 @@ function applyCoInstallSuppression(recommended, skipped) {
4916
5129
  }
4917
5130
  }
4918
5131
  var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
5132
+ "former-id",
4919
5133
  "unsupported-policy",
4920
5134
  "deprecated",
4921
5135
  "curated-not-approved",
@@ -4990,7 +5204,7 @@ function buildOptInEligible(items, skipped) {
4990
5204
  return eligible;
4991
5205
  }
4992
5206
 
4993
- // src/commands/setup-core.ts
5207
+ // src/claude/setup-core.ts
4994
5208
  async function runSetupCore(root, opts) {
4995
5209
  const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
4996
5210
  const say = quiet ? () => {
@@ -5069,6 +5283,24 @@ async function runSetupCore(root, opts) {
5069
5283
  return { ...baseResult, hooksOk: hooksOkAfter, written: files };
5070
5284
  }
5071
5285
 
5286
+ // src/utils/prompts.ts
5287
+ import { stdin as input, stdout as output } from "node:process";
5288
+ import readline from "node:readline/promises";
5289
+ async function ask(question) {
5290
+ const rl = readline.createInterface({ input, output });
5291
+ try {
5292
+ const answer = await rl.question(`${question}
5293
+ > `);
5294
+ return answer.trim();
5295
+ } finally {
5296
+ rl.close();
5297
+ }
5298
+ }
5299
+ async function confirm(question) {
5300
+ const answer = (await ask(`${question} [y/N]`)).toLowerCase();
5301
+ return answer === "y" || answer === "yes";
5302
+ }
5303
+
5072
5304
  // src/commands/setup-project.ts
5073
5305
  async function runSetupProject(options) {
5074
5306
  const root = process.cwd();
@@ -5083,8 +5315,8 @@ async function runSetupProject(options) {
5083
5315
  // src/commands/init.ts
5084
5316
  async function runInit(options) {
5085
5317
  const root = process.cwd();
5086
- const hausDir = path29.join(root, ".haus-workflow");
5087
- const alreadyInit = await fs23.pathExists(hausDir);
5318
+ const hausDir = path35.join(root, ".haus-workflow");
5319
+ const alreadyInit = await fs28.pathExists(hausDir);
5088
5320
  if (alreadyInit) {
5089
5321
  log("Haus AI already initialized in this project.");
5090
5322
  log("Run `haus setup-project` to reconfigure.");
@@ -5096,8 +5328,8 @@ async function runInit(options) {
5096
5328
 
5097
5329
  // src/install/apply.ts
5098
5330
  import crypto2 from "node:crypto";
5099
- import path30 from "node:path";
5100
- import fs24 from "fs-extra";
5331
+ import path36 from "node:path";
5332
+ import fs29 from "fs-extra";
5101
5333
 
5102
5334
  // src/install/header.ts
5103
5335
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5183,57 +5415,57 @@ function hashContent(content2) {
5183
5415
  }
5184
5416
  function sourceVersion() {
5185
5417
  try {
5186
- const pkgPath = path30.join(packageRoot(), "package.json");
5187
- const pkg = JSON.parse(fs24.readFileSync(pkgPath, "utf8"));
5418
+ const pkgPath = path36.join(packageRoot(), "package.json");
5419
+ const pkg = JSON.parse(fs29.readFileSync(pkgPath, "utf8"));
5188
5420
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5189
5421
  } catch {
5190
5422
  return "haus@0.0.0";
5191
5423
  }
5192
5424
  }
5193
5425
  function globalSrcDir() {
5194
- return path30.join(packageRoot(), "library", "global");
5426
+ return path36.join(packageRoot(), "library", "global");
5195
5427
  }
5196
5428
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5197
- const skillDir = path30.join(skillsDir, skillName);
5429
+ const skillDir = path36.join(skillsDir, skillName);
5198
5430
  const entries = [];
5199
- for (const rel of fs24.readdirSync(skillDir, { recursive: true })) {
5431
+ for (const rel of fs29.readdirSync(skillDir, { recursive: true })) {
5200
5432
  if (rel === "SKILL.md") continue;
5201
- const abs = path30.join(skillDir, rel);
5202
- if (!fs24.statSync(abs).isFile()) continue;
5203
- const posixRel = rel.split(path30.sep).join("/");
5433
+ const abs = path36.join(skillDir, rel);
5434
+ if (!fs29.statSync(abs).isFile()) continue;
5435
+ const posixRel = rel.split(path36.sep).join("/");
5204
5436
  entries.push({
5205
5437
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5206
- srcRelPath: path30.join("library", "global", "skills", skillName, rel),
5207
- destPath: path30.join(claudeDir, "skills", skillName, rel)
5438
+ srcRelPath: path36.join("library", "global", "skills", skillName, rel),
5439
+ destPath: path36.join(claudeDir, "skills", skillName, rel)
5208
5440
  });
5209
5441
  }
5210
5442
  return entries;
5211
5443
  }
5212
5444
  function collectSourceFiles(srcDir, claudeDir) {
5213
5445
  const entries = [];
5214
- const skillsDir = path30.join(srcDir, "skills");
5215
- if (fs24.pathExistsSync(skillsDir)) {
5216
- for (const skillName of fs24.readdirSync(skillsDir)) {
5217
- const skillFile = path30.join(skillsDir, skillName, "SKILL.md");
5218
- if (fs24.pathExistsSync(skillFile)) {
5446
+ const skillsDir = path36.join(srcDir, "skills");
5447
+ if (fs29.pathExistsSync(skillsDir)) {
5448
+ for (const skillName of fs29.readdirSync(skillsDir)) {
5449
+ const skillFile = path36.join(skillsDir, skillName, "SKILL.md");
5450
+ if (fs29.pathExistsSync(skillFile)) {
5219
5451
  entries.push({
5220
5452
  stableId: `skill.${skillName}`,
5221
- srcRelPath: path30.join("library", "global", "skills", skillName, "SKILL.md"),
5222
- destPath: path30.join(claudeDir, "skills", skillName, "SKILL.md")
5453
+ srcRelPath: path36.join("library", "global", "skills", skillName, "SKILL.md"),
5454
+ destPath: path36.join(claudeDir, "skills", skillName, "SKILL.md")
5223
5455
  });
5224
5456
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5225
5457
  }
5226
5458
  }
5227
5459
  }
5228
- const commandsDir = path30.join(srcDir, "commands");
5229
- if (fs24.pathExistsSync(commandsDir)) {
5230
- for (const fileName of fs24.readdirSync(commandsDir)) {
5460
+ const commandsDir = path36.join(srcDir, "commands");
5461
+ if (fs29.pathExistsSync(commandsDir)) {
5462
+ for (const fileName of fs29.readdirSync(commandsDir)) {
5231
5463
  if (!fileName.endsWith(".md")) continue;
5232
5464
  const commandName = fileName.slice(0, -".md".length);
5233
5465
  entries.push({
5234
5466
  stableId: `command.${commandName}`,
5235
- srcRelPath: path30.join("library", "global", "commands", fileName),
5236
- destPath: path30.join(claudeDir, "commands", fileName)
5467
+ srcRelPath: path36.join("library", "global", "commands", fileName),
5468
+ destPath: path36.join(claudeDir, "commands", fileName)
5237
5469
  });
5238
5470
  }
5239
5471
  }
@@ -5259,7 +5491,7 @@ async function applyInstall(options = {}) {
5259
5491
  };
5260
5492
  const manifestFiles = [];
5261
5493
  for (const entry of sourceFiles) {
5262
- const srcPath = path30.join(packageRoot(), entry.srcRelPath);
5494
+ const srcPath = path36.join(packageRoot(), entry.srcRelPath);
5263
5495
  const rawContent = await readText(srcPath);
5264
5496
  if (rawContent === void 0) {
5265
5497
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5279,7 +5511,7 @@ async function applyInstall(options = {}) {
5279
5511
  }
5280
5512
  continue;
5281
5513
  }
5282
- const destExists = fs24.pathExistsSync(entry.destPath);
5514
+ const destExists = fs29.pathExistsSync(entry.destPath);
5283
5515
  if (destExists) {
5284
5516
  const currentContent = await readText(entry.destPath);
5285
5517
  if (currentContent !== void 0) {
@@ -5319,7 +5551,7 @@ async function applyInstall(options = {}) {
5319
5551
  }
5320
5552
  let mergedSettings;
5321
5553
  if (!check) {
5322
- const fragmentPath = path30.join(srcDir, "settings-fragments", "hooks.json");
5554
+ const fragmentPath = path36.join(srcDir, "settings-fragments", "hooks.json");
5323
5555
  const fragments = await loadHooksFragment(fragmentPath);
5324
5556
  const settings = await readSettings();
5325
5557
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5333,13 +5565,13 @@ async function applyInstall(options = {}) {
5333
5565
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5334
5566
  for (const entry of existingManifest.files) {
5335
5567
  if (currentDestPaths.has(entry.destPath)) continue;
5336
- if (!fs24.pathExistsSync(entry.destPath)) continue;
5568
+ if (!fs29.pathExistsSync(entry.destPath)) continue;
5337
5569
  const content2 = await readText(entry.destPath);
5338
5570
  if (!content2) continue;
5339
5571
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5340
5572
  const currentHash = hashContent(content2);
5341
5573
  if (hasHeader && currentHash === entry.hash) {
5342
- if (!dryRun) await fs24.remove(entry.destPath);
5574
+ if (!dryRun) await fs29.remove(entry.destPath);
5343
5575
  result.deleted.push(entry.destPath);
5344
5576
  } else {
5345
5577
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -5508,8 +5740,8 @@ async function runRefresh() {
5508
5740
  }
5509
5741
 
5510
5742
  // src/install/scaffold.ts
5511
- import path31 from "node:path";
5512
- import fs25 from "fs-extra";
5743
+ import path37 from "node:path";
5744
+ import fs30 from "fs-extra";
5513
5745
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5514
5746
  const scaffolded = [];
5515
5747
  const skipped = [];
@@ -5522,27 +5754,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5522
5754
  skipped.push(name);
5523
5755
  if (outcome === "skipped-exists") skippedExisting.push(name);
5524
5756
  };
5525
- const realRoot = await fs25.realpath(catalogRoot).catch(() => null);
5757
+ const realRoot = await fs30.realpath(catalogRoot).catch(() => null);
5526
5758
  for (const item of items) {
5527
5759
  if (item.type !== "config") continue;
5528
- const sourcePath = path31.resolve(catalogRoot, item.path);
5529
- const rel = path31.relative(catalogRoot, sourcePath);
5530
- const escapes = rel === "" || path31.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
5760
+ const sourcePath = path37.resolve(catalogRoot, item.path);
5761
+ const rel = path37.relative(catalogRoot, sourcePath);
5762
+ const escapes = rel === "" || path37.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
5531
5763
  if (escapes) {
5532
5764
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
5533
5765
  continue;
5534
5766
  }
5535
- const realSource = await fs25.realpath(sourcePath).catch(() => null);
5767
+ const realSource = await fs30.realpath(sourcePath).catch(() => null);
5536
5768
  if (!realRoot || !realSource) {
5537
5769
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
5538
5770
  continue;
5539
5771
  }
5540
- const realRel = path31.relative(realRoot, realSource);
5541
- if (realRel !== "" && (path31.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
5772
+ const realRel = path37.relative(realRoot, realSource);
5773
+ if (realRel !== "" && (path37.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
5542
5774
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
5543
5775
  continue;
5544
5776
  }
5545
- const stat = await fs25.lstat(sourcePath).catch(() => null);
5777
+ const stat = await fs30.lstat(sourcePath).catch(() => null);
5546
5778
  if (!stat) {
5547
5779
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
5548
5780
  continue;
@@ -5552,14 +5784,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5552
5784
  continue;
5553
5785
  }
5554
5786
  if (stat.isFile()) {
5555
- const filename = path31.basename(sourcePath);
5556
- const dest = path31.join(projectRoot, filename);
5787
+ const filename = path37.basename(sourcePath);
5788
+ const dest = path37.join(projectRoot, filename);
5557
5789
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
5558
5790
  } else if (stat.isDirectory()) {
5559
- const entries = await fs25.readdir(sourcePath);
5791
+ const entries = await fs30.readdir(sourcePath);
5560
5792
  for (const entry of entries) {
5561
- const src = path31.join(sourcePath, entry);
5562
- const dest = path31.join(projectRoot, entry);
5793
+ const src = path37.join(sourcePath, entry);
5794
+ const dest = path37.join(projectRoot, entry);
5563
5795
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
5564
5796
  }
5565
5797
  }
@@ -5567,42 +5799,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5567
5799
  return { scaffolded, skipped, skippedExisting };
5568
5800
  }
5569
5801
  async function scaffoldFile(src, dest, itemId, projectRoot, opts) {
5570
- const destDisplay = path31.relative(projectRoot, dest) || path31.basename(dest);
5571
- const srcStat = await fs25.lstat(src).catch(() => null);
5802
+ const destDisplay = path37.relative(projectRoot, dest) || path37.basename(dest);
5803
+ const srcStat = await fs30.lstat(src).catch(() => null);
5572
5804
  if (!srcStat) {
5573
- warn(`Skipping ${path31.basename(src)}: source not found`);
5805
+ warn(`Skipping ${path37.basename(src)}: source not found`);
5574
5806
  return "skipped-refused";
5575
5807
  }
5576
5808
  if (srcStat.isSymbolicLink()) {
5577
- warn(`Skipping ${path31.basename(src)}: source is a symlink`);
5809
+ warn(`Skipping ${path37.basename(src)}: source is a symlink`);
5578
5810
  return "skipped-refused";
5579
5811
  }
5580
- const exists = await fs25.pathExists(dest);
5812
+ const exists = await fs30.pathExists(dest);
5581
5813
  if (exists && !opts.force) {
5582
5814
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
5583
5815
  return "skipped-exists";
5584
5816
  }
5585
5817
  if (opts.dryRun) {
5586
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path31.basename(dest)} (${itemId})`);
5818
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path37.basename(dest)} (${itemId})`);
5587
5819
  return "scaffolded";
5588
5820
  }
5589
5821
  if (exists && opts.force) {
5590
- await fs25.remove(dest);
5822
+ await fs30.remove(dest);
5591
5823
  }
5592
- await fs25.ensureDir(path31.dirname(dest));
5593
- await fs25.copy(src, dest, {
5824
+ await fs30.ensureDir(path37.dirname(dest));
5825
+ await fs30.copy(src, dest, {
5594
5826
  overwrite: true,
5595
5827
  dereference: false,
5596
5828
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
5597
5829
  filter: (s) => {
5598
5830
  try {
5599
- return !fs25.lstatSync(s).isSymbolicLink();
5831
+ return !fs30.lstatSync(s).isSymbolicLink();
5600
5832
  } catch {
5601
5833
  return false;
5602
5834
  }
5603
5835
  }
5604
5836
  });
5605
- log(`${exists ? "Overwrote" : "Created"} ${path31.basename(dest)} (${itemId})`);
5837
+ log(`${exists ? "Overwrote" : "Created"} ${path37.basename(dest)} (${itemId})`);
5606
5838
  return "scaffolded";
5607
5839
  }
5608
5840
 
@@ -5659,8 +5891,8 @@ async function runScan(options) {
5659
5891
  }
5660
5892
 
5661
5893
  // src/commands/undo.ts
5662
- import path32 from "node:path";
5663
- import fs26 from "fs-extra";
5894
+ import path38 from "node:path";
5895
+ import fs31 from "fs-extra";
5664
5896
 
5665
5897
  // src/claude/managed-paths.ts
5666
5898
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -5672,67 +5904,85 @@ function coreManagedAbsolutePaths(root) {
5672
5904
  }
5673
5905
 
5674
5906
  // src/commands/undo.ts
5675
- async function collectManagedPaths(root) {
5676
- const paths = new Set(coreManagedAbsolutePaths(root));
5907
+ async function collectCoreManagedPaths(root) {
5908
+ const existing = [];
5909
+ for (const abs of coreManagedAbsolutePaths(root)) {
5910
+ if (await fs31.pathExists(abs)) existing.push(abs);
5911
+ }
5912
+ return existing;
5913
+ }
5914
+ async function collectLockTrackedPaths(root) {
5677
5915
  const lock = await readJson(hausPath(root, "haus.lock.json"));
5916
+ const removable = [];
5917
+ const preserved = [];
5678
5918
  for (const row of lock ?? []) {
5679
- for (const rel of row.paths ?? []) {
5680
- paths.add(path32.resolve(root, rel));
5919
+ const relPaths = row.paths ?? [];
5920
+ const existingRel = [];
5921
+ for (const rel of relPaths) {
5922
+ if (await fs31.pathExists(path38.resolve(root, rel))) existingRel.push(rel);
5923
+ }
5924
+ if (existingRel.length === 0) continue;
5925
+ if (row.hash === void 0) {
5926
+ removable.push(...existingRel.map((rel) => path38.resolve(root, rel)));
5927
+ continue;
5928
+ }
5929
+ const currentHash = await hashInstalledPaths(root, existingRel);
5930
+ if (currentHash === row.hash) {
5931
+ removable.push(...existingRel.map((rel) => path38.resolve(root, rel)));
5932
+ } else {
5933
+ preserved.push(...existingRel.map((rel) => path38.resolve(root, rel)));
5934
+ log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
5681
5935
  }
5682
5936
  }
5683
- const existing = [];
5684
- for (const abs of paths) {
5685
- if (await fs26.pathExists(abs)) existing.push(abs);
5686
- }
5687
- return existing;
5937
+ return { removable, preserved };
5688
5938
  }
5689
5939
  async function settingsHasHausContent(root) {
5690
5940
  const settingsPath = claudePath(root, "settings.json");
5691
- if (!await fs26.pathExists(settingsPath)) return false;
5941
+ if (!await fs31.pathExists(settingsPath)) return false;
5692
5942
  const settings = await readProjectSettings(root);
5693
5943
  return settings._haus != null;
5694
5944
  }
5695
5945
  async function claudeMdHasHausBlock(root) {
5696
- const filePath = path32.join(root, "CLAUDE.md");
5697
- if (!await fs26.pathExists(filePath)) return false;
5698
- const text = await fs26.readFile(filePath, "utf8");
5946
+ const filePath = path38.join(root, "CLAUDE.md");
5947
+ if (!await fs31.pathExists(filePath)) return false;
5948
+ const text = await fs31.readFile(filePath, "utf8");
5699
5949
  return text.includes(BLOCK_BEGIN);
5700
5950
  }
5701
5951
  async function stripProjectSettings(root) {
5702
5952
  const settingsPath = claudePath(root, "settings.json");
5703
- if (!await fs26.pathExists(settingsPath)) return false;
5953
+ if (!await fs31.pathExists(settingsPath)) return false;
5704
5954
  let settings = await readProjectSettings(root);
5705
5955
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
5706
5956
  const hasContent = Object.keys(settings).length > 0;
5707
5957
  if (hasContent) {
5708
5958
  await writeProjectSettings(root, settings);
5709
- log(`Stripped haus rules from ${path32.relative(root, settingsPath)} (user settings preserved).`);
5959
+ log(`Stripped haus rules from ${path38.relative(root, settingsPath)} (user settings preserved).`);
5710
5960
  return true;
5711
5961
  }
5712
- await fs26.remove(settingsPath);
5713
- log(`Removed ${path32.relative(root, settingsPath)} (no user-owned settings remained).`);
5962
+ await fs31.remove(settingsPath);
5963
+ log(`Removed ${path38.relative(root, settingsPath)} (no user-owned settings remained).`);
5714
5964
  return true;
5715
5965
  }
5716
5966
  async function stripRootClaudeMd(root) {
5717
- const filePath = path32.join(root, "CLAUDE.md");
5718
- if (!await fs26.pathExists(filePath)) return false;
5719
- const prev = await fs26.readFile(filePath, "utf8");
5967
+ const filePath = path38.join(root, "CLAUDE.md");
5968
+ if (!await fs31.pathExists(filePath)) return false;
5969
+ const prev = await fs31.readFile(filePath, "utf8");
5720
5970
  if (!prev.includes(BLOCK_BEGIN)) return false;
5721
5971
  const next = stripHausBlock(prev);
5722
5972
  if (next.length === 0) {
5723
- await fs26.remove(filePath);
5973
+ await fs31.remove(filePath);
5724
5974
  log("Removed CLAUDE.md (only contained haus import block).");
5725
5975
  } else {
5726
- await fs26.writeFile(filePath, next, "utf8");
5976
+ await fs31.writeFile(filePath, next, "utf8");
5727
5977
  log("Removed haus import block from CLAUDE.md (user content preserved).");
5728
5978
  }
5729
5979
  return true;
5730
5980
  }
5731
5981
  async function pruneDirIfEmpty(dir) {
5732
- if (!await fs26.pathExists(dir)) return;
5982
+ if (!await fs31.pathExists(dir)) return;
5733
5983
  try {
5734
- const entries = await fs26.readdir(dir);
5735
- if (entries.length === 0) await fs26.remove(dir);
5984
+ const entries = await fs31.readdir(dir);
5985
+ if (entries.length === 0) await fs31.remove(dir);
5736
5986
  } catch {
5737
5987
  }
5738
5988
  }
@@ -5741,27 +5991,34 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
5741
5991
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5742
5992
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
5743
5993
  for (const abs of managedAbsPaths) {
5744
- if (!await fs26.pathExists(abs)) continue;
5745
- const rel = path32.relative(root, abs);
5746
- const backupPath = path32.join(backupRoot, rel);
5747
- await fs26.ensureDir(path32.dirname(backupPath));
5748
- await fs26.copy(abs, backupPath);
5994
+ if (!await fs31.pathExists(abs)) continue;
5995
+ const rel = path38.relative(root, abs);
5996
+ const backupPath = path38.join(backupRoot, rel);
5997
+ await fs31.ensureDir(path38.dirname(backupPath));
5998
+ await fs31.copy(abs, backupPath);
5749
5999
  }
5750
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path32.relative(root, backupRoot)}.`);
6000
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path38.relative(root, backupRoot)}.`);
5751
6001
  }
5752
6002
  async function runUndo(options) {
5753
- const root = process.cwd();
5754
- const managed = await collectManagedPaths(root);
6003
+ const root = options.root ?? process.cwd();
6004
+ const coreManaged = await collectCoreManagedPaths(root);
6005
+ const { removable: lockRemovable, preserved: lockPreserved } = await collectLockTrackedPaths(root);
6006
+ const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockRemovable])];
5755
6007
  const stripSettings = await settingsHasHausContent(root);
5756
6008
  const stripClaudeMd = await claudeMdHasHausBlock(root);
5757
6009
  if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
5758
6010
  log("Nothing to remove: no haus-managed files found in this directory.");
5759
6011
  return;
5760
6012
  }
5761
- const relTargets = managed.map((p) => path32.relative(root, p));
6013
+ const relTargets = managed.map((p) => path38.relative(root, p));
5762
6014
  const summaryParts = [...relTargets];
5763
6015
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
5764
6016
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
6017
+ if (lockPreserved.length > 0) {
6018
+ summaryParts.push(
6019
+ `(preserving ${lockPreserved.length} locally-modified catalog item file(s) \u2014 not shown above)`
6020
+ );
6021
+ }
5765
6022
  if (!options.yes) {
5766
6023
  const ok = await confirm(
5767
6024
  `Remove haus-managed files?
@@ -5775,9 +6032,9 @@ User-owned .claude/ files will be preserved.`
5775
6032
  }
5776
6033
  await backupManagedFilesBeforeUndo(root, managed);
5777
6034
  for (const abs of managed) {
5778
- if (!await fs26.pathExists(abs)) continue;
5779
- await fs26.remove(abs);
5780
- log(`Removed ${path32.relative(root, abs)}`);
6035
+ if (!await fs31.pathExists(abs)) continue;
6036
+ await fs31.remove(abs);
6037
+ log(`Removed ${path38.relative(root, abs)}`);
5781
6038
  }
5782
6039
  if (stripSettings) await stripProjectSettings(root);
5783
6040
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -5788,8 +6045,8 @@ User-owned .claude/ files will be preserved.`
5788
6045
 
5789
6046
  // src/install/uninstall.ts
5790
6047
  import crypto3 from "node:crypto";
5791
- import path33 from "node:path";
5792
- import fs27 from "fs-extra";
6048
+ import path39 from "node:path";
6049
+ import fs32 from "fs-extra";
5793
6050
  async function runUninstall(options = {}) {
5794
6051
  const { force = false } = options;
5795
6052
  const manifest = await readManifest();
@@ -5799,7 +6056,7 @@ async function runUninstall(options = {}) {
5799
6056
  return result;
5800
6057
  }
5801
6058
  for (const entry of manifest.files) {
5802
- const exists = fs27.pathExistsSync(entry.destPath);
6059
+ const exists = fs32.pathExistsSync(entry.destPath);
5803
6060
  if (!exists) continue;
5804
6061
  const content2 = await readText(entry.destPath);
5805
6062
  if (content2 === void 0) continue;
@@ -5817,23 +6074,23 @@ async function runUninstall(options = {}) {
5817
6074
  result.skipped.push(entry.destPath);
5818
6075
  continue;
5819
6076
  }
5820
- await fs27.remove(entry.destPath);
5821
- await pruneEmptyDir(path33.dirname(entry.destPath));
6077
+ await fs32.remove(entry.destPath);
6078
+ await pruneEmptyDir(path39.dirname(entry.destPath));
5822
6079
  result.deleted.push(entry.destPath);
5823
6080
  }
5824
6081
  const settings = await readSettings();
5825
6082
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
5826
6083
  await writeSettings(stripped);
5827
6084
  result.hooksStripped = true;
5828
- const hausDir = path33.join(globalClaudeDir(), "haus");
6085
+ const hausDir = path39.join(globalClaudeDir(), "haus");
5829
6086
  const manifestPath2 = hausManifestPath();
5830
- if (fs27.pathExistsSync(manifestPath2)) {
5831
- await fs27.remove(manifestPath2);
6087
+ if (fs32.pathExistsSync(manifestPath2)) {
6088
+ await fs32.remove(manifestPath2);
5832
6089
  }
5833
- if (fs27.pathExistsSync(hausDir)) {
6090
+ if (fs32.pathExistsSync(hausDir)) {
5834
6091
  try {
5835
- const remaining = await fs27.readdir(hausDir);
5836
- if (remaining.length === 0) await fs27.remove(hausDir);
6092
+ const remaining = await fs32.readdir(hausDir);
6093
+ if (remaining.length === 0) await fs32.remove(hausDir);
5837
6094
  } catch {
5838
6095
  }
5839
6096
  }
@@ -5866,12 +6123,12 @@ async function runUninstallCommand(options) {
5866
6123
  }
5867
6124
 
5868
6125
  // src/commands/update.ts
5869
- import path35 from "node:path";
6126
+ import path41 from "node:path";
5870
6127
 
5871
6128
  // src/claude/refresh-project.ts
5872
- import fs28 from "fs-extra";
6129
+ import fs33 from "fs-extra";
5873
6130
  async function isHausProject(root) {
5874
- if (await fs28.pathExists(hausPath(root, "recommendation.json"))) return true;
6131
+ if (await fs33.pathExists(hausPath(root, "recommendation.json"))) return true;
5875
6132
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
5876
6133
  if (result.status === "ok") return result.value._haus != null;
5877
6134
  return false;
@@ -5906,7 +6163,7 @@ function summarizeLockDiff(before, after) {
5906
6163
 
5907
6164
  // src/update/lockfile.ts
5908
6165
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
5909
- import path34 from "node:path";
6166
+ import path40 from "node:path";
5910
6167
  async function readLockSummary(root) {
5911
6168
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
5912
6169
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -5917,7 +6174,7 @@ async function checkLock(root) {
5917
6174
  const hasValidVersions = lock.every(
5918
6175
  (item) => !item.version || normalizeVersion(item.version) !== null
5919
6176
  );
5920
- const catalogRef = lock[0]?.catalogRef ?? null;
6177
+ const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
5921
6178
  const drift = [];
5922
6179
  for (const item of lock) {
5923
6180
  if (!item.hash) continue;
@@ -5942,7 +6199,7 @@ async function applyLock(root) {
5942
6199
  try {
5943
6200
  const backupDir = hausPath(root, "backups");
5944
6201
  await mkdir(backupDir, { recursive: true });
5945
- await copyFile(lockPath, path34.join(backupDir, `haus.lock.${Date.now()}.json`));
6202
+ await copyFile(lockPath, path40.join(backupDir, `haus.lock.${Date.now()}.json`));
5946
6203
  } catch {
5947
6204
  }
5948
6205
  const enriched = await Promise.all(
@@ -5964,7 +6221,7 @@ function diffLock(before, after) {
5964
6221
  }
5965
6222
  async function hasLocalOverrides(root) {
5966
6223
  try {
5967
- await readFile2(path34.join(root, ".claude", "settings.json"), "utf8");
6224
+ await readFile2(path40.join(root, ".claude", "settings.json"), "utf8");
5968
6225
  return true;
5969
6226
  } catch {
5970
6227
  return false;
@@ -5980,23 +6237,28 @@ async function runUpdate(options) {
5980
6237
  return;
5981
6238
  }
5982
6239
  if (options.check) {
5983
- const pkgJson2 = await readJson(path35.join(packageRoot(), "package.json"));
6240
+ const pkgJson2 = await readJson(path41.join(packageRoot(), "package.json"));
5984
6241
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
5985
- const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
5986
- checkLock(root),
6242
+ const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
6243
+ options.fast ? fastLockStatus(root) : checkLock(root),
5987
6244
  fetchNpmVersionStatus(currentVersion2),
5988
6245
  fetchLatestCatalogTag(),
5989
- detectGlobalInstallDrift()
6246
+ options.fast ? Promise.resolve(null) : detectGlobalInstallDrift(),
6247
+ loadCatalog(root),
6248
+ readJson(hausPath(root, "haus.lock.json"))
5990
6249
  ]);
5991
- const installedRef = status.catalogRef ?? "main";
5992
- const catalogRefBehind = latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
6250
+ const formerIdMigrations = findFormerIdMigrations(lockItems ?? [], catalogItems);
6251
+ const installedRef = options.fast ? status.catalogRef : status.catalogRef ?? "main";
6252
+ const catalogRefBehind = installedRef !== null && latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
5993
6253
  log(
5994
6254
  JSON.stringify(
5995
6255
  {
5996
6256
  ...status,
6257
+ checkMode: options.fast ? "fast" : "full",
5997
6258
  installedCatalogRef: installedRef,
5998
6259
  latestCatalogTag,
5999
6260
  catalogRefBehind,
6261
+ formerIdMigrations,
6000
6262
  globalInstallDrift,
6001
6263
  localOverrides: await hasLocalOverrides(root),
6002
6264
  summary: diffGeneratedFiles(),
@@ -6006,10 +6268,11 @@ async function runUpdate(options) {
6006
6268
  2
6007
6269
  )
6008
6270
  );
6009
- if (status.count > 0 && !status.ok) process.exitCode = 1;
6271
+ const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
6272
+ if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
6010
6273
  return;
6011
6274
  }
6012
- const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
6275
+ const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6013
6276
  const currentVersion = pkgJson?.version ?? "0.0.0";
6014
6277
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
6015
6278
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -6048,7 +6311,7 @@ async function runFromHookCheck(root) {
6048
6311
  try {
6049
6312
  const summary = await readLockSummary(root);
6050
6313
  if (summary.count === 0) return;
6051
- const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
6314
+ const pkgJson = await readJson(path41.join(packageRoot(), "package.json"));
6052
6315
  const currentVersion = pkgJson?.version ?? "0.0.0";
6053
6316
  const [npmVersion, latestCatalogTag] = await Promise.all([
6054
6317
  fetchNpmVersionStatus(currentVersion),
@@ -6114,19 +6377,18 @@ async function detectGlobalInstallDrift() {
6114
6377
  return null;
6115
6378
  }
6116
6379
  }
6380
+ async function fastLockStatus(root) {
6381
+ const summary = await readLockSummary(root);
6382
+ return { count: summary.count, catalogRef: summary.catalogRef, drift: [], driftCount: 0 };
6383
+ }
6117
6384
 
6118
6385
  // src/commands/validate-catalog.ts
6119
- import path37 from "node:path";
6386
+ import path43 from "node:path";
6120
6387
 
6121
6388
  // src/catalog/validate-core.ts
6122
- import fs29 from "node:fs";
6123
- import path36 from "node:path";
6389
+ import fs34 from "node:fs";
6390
+ import path42 from "node:path";
6124
6391
  var ITEM_SEMVER_RE = /^\d+\.\d+\.\d+$/;
6125
- function isSafeCatalogPath2(itemPath) {
6126
- if (!itemPath || path36.isAbsolute(itemPath) || itemPath.includes("\\")) return false;
6127
- const normalized = path36.normalize(itemPath);
6128
- return !normalized.startsWith("..") && !normalized.includes("/..");
6129
- }
6130
6392
  function auditForbiddenStacks(items) {
6131
6393
  const failures = [];
6132
6394
  for (const item of items) {
@@ -6206,7 +6468,7 @@ function auditManifestStructure(manifestVersion, items) {
6206
6468
  if (item.type === "skill" || item.type === "agent" || item.type === "template" || item.type === "command" || item.type === "config") {
6207
6469
  if (!item.path) {
6208
6470
  failures.push(`${item.id}: missing path`);
6209
- } else if (!isSafeCatalogPath2(item.path)) {
6471
+ } else if (!isSafeCatalogPath(item.path)) {
6210
6472
  failures.push(`${item.id}: path "${item.path}" is not a safe relative path`);
6211
6473
  } else {
6212
6474
  const norm = item.path.replace(/\\/g, "/");
@@ -6235,14 +6497,14 @@ function checkRequiredFrontmatter(text, label) {
6235
6497
  }
6236
6498
  function readForAudit(absPath, label, failures) {
6237
6499
  try {
6238
- return fs29.readFileSync(absPath, "utf8");
6500
+ return fs34.readFileSync(absPath, "utf8");
6239
6501
  } catch (err) {
6240
6502
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
6241
6503
  return null;
6242
6504
  }
6243
6505
  }
6244
6506
  function auditTemplateContent(manifestDir, absPath, itemId) {
6245
- const rel = path36.relative(manifestDir, absPath);
6507
+ const rel = path42.relative(manifestDir, absPath);
6246
6508
  const failures = [];
6247
6509
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
6248
6510
  if (text === null) return failures;
@@ -6263,48 +6525,48 @@ function auditTemplateContent(manifestDir, absPath, itemId) {
6263
6525
  function auditShippedFiles(manifestDir, items) {
6264
6526
  const failures = [];
6265
6527
  for (const item of items) {
6266
- if (!item.path || !isSafeCatalogPath2(item.path)) continue;
6267
- const absPath = path36.join(manifestDir, item.path);
6528
+ if (!item.path || !isSafeCatalogPath(item.path)) continue;
6529
+ const absPath = path42.join(manifestDir, item.path);
6268
6530
  if (item.type === "skill") {
6269
- const skillMd = path36.join(absPath, "SKILL.md");
6270
- if (!fs29.existsSync(skillMd)) {
6271
- failures.push(`${item.id}: missing ${path36.relative(manifestDir, skillMd)}`);
6531
+ const skillMd = path42.join(absPath, "SKILL.md");
6532
+ if (!fs34.existsSync(skillMd)) {
6533
+ failures.push(`${item.id}: missing ${path42.relative(manifestDir, skillMd)}`);
6272
6534
  continue;
6273
6535
  }
6274
6536
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
6275
6537
  if (text === null) continue;
6276
6538
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
6277
6539
  failures.push(
6278
- ...auditForbiddenTagsInText(text, `${item.id}: ${path36.relative(manifestDir, skillMd)}`)
6540
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path42.relative(manifestDir, skillMd)}`)
6279
6541
  );
6280
6542
  } else if (item.type === "agent") {
6281
- if (!fs29.existsSync(absPath)) {
6543
+ if (!fs34.existsSync(absPath)) {
6282
6544
  failures.push(`${item.id}: missing agent file ${item.path}`);
6283
6545
  continue;
6284
6546
  }
6285
- const rel = path36.relative(manifestDir, absPath);
6547
+ const rel = path42.relative(manifestDir, absPath);
6286
6548
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6287
6549
  if (text === null) continue;
6288
6550
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
6289
6551
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
6290
6552
  } else if (item.type === "template") {
6291
- if (!fs29.existsSync(absPath)) {
6553
+ if (!fs34.existsSync(absPath)) {
6292
6554
  failures.push(`${item.id}: missing template file ${item.path}`);
6293
6555
  continue;
6294
6556
  }
6295
6557
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
6296
6558
  } else if (item.type === "command") {
6297
- if (!fs29.existsSync(absPath)) {
6559
+ if (!fs34.existsSync(absPath)) {
6298
6560
  failures.push(`${item.id}: missing command file ${item.path}`);
6299
6561
  continue;
6300
6562
  }
6301
- const rel = path36.relative(manifestDir, absPath);
6563
+ const rel = path42.relative(manifestDir, absPath);
6302
6564
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6303
6565
  if (text === null) continue;
6304
6566
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
6305
6567
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
6306
6568
  } else if (item.type === "config") {
6307
- if (!fs29.existsSync(absPath)) {
6569
+ if (!fs34.existsSync(absPath)) {
6308
6570
  failures.push(`${item.id}: missing config path ${item.path}`);
6309
6571
  }
6310
6572
  }
@@ -6320,7 +6582,7 @@ var DIR_ITEM_TYPE = {
6320
6582
  function walkMd(dir, fn, failures) {
6321
6583
  let entries;
6322
6584
  try {
6323
- entries = fs29.readdirSync(dir, { withFileTypes: true });
6585
+ entries = fs34.readdirSync(dir, { withFileTypes: true });
6324
6586
  } catch (err) {
6325
6587
  failures.push(
6326
6588
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -6328,7 +6590,7 @@ function walkMd(dir, fn, failures) {
6328
6590
  return;
6329
6591
  }
6330
6592
  for (const entry of entries) {
6331
- const full = path36.join(dir, entry.name);
6593
+ const full = path42.join(dir, entry.name);
6332
6594
  if (entry.isDirectory()) walkMd(full, fn, failures);
6333
6595
  else if (entry.name.endsWith(".md")) fn(full);
6334
6596
  }
@@ -6337,13 +6599,13 @@ function auditMarkdownContent(manifestDir, items) {
6337
6599
  const failures = [];
6338
6600
  const pathSourceMap = buildItemPathSourceMap(items);
6339
6601
  for (const dir of ["skills", "agents", "templates", "commands"]) {
6340
- const abs = path36.join(manifestDir, dir);
6341
- if (!fs29.existsSync(abs)) continue;
6602
+ const abs = path42.join(manifestDir, dir);
6603
+ if (!fs34.existsSync(abs)) continue;
6342
6604
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
6343
6605
  walkMd(
6344
6606
  abs,
6345
6607
  (file) => {
6346
- const rel = path36.relative(manifestDir, file);
6608
+ const rel = path42.relative(manifestDir, file);
6347
6609
  const text = readForAudit(file, rel, failures);
6348
6610
  if (text === null) return;
6349
6611
  const norm = rel.replace(/\\/g, "/");
@@ -6368,6 +6630,7 @@ function auditMarkdownContent(manifestDir, items) {
6368
6630
  function validateCatalogData(manifestDir, manifestVersion, items) {
6369
6631
  const failures = [
6370
6632
  ...auditManifestStructure(manifestVersion, items),
6633
+ ...validateFormerIds(items),
6371
6634
  ...auditOptInMetadata(items),
6372
6635
  ...auditForbiddenStacks(items),
6373
6636
  ...auditShippedFiles(manifestDir, items),
@@ -6384,8 +6647,8 @@ async function runValidateCatalog(manifestPath2) {
6384
6647
  process.exitCode = 1;
6385
6648
  return;
6386
6649
  }
6387
- const abs = path37.resolve(process.cwd(), manifestPath2);
6388
- const manifestDir = path37.dirname(abs);
6650
+ const abs = path43.resolve(process.cwd(), manifestPath2);
6651
+ const manifestDir = path43.dirname(abs);
6389
6652
  const data = await readJson(abs);
6390
6653
  if (!data?.items) {
6391
6654
  error(`Could not read catalog manifest at ${abs}`);
@@ -6402,8 +6665,8 @@ async function runValidateCatalog(manifestPath2) {
6402
6665
  }
6403
6666
 
6404
6667
  // src/commands/workspace.ts
6405
- import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
6406
- import path44 from "node:path";
6668
+ import { existsSync as existsSync6, statSync as statSync3 } from "node:fs";
6669
+ import path51 from "node:path";
6407
6670
 
6408
6671
  // src/commands/workspace/aggregate.ts
6409
6672
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -6457,7 +6720,7 @@ ${summaries.map(
6457
6720
  }
6458
6721
 
6459
6722
  // src/commands/workspace/config.ts
6460
- import path38 from "node:path";
6723
+ import path44 from "node:path";
6461
6724
  import YAML from "yaml";
6462
6725
  var WORKSPACE_FILE = "haus.workspace.yaml";
6463
6726
  function parseWorkspaceConfig(text) {
@@ -6480,11 +6743,11 @@ function parseWorkspaceConfig(text) {
6480
6743
  };
6481
6744
  }
6482
6745
  async function readWorkspaceConfig(workspaceRoot) {
6483
- return parseWorkspaceConfig(await readText(path38.join(workspaceRoot, WORKSPACE_FILE)));
6746
+ return parseWorkspaceConfig(await readText(path44.join(workspaceRoot, WORKSPACE_FILE)));
6484
6747
  }
6485
6748
 
6486
6749
  // src/commands/workspace/discover.ts
6487
- import path39 from "node:path";
6750
+ import path45 from "node:path";
6488
6751
  import fg4 from "fast-glob";
6489
6752
  import YAML2 from "yaml";
6490
6753
  var DEFAULT_MAX_DEPTH = 3;
@@ -6515,8 +6778,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
6515
6778
  const gitDirs = /* @__PURE__ */ new Set();
6516
6779
  const manifestDirs = /* @__PURE__ */ new Set();
6517
6780
  for (const match of matches) {
6518
- const base = path39.posix.basename(match);
6519
- const dir = path39.posix.dirname(match);
6781
+ const base = path45.posix.basename(match);
6782
+ const dir = path45.posix.dirname(match);
6520
6783
  const owner = dir === "." ? "." : dir;
6521
6784
  if (base === ".git") gitDirs.add(owner);
6522
6785
  else manifestDirs.add(owner);
@@ -6532,9 +6795,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
6532
6795
  }
6533
6796
  repoRoots.sort((a, b) => a.localeCompare(b));
6534
6797
  return mapWithConcurrency(repoRoots, async (relDir) => {
6535
- const absDir = path39.resolve(workspaceRoot, relDir);
6536
- const pkg = await readJson(path39.join(absDir, "package.json"));
6537
- const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path39.basename(relDir === "." ? workspaceRoot : absDir);
6798
+ const absDir = path45.resolve(workspaceRoot, relDir);
6799
+ const pkg = await readJson(path45.join(absDir, "package.json"));
6800
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path45.basename(relDir === "." ? workspaceRoot : absDir);
6538
6801
  let role = "auto";
6539
6802
  try {
6540
6803
  const scan = await scanProject(absDir);
@@ -6577,7 +6840,7 @@ function renderWorkspaceYaml(config) {
6577
6840
  });
6578
6841
  }
6579
6842
  async function runDiscover(workspaceRoot, opts = {}) {
6580
- const yamlPath = path39.join(workspaceRoot, "haus.workspace.yaml");
6843
+ const yamlPath = path45.join(workspaceRoot, "haus.workspace.yaml");
6581
6844
  const existingText = await readText(yamlPath);
6582
6845
  const existing = parseWorkspaceConfig(existingText);
6583
6846
  if (existingText && !existing) {
@@ -6614,18 +6877,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
6614
6877
 
6615
6878
  // src/commands/workspace/doctor.ts
6616
6879
  import { existsSync as existsSync3 } from "node:fs";
6617
- import path41 from "node:path";
6880
+ import path47 from "node:path";
6618
6881
 
6619
6882
  // src/commands/workspace/manifest.ts
6620
6883
  import { readFileSync as readFileSync3 } from "node:fs";
6621
- import path40 from "node:path";
6884
+ import path46 from "node:path";
6622
6885
  var MANIFEST_FILE = "workspace.manifest.json";
6623
6886
  function manifestPath(workspaceRoot) {
6624
6887
  return hausPath(workspaceRoot, MANIFEST_FILE);
6625
6888
  }
6626
6889
  function hausVersion() {
6627
6890
  try {
6628
- const pkg = JSON.parse(readFileSync3(path40.join(packageRoot(), "package.json"), "utf8"));
6891
+ const pkg = JSON.parse(readFileSync3(path46.join(packageRoot(), "package.json"), "utf8"));
6629
6892
  return pkg.version ?? "0.0.0";
6630
6893
  } catch {
6631
6894
  return "0.0.0";
@@ -6690,8 +6953,9 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
6690
6953
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
6691
6954
  }
6692
6955
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
6956
+ const catalogRefByRepo = [];
6693
6957
  for (const repo of config.repos) {
6694
- const repoRoot = path41.resolve(workspaceRoot, repo.path);
6958
+ const repoRoot = path47.resolve(workspaceRoot, repo.path);
6695
6959
  const entry = manifestByName.get(repo.name);
6696
6960
  if (!entry) {
6697
6961
  flag({
@@ -6724,6 +6988,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
6724
6988
  });
6725
6989
  }
6726
6990
  const lock = await checkLock(repoRoot);
6991
+ if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
6727
6992
  if (!existsSync3(hausPath(repoRoot, "haus.lock.json"))) {
6728
6993
  flag({
6729
6994
  repo: repo.name,
@@ -6741,6 +7006,14 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
6741
7006
  ok(`- ${repo.name}: OK (${lock.count} lock item(s))`);
6742
7007
  }
6743
7008
  }
7009
+ const distinctRefs = new Set(catalogRefByRepo.map((r) => r.ref));
7010
+ if (distinctRefs.size > 1) {
7011
+ flag({
7012
+ repo: "(workspace)",
7013
+ kind: "catalog-ref-mismatch",
7014
+ detail: `Repos are on different catalog refs \u2014 ${catalogRefByRepo.map((r) => `${r.repo}: ${r.ref}`).join(", ")}. Run \`haus workspace setup --write\` to bring them onto the same ref.`
7015
+ });
7016
+ }
6744
7017
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
6745
7018
  }
6746
7019
  function emit(args) {
@@ -6765,11 +7038,11 @@ function emit(args) {
6765
7038
 
6766
7039
  // src/commands/workspace/setup.ts
6767
7040
  import { existsSync as existsSync4, statSync } from "node:fs";
6768
- import path43 from "node:path";
7041
+ import path49 from "node:path";
6769
7042
 
6770
7043
  // src/claude/write-workspace-claude-md.ts
6771
- import path42 from "node:path";
6772
- import fs30 from "fs-extra";
7044
+ import path48 from "node:path";
7045
+ import fs35 from "fs-extra";
6773
7046
  function buildWorkspaceImportBlock(client, members) {
6774
7047
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
6775
7048
  const body = [
@@ -6787,15 +7060,16 @@ ${BLOCK_END}`;
6787
7060
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
6788
7061
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
6789
7062
  const dryRun = opts.dryRun ?? false;
6790
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path42.join(workspaceRoot, "CLAUDE.md");
7063
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path48.join(workspaceRoot, "CLAUDE.md");
6791
7064
  const say = opts.quiet ? () => {
6792
7065
  } : log;
6793
- const prev = await fs30.pathExists(filePath) ? await fs30.readFile(filePath, "utf8") : "";
7066
+ const existed = await fs35.pathExists(filePath);
7067
+ const prev = existed ? await fs35.readFile(filePath, "utf8") : "";
6794
7068
  const next = opts.collision ? `${block}
6795
7069
  ` : injectHausBlock(prev, block);
6796
7070
  const printable = displayPath(workspaceRoot, filePath);
6797
7071
  if (dryRun) {
6798
- if (!prev) {
7072
+ if (!existed) {
6799
7073
  say(createUnifiedDiff(printable, "", next));
6800
7074
  } else if (hasTextChanged(prev, next)) {
6801
7075
  say(createUnifiedDiff(printable, prev, next));
@@ -6804,31 +7078,33 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
6804
7078
  }
6805
7079
  return filePath;
6806
7080
  }
6807
- if (hasTextChanged(prev, next) && prev.length > 0) {
6808
- const diffText = createUnifiedDiff(printable, prev, next);
6809
- const summary = summarizeDiff(diffText);
6810
- say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
7081
+ if (!existed || hasTextChanged(prev, next)) {
7082
+ if (existed) {
7083
+ const diffText = createUnifiedDiff(printable, prev, next);
7084
+ const summary = summarizeDiff(diffText);
7085
+ say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
7086
+ }
7087
+ await writeText(filePath, next);
6811
7088
  }
6812
- await writeText(filePath, next);
6813
7089
  return filePath;
6814
7090
  }
6815
7091
 
6816
7092
  // src/commands/workspace/setup.ts
6817
7093
  function resolveWorkspaceRoot(start = process.cwd()) {
6818
- let dir = path43.resolve(start);
7094
+ let dir = path49.resolve(start);
6819
7095
  for (; ; ) {
6820
- if (existsSync4(path43.join(dir, WORKSPACE_FILE))) return dir;
6821
- const parent = path43.dirname(dir);
6822
- if (parent === dir) return path43.resolve(start);
7096
+ if (existsSync4(path49.join(dir, WORKSPACE_FILE))) return dir;
7097
+ const parent = path49.dirname(dir);
7098
+ if (parent === dir) return path49.resolve(start);
6823
7099
  dir = parent;
6824
7100
  }
6825
7101
  }
6826
7102
  function isRootRepo(workspaceRoot, repoPath) {
6827
- return path43.resolve(workspaceRoot, repoPath) === path43.resolve(workspaceRoot);
7103
+ return path49.resolve(workspaceRoot, repoPath) === path49.resolve(workspaceRoot);
6828
7104
  }
6829
7105
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
6830
7106
  const apply = options.write ?? false;
6831
- const configText = await readText(path43.join(workspaceRoot, WORKSPACE_FILE));
7107
+ const configText = await readText(path49.join(workspaceRoot, WORKSPACE_FILE));
6832
7108
  if (!configText) {
6833
7109
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
6834
7110
  process.exitCode = 1;
@@ -6855,7 +7131,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
6855
7131
  const statuses = [];
6856
7132
  const aggregateInputs = [];
6857
7133
  for (const repo of repos) {
6858
- const repoRoot = path43.resolve(workspaceRoot, repo.path);
7134
+ const repoRoot = path49.resolve(workspaceRoot, repo.path);
6859
7135
  say(`
6860
7136
  \u2192 ${repo.name} (${repo.path})`);
6861
7137
  try {
@@ -6926,7 +7202,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
6926
7202
  const status = statusByName.get(repo.name);
6927
7203
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
6928
7204
  if (status?.status === "ok") {
6929
- const lock = await checkLock(path43.resolve(workspaceRoot, repo.path));
7205
+ const lock = await checkLock(path49.resolve(workspaceRoot, repo.path));
6930
7206
  manifestRepos.push({
6931
7207
  name: repo.name,
6932
7208
  path: repo.path,
@@ -6988,6 +7264,91 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
6988
7264
  return result;
6989
7265
  }
6990
7266
 
7267
+ // src/commands/workspace/undo.ts
7268
+ import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
7269
+ import path50 from "node:path";
7270
+ import fs36 from "fs-extra";
7271
+ var WORKSPACE_AGGREGATE_FILES = [
7272
+ "workspace-summary.json",
7273
+ "dependency-ownership-map.json",
7274
+ "cross-repo-summary.md",
7275
+ "workspace-context-map.json"
7276
+ ];
7277
+ async function runWorkspaceUndo(workspaceRoot, options = {}) {
7278
+ const config = await readWorkspaceConfig(workspaceRoot);
7279
+ if (!config) {
7280
+ error(`Missing or malformed ${WORKSPACE_FILE}. Nothing to undo.`);
7281
+ process.exitCode = 1;
7282
+ return;
7283
+ }
7284
+ if (config.repos.length === 0) {
7285
+ log(`No repos configured in ${WORKSPACE_FILE}.`);
7286
+ }
7287
+ if (!options.yes) {
7288
+ const ok = await confirm(
7289
+ `Undo haus setup for ${config.repos.length} repo(s) in this workspace, plus workspace-root artifacts?`
7290
+ );
7291
+ if (!ok) {
7292
+ log("Cancelled.");
7293
+ return;
7294
+ }
7295
+ }
7296
+ let anyRepoFailed = false;
7297
+ for (const repo of config.repos) {
7298
+ const repoRoot = path50.resolve(workspaceRoot, repo.path);
7299
+ log(`
7300
+ \u2192 ${repo.name} (${repo.path})`);
7301
+ try {
7302
+ if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
7303
+ throw new Error(`Repo path is not a directory: ${repo.path}`);
7304
+ }
7305
+ await runUndo({ yes: true, root: repoRoot });
7306
+ } catch (err) {
7307
+ anyRepoFailed = true;
7308
+ warn(`Undo failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
7309
+ }
7310
+ }
7311
+ for (const rel of WORKSPACE_AGGREGATE_FILES) {
7312
+ const abs = hausPath(workspaceRoot, rel);
7313
+ if (await fs36.pathExists(abs)) {
7314
+ await fs36.remove(abs);
7315
+ log(`Removed ${path50.relative(workspaceRoot, abs)}`);
7316
+ }
7317
+ }
7318
+ const manifestFile = manifestPath(workspaceRoot);
7319
+ if (await fs36.pathExists(manifestFile)) {
7320
+ await fs36.remove(manifestFile);
7321
+ log(`Removed ${path50.relative(workspaceRoot, manifestFile)}`);
7322
+ }
7323
+ const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
7324
+ if (await fs36.pathExists(workspaceMdPath)) {
7325
+ await fs36.remove(workspaceMdPath);
7326
+ log(`Removed ${path50.relative(workspaceRoot, workspaceMdPath)}`);
7327
+ } else {
7328
+ const claudeMdPath = path50.join(workspaceRoot, "CLAUDE.md");
7329
+ if (existsSync5(claudeMdPath)) {
7330
+ const prev = await readText(claudeMdPath);
7331
+ if (prev?.includes(BLOCK_BEGIN)) {
7332
+ const next = stripHausBlock(prev);
7333
+ if (next.length === 0) {
7334
+ await fs36.remove(claudeMdPath);
7335
+ log("Removed CLAUDE.md (only contained the workspace haus import block).");
7336
+ } else {
7337
+ await fs36.writeFile(claudeMdPath, next, "utf8");
7338
+ log("Removed haus import block from CLAUDE.md (user content preserved).");
7339
+ }
7340
+ }
7341
+ }
7342
+ }
7343
+ log(`${WORKSPACE_FILE} left in place \u2014 it's your own config, not haus-owned output.`);
7344
+ if (anyRepoFailed) {
7345
+ process.exitCode = 1;
7346
+ warn("Workspace undo finished with at least one repo failure \u2014 see above.");
7347
+ } else {
7348
+ log("Workspace undo complete.");
7349
+ }
7350
+ }
7351
+
6991
7352
  // src/commands/workspace.ts
6992
7353
  function normalizeOnly(only) {
6993
7354
  if (!only) return void 0;
@@ -7016,7 +7377,7 @@ relationships: []
7016
7377
  log("Workspace initialized.");
7017
7378
  }
7018
7379
  async function scanWorkspace(workspaceRoot, opts) {
7019
- const configText = await readText(path44.join(workspaceRoot, WORKSPACE_FILE));
7380
+ const configText = await readText(path51.join(workspaceRoot, WORKSPACE_FILE));
7020
7381
  if (!configText) {
7021
7382
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
7022
7383
  process.exitCode = 1;
@@ -7038,9 +7399,9 @@ async function scanWorkspace(workspaceRoot, opts) {
7038
7399
  const inputs = [];
7039
7400
  const failures = [];
7040
7401
  for (const repo of config.repos) {
7041
- const repoRoot = path44.resolve(workspaceRoot, repo.path);
7402
+ const repoRoot = path51.resolve(workspaceRoot, repo.path);
7042
7403
  try {
7043
- if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
7404
+ if (!existsSync6(repoRoot) || !statSync3(repoRoot).isDirectory()) {
7044
7405
  throw new Error("path is not a directory");
7045
7406
  }
7046
7407
  const result = await scanProject(repoRoot);
@@ -7092,13 +7453,16 @@ async function runWorkspace(action, options = {}) {
7092
7453
  case "doctor":
7093
7454
  await runWorkspaceDoctor(workspaceRoot, { json: options.json });
7094
7455
  return;
7456
+ case "undo":
7457
+ await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
7458
+ return;
7095
7459
  }
7096
7460
  }
7097
7461
 
7098
7462
  // src/cli.ts
7099
7463
  function cliVersion() {
7100
7464
  try {
7101
- const pkgPath = path45.join(packageRoot(), "package.json");
7465
+ const pkgPath = path52.join(packageRoot(), "package.json");
7102
7466
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
7103
7467
  return pkg.version ?? "0.0.0";
7104
7468
  } catch {
@@ -7108,7 +7472,7 @@ function cliVersion() {
7108
7472
  var program = new Command();
7109
7473
  function validateRuntimeNodeVersion() {
7110
7474
  try {
7111
- const pkgPath = path45.join(packageRoot(), "package.json");
7475
+ const pkgPath = path52.join(packageRoot(), "package.json");
7112
7476
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
7113
7477
  const requiredRange = pkg.engines?.node;
7114
7478
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -7149,7 +7513,7 @@ program.command("clone").description("Clone a single git repository by URL").arg
7149
7513
  program.command("refresh").action(runRefresh);
7150
7514
  program.command("catalog-audit").action(runCatalogAudit);
7151
7515
  program.command("validate-catalog").argument("[manifest]").action(runValidateCatalog);
7152
- program.command("update").option("--check").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
7516
+ program.command("update").option("--check").option("--fast", "With --check, skip per-item content hashing (cheap count+ref only)").option("--from-hook", "Emit JSON for the Claude Code SessionStart hook (silent if up to date)").action(runUpdate);
7153
7517
  program.command("install").option("--dry-run").option("--force").option("--check", "Exit non-zero if any HAUS-MANAGED file is out of date").option("--postinstall", "Run by the npm postinstall hook; prints a plain-language change notice").action(runInstall);
7154
7518
  program.command("scaffold [ids...]").description(
7155
7519
  "Copy catalog config items (ESLint, Prettier) into the project root. Pass item IDs to scaffold specific items, or omit to scaffold all available config items."
@@ -7176,6 +7540,7 @@ workspace.command("discover").description("Auto-find member repos and write/merg
7176
7540
  workspace.command("scan").description("Aggregate a cross-repo summary from a fast scan of each repo").option("--json", "Output the written artifact paths as JSON").action((opts) => runWorkspace("scan", opts));
7177
7541
  workspace.command("setup").description("Per-repo setup loop + workspace layer + manifest").option("--write", "Apply changes (default previews only)").option("--dry-run", "Preview changes without writing").option("--json", "Emit machine-readable per-repo output").option("--continue-on-error", "Keep going past a failed repo (default fail-fast)").option("--only <names>", "Restrict to comma-separated repo names").action((opts) => runWorkspace("setup", opts));
7178
7542
  workspace.command("doctor").description("Report workspace drift against the manifest").option("--json", "Output the manifest and drift array as JSON").action((opts) => runWorkspace("doctor", opts));
7543
+ workspace.command("undo").description("Revert haus setup for every configured repo, plus workspace-root artifacts").option("-y, --yes", "Skip confirmation").action((opts) => runWorkspace("undo", opts));
7179
7544
  program.parseAsync(process.argv).catch((err) => {
7180
7545
  const message = err instanceof Error ? err.message : String(err);
7181
7546
  error(message);