@haus-tech/haus-workflow 1.1.2 → 1.3.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 path53 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,352 @@ 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
+ safetyNotesRequiredTags: [
883
+ "stripe",
884
+ "qliro",
885
+ "payments",
886
+ "bankid",
887
+ "myid",
888
+ "oidc",
889
+ "azure-ad",
890
+ "saml2"
891
+ ],
892
+ intentsRequiredTags: [
893
+ "stripe",
894
+ "qliro",
895
+ "payments",
896
+ "bankid",
897
+ "myid",
898
+ "oidc",
899
+ "azure-ad",
900
+ "saml2"
901
+ ],
902
+ alwaysAllowedTags: [
903
+ "haus",
904
+ "security",
905
+ "quality",
906
+ "review",
907
+ "workflow",
908
+ "baseline",
909
+ "project-instructions"
910
+ ],
911
+ patternTagSuffixes: ["-patterns"]
912
+ };
913
+
914
+ // src/catalog/validation-rules.ts
915
+ var toRegExp = (r) => new RegExp(r.source, r.flags);
916
+ var FORBIDDEN_TAGS = validation_rules_default.forbiddenTags;
917
+ var REQUIRED_SKILL_FRONTMATTER = validation_rules_default.requiredSkillFrontmatter;
918
+ var RISKY_INSTALL_PATTERNS = validation_rules_default.riskyInstallPatterns.map(toRegExp);
919
+ var ALLOWED_NPX_PATTERN = toRegExp(validation_rules_default.allowedNpxPattern);
920
+ var ANY_NPX_PATTERN = toRegExp(validation_rules_default.anyNpxPattern);
921
+ var NPX_TSX_ONLY_EXEMPT_SOURCES = validation_rules_default.npxTsxOnlyExemptSources ?? [];
922
+ function isNpxTsxOnlyExempt(_itemType, itemSource) {
923
+ return Boolean(itemSource && NPX_TSX_ONLY_EXEMPT_SOURCES.includes(itemSource));
800
924
  }
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("/../");
925
+ function buildItemPathSourceMap(items) {
926
+ const map = /* @__PURE__ */ new Map();
927
+ for (const item of items) {
928
+ if (!item.path) continue;
929
+ map.set(String(item.path).replace(/\\/g, "/"), item.source);
930
+ }
931
+ return map;
806
932
  }
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;
933
+ function resolveMarkdownItemSource(relPath, pathSourceMap) {
934
+ const norm = relPath.replace(/\\/g, "/");
935
+ let cursor = norm;
936
+ while (true) {
937
+ if (pathSourceMap.has(cursor)) return pathSourceMap.get(cursor);
938
+ const slash = cursor.lastIndexOf("/");
939
+ if (slash === -1) break;
940
+ cursor = cursor.slice(0, slash);
941
+ }
942
+ return void 0;
812
943
  }
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;
944
+ var HTTP_URL_PATTERN = toRegExp(validation_rules_default.httpUrlPattern);
945
+ var PLACEHOLDER_PATTERN = toRegExp(validation_rules_default.placeholderPattern);
946
+ var ALLOWED_STACKS = validation_rules_default.allowedStacks;
947
+ var ALWAYS_ALLOWED_TAGS = validation_rules_default.alwaysAllowedTags;
948
+ var PATTERN_TAG_SUFFIXES = validation_rules_default.patternTagSuffixes;
949
+ var ALLOWED_SET = new Set([...ALLOWED_STACKS, ...ALWAYS_ALLOWED_TAGS].map((t) => t.toLowerCase()));
950
+ function isTagAllowed(tag) {
951
+ const lower = tag.toLowerCase();
952
+ if (ALLOWED_SET.has(lower)) return true;
953
+ return PATTERN_TAG_SUFFIXES.some((suffix) => lower.endsWith(suffix));
954
+ }
955
+ function auditDisallowedTags(items) {
956
+ const failures = [];
957
+ for (const item of items) {
958
+ if (!item.id) continue;
959
+ for (const tag of Array.isArray(item.tags) ? item.tags : []) {
960
+ if (!isTagAllowed(tag)) failures.push(`${item.id}: tag not in allowlist: "${tag}"`);
819
961
  }
820
- safe.push(rel);
821
962
  }
822
- return safe;
963
+ return failures;
823
964
  }
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;
965
+
966
+ // src/catalog/forbidden-content.ts
967
+ var PROSE_FORBIDDEN_TAGS = FORBIDDEN_TAGS.filter((t) => t.toLowerCase() !== "go");
968
+ function escapeRegExp(value) {
969
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
828
970
  }
829
- var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
830
- function isMarkdownPath(rel) {
831
- return rel.toLowerCase().endsWith(".md");
971
+ function extractUseWhenSection(text) {
972
+ const marker = "## Use when";
973
+ const idx = text.toLowerCase().indexOf(marker.toLowerCase());
974
+ if (idx < 0) return "";
975
+ const tail = text.slice(idx + marker.length);
976
+ const next = tail.search(/\n##\s+/);
977
+ return next < 0 ? tail : tail.slice(0, next);
832
978
  }
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;
841
- }
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
- }
979
+ function isYamlBlockScalarHeader(rest) {
980
+ return /^[>|][-+]?(\d+)?(?:\s+#.*)?$/.test(rest);
981
+ }
982
+ function extractFrontmatterValue(text, key) {
983
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
984
+ if (!m) return "";
985
+ const lines = m[1].split(/\r?\n/);
986
+ const keyRe = new RegExp(`^${escapeRegExp(key)}:[ \\t]*`);
987
+ const idx = lines.findIndex((l) => keyRe.test(l));
988
+ if (idx < 0) return "";
989
+ const rest = lines[idx].replace(keyRe, "").trim();
990
+ if (!rest) return "";
991
+ if (!isYamlBlockScalarHeader(rest)) {
992
+ return rest.replace(/^["']|["']$/g, "").trim();
849
993
  }
850
- return out.sort();
994
+ const body = [];
995
+ for (let j = idx + 1; j < lines.length; j++) {
996
+ const line2 = lines[j];
997
+ if (/^[a-zA-Z_][\w.-]*:[ \t]/.test(line2)) break;
998
+ if (line2.trim() === "") continue;
999
+ if (/^\s+/.test(line2)) body.push(line2.trimStart());
1000
+ else break;
1001
+ }
1002
+ return body.join(" ").replace(/\s+/g, " ").trim();
851
1003
  }
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;
1004
+ function extractFrontmatterDescription(text) {
1005
+ return extractFrontmatterValue(text, "description");
1006
+ }
1007
+ function auditForbiddenTagsInText(text, label) {
1008
+ const body = `${extractFrontmatterDescription(text)}
1009
+ ${extractUseWhenSection(text)}`;
1010
+ if (!body.trim()) return [];
1011
+ const failures = [];
1012
+ for (const word of PROSE_FORBIDDEN_TAGS) {
1013
+ const re = new RegExp(`\\b${escapeRegExp(word)}\\b`, "i");
1014
+ if (re.test(body)) failures.push(`${label}: forbidden stack/tag "${word}" in content`);
861
1015
  }
1016
+ return failures;
862
1017
  }
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;
1018
+
1019
+ // src/catalog/ingest-catalog.ts
1020
+ function validateCatalogItem(item, content2) {
1021
+ const label = item.id;
1022
+ const checkNonTsxNpx = !isNpxTsxOnlyExempt(item.type, item.source);
1023
+ const lines = content2.split(/\r?\n/);
1024
+ for (let i = 0; i < lines.length; i++) {
1025
+ const line2 = lines[i] ?? "";
1026
+ if (RISKY_INSTALL_PATTERNS.some((re) => re.test(line2))) {
1027
+ return { ok: false, reason: `${label}: risky install pattern at line ${i + 1}` };
1028
+ }
1029
+ if (checkNonTsxNpx && ANY_NPX_PATTERN.test(line2) && !ALLOWED_NPX_PATTERN.test(line2)) {
1030
+ return { ok: false, reason: `${label}: disallowed npx at line ${i + 1}` };
882
1031
  }
883
- return tree.tree.filter((e) => e.type === "blob").map((e) => e.path);
884
- } catch {
885
- return null;
886
1032
  }
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();
1033
+ const tagFailures = auditForbiddenTagsInText(content2, label);
1034
+ if (tagFailures.length > 0) {
1035
+ return { ok: false, reason: tagFailures[0] };
906
1036
  }
907
- if (!relFiles) return null;
908
- return sanitizeRelativeFilePaths(relFiles, normalized);
1037
+ return { ok: true };
1038
+ }
1039
+
1040
+ // src/catalog/remote-catalog/sync.ts
1041
+ var KNOWN_ITEM_TYPES = /* @__PURE__ */ new Set(["skill", "agent", "template", "command", "config"]);
1042
+ function isMarkdownPath(rel) {
1043
+ return rel.toLowerCase().endsWith(".md");
909
1044
  }
910
1045
  async function fetchPrefixFiles(catalogPrefix, relFiles, base, label) {
911
1046
  const fetched = [];
@@ -941,7 +1076,7 @@ function validateMarkdownFiles(item, fetched) {
941
1076
  return true;
942
1077
  }
943
1078
  async function directoryMatchesFetched(destDir, fetched) {
944
- if (!await fs2.pathExists(destDir)) return false;
1079
+ if (!await fs6.pathExists(destDir)) return false;
945
1080
  const existing = await listFilesRecursive(destDir);
946
1081
  const relSet = new Set(fetched.map((f) => f.rel));
947
1082
  if (existing.length !== fetched.length) return false;
@@ -949,30 +1084,30 @@ async function directoryMatchesFetched(destDir, fetched) {
949
1084
  if (!relSet.has(rel)) return false;
950
1085
  }
951
1086
  for (const file of fetched) {
952
- const dest = path3.join(destDir, file.rel);
953
- if (!await fs2.pathExists(dest)) return false;
1087
+ const dest = path8.join(destDir, file.rel);
1088
+ if (!await fs6.pathExists(dest)) return false;
954
1089
  if (file.kind === "text") {
955
- const local = await fs2.readFile(dest, "utf8");
1090
+ const local = await fs6.readFile(dest, "utf8");
956
1091
  if (local !== file.body) return false;
957
1092
  } else {
958
- const local = await fs2.readFile(dest);
1093
+ const local = await fs6.readFile(dest);
959
1094
  if (!local.equals(file.body)) return false;
960
1095
  }
961
1096
  }
962
1097
  return true;
963
1098
  }
964
1099
  async function writeFetchedDirectory(destDir, fetched) {
965
- if (await fs2.pathExists(destDir)) {
966
- await fs2.remove(destDir);
1100
+ if (await fs6.pathExists(destDir)) {
1101
+ await fs6.remove(destDir);
967
1102
  }
968
- await fs2.ensureDir(destDir);
1103
+ await fs6.ensureDir(destDir);
969
1104
  for (const file of fetched) {
970
- const dest = path3.join(destDir, file.rel);
971
- await fs2.ensureDir(path3.dirname(dest));
1105
+ const dest = path8.join(destDir, file.rel);
1106
+ await fs6.ensureDir(path8.dirname(dest));
972
1107
  if (file.kind === "text") {
973
- await fs2.writeFile(dest, file.body, "utf8");
1108
+ await fs6.writeFile(dest, file.body, "utf8");
974
1109
  } else {
975
- await fs2.writeFile(dest, file.body);
1110
+ await fs6.writeFile(dest, file.body);
976
1111
  }
977
1112
  }
978
1113
  }
@@ -1012,7 +1147,7 @@ async function syncDirectoryFromPrefix(item, catalogPrefix, destDir, base, opts)
1012
1147
  }
1013
1148
  }
1014
1149
  }
1015
- const existed = await fs2.pathExists(destDir);
1150
+ const existed = await fs6.pathExists(destDir);
1016
1151
  if (await directoryMatchesFetched(destDir, fetched)) {
1017
1152
  return "unchanged";
1018
1153
  }
@@ -1129,7 +1264,7 @@ async function syncOneItem(item, base) {
1129
1264
  }
1130
1265
  }
1131
1266
  async function syncRemoteCatalog() {
1132
- cachedBlobPaths = void 0;
1267
+ _resetBlobPathCacheForNewSync();
1133
1268
  const manifest = await fetchRemoteManifest();
1134
1269
  if (!manifest) {
1135
1270
  warn("Remote catalog fetch failed \u2014 using bundled catalog");
@@ -1138,9 +1273,9 @@ async function syncRemoteCatalog() {
1138
1273
  const { version, items } = manifest;
1139
1274
  const cacheDir = getCacheDir();
1140
1275
  try {
1141
- await fs2.ensureDir(cacheDir);
1142
- await fs2.writeFile(
1143
- path3.join(cacheDir, "manifest.json"),
1276
+ await fs6.ensureDir(cacheDir);
1277
+ await fs6.writeFile(
1278
+ path8.join(cacheDir, "manifest.json"),
1144
1279
  `${JSON.stringify({ version, items }, null, 2)}
1145
1280
  `,
1146
1281
  "utf8"
@@ -1171,44 +1306,9 @@ async function syncRemoteCatalog() {
1171
1306
  }
1172
1307
  return { newItems, refreshed, unchanged, failed };
1173
1308
  }
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
1309
  async function getCacheManifestAge() {
1210
1310
  try {
1211
- const stat = await fs2.stat(path3.join(getCacheDir(), "manifest.json"));
1311
+ const stat = await fs6.stat(path8.join(getCacheDir(), "manifest.json"));
1212
1312
  return Date.now() - stat.mtimeMs;
1213
1313
  } catch {
1214
1314
  return null;
@@ -1222,30 +1322,30 @@ async function loadCatalogContext(root) {
1222
1322
  const data2 = await readJson(envPath);
1223
1323
  return {
1224
1324
  items: data2?.items ?? [],
1225
- contentRoot: path4.dirname(envPath),
1325
+ contentRoot: path9.dirname(envPath),
1226
1326
  source: "fixture"
1227
1327
  };
1228
1328
  }
1229
1329
  const cacheDir = getCacheDir();
1230
- const cacheManifestPath = path4.join(cacheDir, "manifest.json");
1330
+ const cacheManifestPath = path9.join(cacheDir, "manifest.json");
1231
1331
  const cacheData = await readJson(cacheManifestPath);
1232
1332
  if (cacheData?.items?.length) {
1233
1333
  return { items: cacheData.items, contentRoot: cacheDir, source: "cache" };
1234
1334
  }
1235
- const localManifest = path4.join(root, "library/catalog/manifest.json");
1335
+ const localManifest = path9.join(root, "library/catalog/manifest.json");
1236
1336
  const localData = await readJson(localManifest);
1237
1337
  if (localData?.items?.length) {
1238
1338
  return {
1239
1339
  items: localData.items,
1240
- contentRoot: path4.dirname(localManifest),
1340
+ contentRoot: path9.dirname(localManifest),
1241
1341
  source: "local"
1242
1342
  };
1243
1343
  }
1244
- const packageManifest = path4.join(packageRoot(), "library/catalog/manifest.json");
1344
+ const packageManifest = path9.join(packageRoot(), "library/catalog/manifest.json");
1245
1345
  const data = await readJson(packageManifest);
1246
1346
  return {
1247
1347
  items: data?.items ?? [],
1248
- contentRoot: path4.dirname(packageManifest),
1348
+ contentRoot: path9.dirname(packageManifest),
1249
1349
  source: "bundled"
1250
1350
  };
1251
1351
  }
@@ -1254,17 +1354,70 @@ async function loadCatalog(root) {
1254
1354
  return ctx.items;
1255
1355
  }
1256
1356
  function catalogItemContentPath(contentRoot, item) {
1257
- return path4.join(contentRoot, item.path);
1357
+ return path9.join(contentRoot, item.path);
1258
1358
  }
1259
1359
 
1260
1360
  // src/claude/write-claude-files.ts
1261
- import path14 from "node:path";
1262
- import fs14 from "fs-extra";
1361
+ import path19 from "node:path";
1362
+ import fs18 from "fs-extra";
1363
+
1364
+ // src/catalog/former-ids.ts
1365
+ function normalizeFormerIds(value) {
1366
+ if (value === void 0 || value === null) return [];
1367
+ if (!Array.isArray(value) || value.length === 0) return [];
1368
+ if (!value.every((entry) => typeof entry === "string")) return [];
1369
+ return value.filter((entry) => entry.length > 0);
1370
+ }
1371
+ function assertFormerIdsShape(itemId, formerIds) {
1372
+ if (formerIds === void 0) return;
1373
+ if (Array.isArray(formerIds) && formerIds.length === 0) return;
1374
+ if (!Array.isArray(formerIds) || !formerIds.every((entry) => typeof entry === "string")) {
1375
+ throw new Error(`${itemId}: formerIds must be a string array`);
1376
+ }
1377
+ }
1378
+ function buildFormerIdMap(items) {
1379
+ const map = /* @__PURE__ */ new Map();
1380
+ const currentIds = new Set(items.map((item) => item.id));
1381
+ for (const item of items) {
1382
+ assertFormerIdsShape(item.id, item.formerIds);
1383
+ const formerIds = item.formerIds ?? [];
1384
+ if (!Array.isArray(formerIds) || formerIds.length === 0) continue;
1385
+ for (const formerId of formerIds) {
1386
+ if (currentIds.has(formerId) && formerId !== item.id) {
1387
+ throw new Error(
1388
+ `formerId ${formerId} on ${item.id} conflicts with another item's current id`
1389
+ );
1390
+ }
1391
+ const existingOwner = map.get(formerId);
1392
+ if (existingOwner !== void 0) {
1393
+ throw new Error(
1394
+ `duplicate formerId ${formerId} claimed by both ${existingOwner} and ${item.id}`
1395
+ );
1396
+ }
1397
+ map.set(formerId, item.id);
1398
+ }
1399
+ }
1400
+ return map;
1401
+ }
1402
+ function findFormerIdMigrations(lockItems, manifestItems) {
1403
+ const formerIdMap = buildFormerIdMap(manifestItems);
1404
+ return lockItems.flatMap(({ id }) => {
1405
+ const newId = formerIdMap.get(id);
1406
+ return newId === void 0 ? [] : [{ oldId: id, newId }];
1407
+ });
1408
+ }
1409
+
1410
+ // src/recommender/orphaned-items.ts
1411
+ function findOrphanedLockEntries(lock, recommendedIds) {
1412
+ return lock.filter(
1413
+ (entry) => typeof entry.id === "string" && !recommendedIds.has(entry.id)
1414
+ );
1415
+ }
1263
1416
 
1264
1417
  // src/update/hash-installed.ts
1265
- import path5 from "node:path";
1418
+ import path10 from "node:path";
1266
1419
  import fg2 from "fast-glob";
1267
- import fs3 from "fs-extra";
1420
+ import fs7 from "fs-extra";
1268
1421
 
1269
1422
  // src/claude/haus-managed-header.ts
1270
1423
  function parseHausManagedAttrs(line2) {
@@ -1303,6 +1456,14 @@ function parseHausManagedHeader(line2) {
1303
1456
 
1304
1457
  // src/update/hash-installed.ts
1305
1458
  var EMPTY_LOCK_PATHS_TOKEN = "haus-lock:empty-paths";
1459
+ function digestFileContent(buf) {
1460
+ const asText = buf.toString("utf8");
1461
+ const roundTrip = Buffer.from(asText, "utf8");
1462
+ if (roundTrip.equals(buf)) {
1463
+ return hashText(normaliseLF(asText));
1464
+ }
1465
+ return hashText(buf);
1466
+ }
1306
1467
  async function hashInstalledPaths(root, relPaths) {
1307
1468
  if (relPaths.length === 0) {
1308
1469
  return hashText(EMPTY_LOCK_PATHS_TOKEN);
@@ -1310,21 +1471,21 @@ async function hashInstalledPaths(root, relPaths) {
1310
1471
  const normalized = [...new Set(relPaths.map((p) => p.replace(/\\/g, "/")))].sort();
1311
1472
  const fileDigests = [];
1312
1473
  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);
1474
+ const abs = path10.join(root, rel);
1475
+ if (!await fs7.pathExists(abs)) continue;
1476
+ const stat = await fs7.stat(abs);
1316
1477
  if (stat.isFile()) {
1317
- const body = await fs3.readFile(abs, "utf8");
1318
- fileDigests.push({ rel, digest: hashText(normaliseLF(body)) });
1478
+ const body = await fs7.readFile(abs);
1479
+ fileDigests.push({ rel, digest: digestFileContent(body) });
1319
1480
  continue;
1320
1481
  }
1321
1482
  if (!stat.isDirectory()) continue;
1322
1483
  const inner = await fg2("**/*", { cwd: abs, onlyFiles: true, dot: true });
1323
1484
  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)) });
1485
+ const relFile = path10.join(rel, sub).replace(/\\/g, "/");
1486
+ const absFile = path10.join(abs, sub);
1487
+ const body = await fs7.readFile(absFile);
1488
+ fileDigests.push({ rel: relFile, digest: digestFileContent(body) });
1328
1489
  }
1329
1490
  }
1330
1491
  if (fileDigests.length === 0) {
@@ -1335,7 +1496,7 @@ async function hashInstalledPaths(root, relPaths) {
1335
1496
  }
1336
1497
 
1337
1498
  // src/claude/managed-write.ts
1338
- import fs4 from "fs-extra";
1499
+ import fs8 from "fs-extra";
1339
1500
 
1340
1501
  // src/utils/diff.ts
1341
1502
  import { createTwoFilesPatch } from "diff";
@@ -1361,10 +1522,11 @@ function summarizeDiff(diffText) {
1361
1522
 
1362
1523
  // src/claude/managed-write.ts
1363
1524
  async function writeManagedText(root, filePath, nextText, dryRun) {
1364
- const prev = await fs4.pathExists(filePath) ? await fs4.readFile(filePath, "utf8") : "";
1525
+ const existed = await fs8.pathExists(filePath);
1526
+ const prev = existed ? await fs8.readFile(filePath, "utf8") : "";
1365
1527
  const printable = displayPath(root, filePath);
1366
1528
  if (dryRun) {
1367
- if (!prev) {
1529
+ if (!existed) {
1368
1530
  log(createUnifiedDiff(printable, "", nextText));
1369
1531
  } else if (hasTextChanged(prev, nextText)) {
1370
1532
  log(createUnifiedDiff(printable, prev, nextText));
@@ -1373,12 +1535,14 @@ async function writeManagedText(root, filePath, nextText, dryRun) {
1373
1535
  }
1374
1536
  return;
1375
1537
  }
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})`);
1538
+ if (!existed || hasTextChanged(prev, nextText)) {
1539
+ if (existed) {
1540
+ const diffText = createUnifiedDiff(printable, prev, nextText);
1541
+ const summary = summarizeDiff(diffText);
1542
+ log(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
1543
+ }
1544
+ await writeText(filePath, nextText);
1380
1545
  }
1381
- await writeText(filePath, nextText);
1382
1546
  }
1383
1547
  async function writeManagedJson(root, filePath, value, dryRun) {
1384
1548
  const nextText = `${JSON.stringify(value, null, 2)}
@@ -1393,18 +1557,18 @@ function buildAllowRules() {
1393
1557
  }
1394
1558
 
1395
1559
  // src/install/settings-merge.ts
1396
- import path7 from "node:path";
1397
- import fs5 from "fs-extra";
1560
+ import path12 from "node:path";
1561
+ import fs9 from "fs-extra";
1398
1562
 
1399
1563
  // src/install/manifest.ts
1400
1564
  import os3 from "node:os";
1401
- import path6 from "node:path";
1565
+ import path11 from "node:path";
1402
1566
  var MANIFEST_SCHEMA = "haus-install-manifest/1";
1403
1567
  function globalClaudeDir() {
1404
- return path6.join(os3.homedir(), ".claude");
1568
+ return path11.join(os3.homedir(), ".claude");
1405
1569
  }
1406
1570
  function hausManifestPath() {
1407
- return path6.join(globalClaudeDir(), "haus", "install-manifest.json");
1571
+ return path11.join(globalClaudeDir(), "haus", "install-manifest.json");
1408
1572
  }
1409
1573
  async function readManifest() {
1410
1574
  return readJson(hausManifestPath());
@@ -1424,7 +1588,7 @@ function buildManifest(source, files, hooks) {
1424
1588
 
1425
1589
  // src/install/settings-merge.ts
1426
1590
  function settingsJsonPath() {
1427
- return path7.join(globalClaudeDir(), "settings.json");
1591
+ return path12.join(globalClaudeDir(), "settings.json");
1428
1592
  }
1429
1593
  async function readSettings() {
1430
1594
  const filePath = settingsJsonPath();
@@ -1539,77 +1703,62 @@ function reconcileManagedRules(existing, prevTracked, newRules) {
1539
1703
  const removed = existing.filter((rule) => !newSet.has(rule));
1540
1704
  return { rules: [...userRules, ...tracked], tracked, added, removed };
1541
1705
  }
1706
+ function createRuleTier(permKey, trackedKey) {
1707
+ function merge(settings, rules) {
1708
+ const existing = settings.permissions?.[permKey] ?? [];
1709
+ const tracked = settings._haus?.[trackedKey] ?? [];
1710
+ const {
1711
+ rules: nextRules,
1712
+ tracked: nextTracked,
1713
+ added
1714
+ } = reconcileManagedRules(existing, tracked, rules);
1715
+ const updated = { ...settings };
1716
+ updated.permissions = { ...settings.permissions ?? {}, [permKey]: nextRules };
1717
+ updated._haus = {
1718
+ hooks: settings._haus?.hooks ?? [],
1719
+ ...settings._haus?.hookCommands ? { hookCommands: settings._haus.hookCommands } : {},
1720
+ ...permKey !== "deny" && settings._haus?.denyRules ? { denyRules: settings._haus.denyRules } : {},
1721
+ ...permKey !== "allow" && settings._haus?.allowRules ? { allowRules: settings._haus.allowRules } : {},
1722
+ ...permKey !== "ask" && settings._haus?.askRules ? { askRules: settings._haus.askRules } : {},
1723
+ [trackedKey]: nextTracked
1724
+ };
1725
+ return { settings: updated, addedRules: added };
1726
+ }
1727
+ function strip(settings) {
1728
+ const prevHaus = settings._haus;
1729
+ const ownedRules = prevHaus?.[trackedKey];
1730
+ if (!ownedRules || ownedRules.length === 0) return settings;
1731
+ const ownedSet = new Set(ownedRules);
1732
+ const updated = { ...settings };
1733
+ const remaining = (settings.permissions?.[permKey] ?? []).filter((rule) => !ownedSet.has(rule));
1734
+ const permissions = { ...settings.permissions ?? {} };
1735
+ if (remaining.length > 0) permissions[permKey] = remaining;
1736
+ else delete permissions[permKey];
1737
+ if (Object.keys(permissions).length > 0) updated.permissions = permissions;
1738
+ else delete updated.permissions;
1739
+ const haus = { ...prevHaus };
1740
+ delete haus[trackedKey];
1741
+ 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;
1742
+ if (stillTracking) updated._haus = haus;
1743
+ else delete updated._haus;
1744
+ return updated;
1745
+ }
1746
+ return { merge, strip };
1747
+ }
1748
+ var denyTier = createRuleTier("deny", "denyRules");
1749
+ var allowTier = createRuleTier("allow", "allowRules");
1750
+ var askTier = createRuleTier("ask", "askRules");
1542
1751
  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 };
1752
+ return denyTier.merge(settings, rules);
1559
1753
  }
1560
1754
  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 };
1755
+ return allowTier.merge(settings, rules);
1577
1756
  }
1578
1757
  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;
1758
+ return allowTier.strip(settings);
1595
1759
  }
1596
1760
  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;
1761
+ return denyTier.strip(settings);
1613
1762
  }
1614
1763
  function stripHausHooks(settings) {
1615
1764
  if (!settings._haus) return settings;
@@ -1631,45 +1780,15 @@ function stripHausHooks(settings) {
1631
1780
  return rest;
1632
1781
  }
1633
1782
  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 };
1783
+ return askTier.merge(settings, rules);
1650
1784
  }
1651
1785
  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;
1786
+ return askTier.strip(settings);
1668
1787
  }
1669
1788
  async function loadHooksFragment(fragmentPath) {
1670
1789
  let raw;
1671
1790
  try {
1672
- raw = await fs5.readJson(fragmentPath);
1791
+ raw = await fs9.readJson(fragmentPath);
1673
1792
  } catch {
1674
1793
  return [];
1675
1794
  }
@@ -1784,8 +1903,8 @@ function buildDenyRules() {
1784
1903
  for (const command of DENY_COMMANDS) {
1785
1904
  rules.push(`Bash(${command}:*)`);
1786
1905
  }
1787
- for (const path46 of DENY_PATHS) {
1788
- const pattern = DENY_DIRS.has(path46) ? `${path46}/**` : path46;
1906
+ for (const path54 of DENY_PATHS) {
1907
+ const pattern = DENY_DIRS.has(path54) ? `${path54}/**` : path54;
1789
1908
  for (const tool of FILE_TOOLS) {
1790
1909
  rules.push(`${tool}(${pattern})`);
1791
1910
  }
@@ -1857,9 +1976,9 @@ async function applyProjectSettingsMerge(root) {
1857
1976
  }
1858
1977
 
1859
1978
  // src/claude/superpowers-install.ts
1860
- import path8 from "node:path";
1979
+ import path13 from "node:path";
1861
1980
  import fg3 from "fast-glob";
1862
- import fs6 from "fs-extra";
1981
+ import fs10 from "fs-extra";
1863
1982
  var SUPERPOWERS_ORIGIN_SOURCE_ID = "superpowers-pcvelz";
1864
1983
  var PATH_REWRITES = [
1865
1984
  ["skills/shared/", ".claude/skills/shared/"]
@@ -1874,41 +1993,41 @@ function rewriteSuperpowersMarkdown(text) {
1874
1993
  async function rewriteMarkdownTree(dir) {
1875
1994
  const files = await fg3("**/*.md", { cwd: dir, onlyFiles: true, dot: true });
1876
1995
  for (const rel of files) {
1877
- const abs = path8.join(dir, rel);
1878
- const text = await fs6.readFile(abs, "utf8");
1996
+ const abs = path13.join(dir, rel);
1997
+ const text = await fs10.readFile(abs, "utf8");
1879
1998
  const rewritten = rewriteSuperpowersMarkdown(text);
1880
1999
  if (rewritten !== text) {
1881
- await fs6.writeFile(abs, rewritten, "utf8");
2000
+ await fs10.writeFile(abs, rewritten, "utf8");
1882
2001
  }
1883
2002
  }
1884
2003
  }
1885
2004
  async function installCatalogSkill(sourcePath, destination, opts) {
1886
2005
  if (opts.dryRun) return;
1887
- await fs6.ensureDir(path8.dirname(destination));
1888
- if (await fs6.pathExists(destination)) {
1889
- await fs6.remove(destination);
2006
+ await fs10.ensureDir(path13.dirname(destination));
2007
+ if (await fs10.pathExists(destination)) {
2008
+ await fs10.remove(destination);
1890
2009
  }
1891
- await fs6.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2010
+ await fs10.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
1892
2011
  if (opts.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID) {
1893
2012
  await rewriteMarkdownTree(destination);
1894
2013
  }
1895
2014
  }
1896
2015
  async function installSuperpowersShared(contentRoot, projectRoot, dryRun) {
1897
- const source = path8.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
1898
- if (!await fs6.pathExists(source)) return null;
2016
+ const source = path13.join(contentRoot, SUPERPOWERS_SHARED_CATALOG_REL);
2017
+ if (!await fs10.pathExists(source)) return null;
1899
2018
  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);
2019
+ if (dryRun) return path13.relative(projectRoot, destination);
2020
+ await fs10.ensureDir(path13.dirname(destination));
2021
+ if (await fs10.pathExists(destination)) {
2022
+ await fs10.remove(destination);
1904
2023
  }
1905
- await fs6.copy(source, destination, { overwrite: true, errorOnExist: false });
2024
+ await fs10.copy(source, destination, { overwrite: true, errorOnExist: false });
1906
2025
  await rewriteMarkdownTree(destination);
1907
- return path8.relative(projectRoot, destination);
2026
+ return path13.relative(projectRoot, destination);
1908
2027
  }
1909
2028
 
1910
2029
  // src/claude/verify-hooks-contract.ts
1911
- import fs7 from "fs-extra";
2030
+ import fs11 from "fs-extra";
1912
2031
 
1913
2032
  // src/claude/load-hooks.ts
1914
2033
  var CANONICAL_HOOKS = {
@@ -1988,7 +2107,7 @@ async function assertPostApplySettingsHausContract(root) {
1988
2107
  }
1989
2108
  async function verifyProjectSettingsHooksContract(root) {
1990
2109
  const settingsPath = claudePath(root, "settings.json");
1991
- if (!await fs7.pathExists(settingsPath)) {
2110
+ if (!await fs11.pathExists(settingsPath)) {
1992
2111
  return {
1993
2112
  ok: true,
1994
2113
  skipped: true,
@@ -2015,8 +2134,8 @@ async function verifyProjectSettingsHooksContract(root) {
2015
2134
  }
2016
2135
 
2017
2136
  // src/claude/write-decisions-seed.ts
2018
- import path9 from "node:path";
2019
- import fs8 from "fs-extra";
2137
+ import path14 from "node:path";
2138
+ import fs12 from "fs-extra";
2020
2139
 
2021
2140
  // library/catalog/decisions-triggers.json
2022
2141
  var decisions_triggers_default = {
@@ -2059,24 +2178,36 @@ Why we chose X \u2014 short, dated, write-once decision log for humans and agent
2059
2178
  | --- | ----- | -------------- | ------ |
2060
2179
  `;
2061
2180
  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);
2181
+ const decisionsDir = path14.join(root, DECISIONS_TRIGGERS.decisionsDir);
2182
+ const readmePath = path14.join(decisionsDir, "README.md");
2183
+ if (await fs12.pathExists(readmePath)) return null;
2184
+ if (!dryRun) await fs12.ensureDir(decisionsDir);
2066
2185
  await writeManagedText(root, readmePath, SEED_README, dryRun);
2067
2186
  return readmePath;
2068
2187
  }
2069
2188
 
2070
2189
  // src/claude/write-prettierignore.ts
2071
- import path10 from "node:path";
2072
- import fs9 from "fs-extra";
2190
+ import path15 from "node:path";
2191
+ import fs13 from "fs-extra";
2073
2192
  var PRETTIERIGNORE_BEGIN = "# HAUS:BEGIN haus-managed v=1";
2074
2193
  var PRETTIERIGNORE_END = "# HAUS:END haus-managed";
2075
- var IGNORED_PATHS = [".haus-workflow/"];
2194
+ var IGNORED_PATHS = [".haus-workflow/", ".claude/"];
2195
+ function prettierIgnoreProtects(lines, dir) {
2196
+ const bare = dir.replace(/\/+$/, "");
2197
+ const equivalents = /* @__PURE__ */ new Set([
2198
+ bare,
2199
+ `${bare}/`,
2200
+ `/${bare}`,
2201
+ `/${bare}/`,
2202
+ `${bare}/**`,
2203
+ `/${bare}/**`
2204
+ ]);
2205
+ return lines.some((line2) => equivalents.has(line2.trim()));
2206
+ }
2076
2207
  function buildPrettierIgnoreBlock() {
2077
2208
  return [
2078
2209
  PRETTIERIGNORE_BEGIN,
2079
- "# haus-owned files \u2014 do not reformat (keeps tamper-detection hashes stable)",
2210
+ "# haus-owned files \u2014 do not reformat (keeps tamper/lock hashes stable)",
2080
2211
  ...IGNORED_PATHS,
2081
2212
  PRETTIERIGNORE_END
2082
2213
  ].join("\n");
@@ -2125,16 +2256,16 @@ ${block}
2125
2256
  `;
2126
2257
  }
2127
2258
  async function writePrettierIgnore(root, dryRun) {
2128
- const filePath = path10.join(root, ".prettierignore");
2129
- const prev = await fs9.pathExists(filePath) ? await fs9.readFile(filePath, "utf8") : "";
2259
+ const filePath = path15.join(root, ".prettierignore");
2260
+ const prev = await fs13.pathExists(filePath) ? await fs13.readFile(filePath, "utf8") : "";
2130
2261
  const next = injectPrettierIgnoreBlock(prev, buildPrettierIgnoreBlock());
2131
2262
  await writeManagedText(root, filePath, next, dryRun);
2132
2263
  return filePath;
2133
2264
  }
2134
2265
 
2135
2266
  // src/claude/write-root-claude-md.ts
2136
- import path11 from "node:path";
2137
- import fs10 from "fs-extra";
2267
+ import path16 from "node:path";
2268
+ import fs14 from "fs-extra";
2138
2269
  var BLOCK_BEGIN = "<!-- HAUS:BEGIN haus-imports v=1 -->";
2139
2270
  var BLOCK_END = "<!-- HAUS:END haus-imports -->";
2140
2271
  var IMPORT_CONTENT = `@.haus-workflow/WORKFLOW.md
@@ -2165,12 +2296,20 @@ function findImportBlockRange(content2) {
2165
2296
  }
2166
2297
  function stripHausBlock(existing) {
2167
2298
  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}
2299
+ if (range) {
2300
+ const before = existing.slice(0, range.beginStart);
2301
+ const after = existing.slice(range.endEnd);
2302
+ const merged = `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimEnd();
2303
+ return merged.length > 0 ? `${merged}
2304
+ ` : "";
2305
+ }
2306
+ const loneBegin = findLineMarker2(existing, BLOCK_BEGIN);
2307
+ if (loneBegin) {
2308
+ const before = existing.slice(0, loneBegin.start).trimEnd();
2309
+ return before.length > 0 ? `${before}
2173
2310
  ` : "";
2311
+ }
2312
+ return existing;
2174
2313
  }
2175
2314
  function injectHausBlock(existing, block) {
2176
2315
  const range = findImportBlockRange(existing);
@@ -2200,21 +2339,21 @@ ${block}
2200
2339
  `;
2201
2340
  }
2202
2341
  async function writeRootClaudeMd(root, dryRun) {
2203
- const filePath = path11.join(root, "CLAUDE.md");
2342
+ const filePath = path16.join(root, "CLAUDE.md");
2204
2343
  const block = buildImportBlock();
2205
- const prev = await fs10.pathExists(filePath) ? await fs10.readFile(filePath, "utf8") : "";
2344
+ const prev = await fs14.pathExists(filePath) ? await fs14.readFile(filePath, "utf8") : "";
2206
2345
  const next = injectHausBlock(prev, block);
2207
2346
  await writeManagedText(root, filePath, next, dryRun);
2208
2347
  return filePath;
2209
2348
  }
2210
2349
 
2211
2350
  // src/claude/write-workflow-config.ts
2212
- import path13 from "node:path";
2213
- import fs12 from "fs-extra";
2351
+ import path18 from "node:path";
2352
+ import fs16 from "fs-extra";
2214
2353
 
2215
2354
  // src/claude/derive-workflow-config.ts
2216
- import path12 from "node:path";
2217
- import fs11 from "fs-extra";
2355
+ import path17 from "node:path";
2356
+ import fs15 from "fs-extra";
2218
2357
  function binCmd(pm, bin, args) {
2219
2358
  const tail = args ? ` ${args}` : "";
2220
2359
  if (pm === "yarn") return `yarn ${bin}${tail}`;
@@ -2223,7 +2362,7 @@ function binCmd(pm, bin, args) {
2223
2362
  }
2224
2363
  async function deriveWorkflowConfig(root, ctx) {
2225
2364
  const pm = ctx.packageManager === "unknown" ? "npm" : ctx.packageManager;
2226
- const pkg = await readJson(path12.join(root, "package.json"));
2365
+ const pkg = await readJson(path17.join(root, "package.json"));
2227
2366
  const scripts = pkg?.scripts ?? {};
2228
2367
  const deps = new Set(ctx.dependencies);
2229
2368
  const stacks = Object.values(ctx.detectedStacks ?? {}).flat();
@@ -2233,7 +2372,7 @@ async function deriveWorkflowConfig(root, ctx) {
2233
2372
  return null;
2234
2373
  };
2235
2374
  const hasDep = (name) => deps.has(name);
2236
- const exists = (rel) => fs11.pathExistsSync(path12.join(root, rel));
2375
+ const exists = (rel) => fs15.pathExistsSync(path17.join(root, rel));
2237
2376
  const hasPlaywright = hasDep("@playwright/test") || stacks.includes("playwright");
2238
2377
  const hasCypress = hasDep("cypress");
2239
2378
  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 +2440,7 @@ var FALLBACK_CONTEXT = {
2301
2440
  async function writeWorkflowConfig(root, dryRun, opts = {}) {
2302
2441
  const destPath = hausPath(root, "workflow-config.md");
2303
2442
  const printable = displayPath(root, destPath);
2304
- const exists = await fs12.pathExists(destPath);
2443
+ const exists = await fs16.pathExists(destPath);
2305
2444
  if (exists && !opts.refill) {
2306
2445
  if (dryRun) log(printable + ": exists (project-owned, skipping)");
2307
2446
  return null;
@@ -2309,11 +2448,11 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2309
2448
  const ctx = await readJson(hausPath(root, "context-map.json")) ?? {
2310
2449
  ...FALLBACK_CONTEXT,
2311
2450
  root,
2312
- repoName: path13.basename(root)
2451
+ repoName: path18.basename(root)
2313
2452
  };
2314
2453
  const values = await deriveWorkflowConfig(root, ctx);
2315
2454
  if (exists) {
2316
- const current = await fs12.readFile(destPath, "utf8");
2455
+ const current = await fs16.readFile(destPath, "utf8");
2317
2456
  const refilled = refillContent(current, values);
2318
2457
  if (refilled === current) {
2319
2458
  if (dryRun) log(printable + ": no blank fields to refill");
@@ -2335,7 +2474,23 @@ async function writeWorkflowConfig(root, dryRun, opts = {}) {
2335
2474
  }
2336
2475
 
2337
2476
  // src/claude/write-workflow.ts
2338
- import fs13 from "fs-extra";
2477
+ import fs17 from "fs-extra";
2478
+
2479
+ // src/claude/managed-tamper.ts
2480
+ function checkManagedTamper(body, storedHash, templateContent) {
2481
+ const bodyHash = hashText(normaliseLF(body));
2482
+ if (storedHash) {
2483
+ if (bodyHash !== storedHash) return { status: "tampered" };
2484
+ if (templateContent === null) return { status: "ok" };
2485
+ const currentTemplateHash2 = hashText(normaliseLF(templateContent));
2486
+ return storedHash !== currentTemplateHash2 ? { status: "stale" } : { status: "ok" };
2487
+ }
2488
+ if (templateContent === null) return { status: "legacy-ok" };
2489
+ const currentTemplateHash = hashText(normaliseLF(templateContent));
2490
+ return bodyHash === currentTemplateHash ? { status: "legacy-ok" } : { status: "legacy-diverged" };
2491
+ }
2492
+
2493
+ // src/claude/write-workflow.ts
2339
2494
  var STABLE_ID = "template.workflow";
2340
2495
  function makeWorkflowHeader(pkgVersion, contentHash) {
2341
2496
  return `<!-- HAUS-MANAGED id=${STABLE_ID} v=${SCHEMA_VERSION} source=@haus-tech/haus-workflow@${pkgVersion} hash=${contentHash} -->`;
@@ -2354,8 +2509,8 @@ async function writeWorkflow(root, pkgVersion, dryRun, force = false) {
2354
2509
  ${templateContent}`;
2355
2510
  const destPath = hausPath(root, "WORKFLOW.md");
2356
2511
  const printable = displayPath(root, destPath);
2357
- if (await fs13.pathExists(destPath)) {
2358
- const existing = await fs13.readFile(destPath, "utf8");
2512
+ if (await fs17.pathExists(destPath)) {
2513
+ const existing = await fs17.readFile(destPath, "utf8");
2359
2514
  const firstLine = existing.split("\n")[0] ?? "";
2360
2515
  const parsed = parseHausManagedHeader(firstLine);
2361
2516
  if (!parsed) {
@@ -2373,32 +2528,25 @@ ${templateContent}`;
2373
2528
  return null;
2374
2529
  }
2375
2530
  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}
2531
+ const verdict = checkManagedTamper(existingContent, parsed.hash, templateContent);
2532
+ if (verdict.status === "tampered" && !force) {
2533
+ warn(`${printable}: content modified by user \u2014 skipping. Use --force to overwrite.`);
2534
+ return null;
2535
+ }
2536
+ if (verdict.status === "legacy-diverged" && !force) {
2537
+ const migratedHeader = makeWorkflowHeader(pkgVersion, hashText(normaliseLF(existingContent)));
2538
+ const migrated = `${migratedHeader}
2390
2539
  ${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`);
2540
+ if (hasTextChanged(existing, migrated)) {
2541
+ if (dryRun) {
2542
+ log(`${printable}: migrating legacy hash header (body preserved)`);
2543
+ } else {
2544
+ await writeText(destPath, migrated);
2399
2545
  }
2400
- return destPath;
2546
+ } else if (dryRun) {
2547
+ log(`${printable}: unchanged`);
2401
2548
  }
2549
+ return destPath;
2402
2550
  }
2403
2551
  if (!hasTextChanged(existing, next)) {
2404
2552
  if (dryRun) log(`${printable}: unchanged`);
@@ -2406,7 +2554,7 @@ ${existingContent}`;
2406
2554
  }
2407
2555
  }
2408
2556
  if (dryRun) {
2409
- const prev = await fs13.pathExists(destPath) ? await fs13.readFile(destPath, "utf8") : "";
2557
+ const prev = await fs17.pathExists(destPath) ? await fs17.readFile(destPath, "utf8") : "";
2410
2558
  if (!prev) {
2411
2559
  log(createUnifiedDiff(printable, "", next));
2412
2560
  } else {
@@ -2428,6 +2576,19 @@ function targetDirForType(type) {
2428
2576
  if (type === "skill") return "skills";
2429
2577
  return null;
2430
2578
  }
2579
+ async function removeLegacyManagedStub(root, relPathSegments, stub, dryRun, say) {
2580
+ const target = claudePath(root, ...relPathSegments);
2581
+ if (!await fs18.pathExists(target)) return;
2582
+ const content2 = await fs18.readFile(target, "utf8");
2583
+ if (content2 !== stub && content2 !== `${stub}
2584
+ ` && content2 !== `${stub}\r
2585
+ `) return;
2586
+ if (dryRun) {
2587
+ say(`[dry-run] would remove stale ${displayPath(root, target)}`);
2588
+ } else {
2589
+ await fs18.remove(target);
2590
+ }
2591
+ }
2431
2592
  async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2432
2593
  const say = opts.quiet ? () => {
2433
2594
  } : log;
@@ -2441,7 +2602,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2441
2602
  estimatedTokenReductionPct: 0
2442
2603
  };
2443
2604
  const pkgRoot = packageRoot();
2444
- const hausVersion2 = (await readJson(path14.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2605
+ const hausVersion2 = (await readJson(path19.join(pkgRoot, "package.json")))?.version ?? "0.0.0";
2445
2606
  const coreFiles = [claudePath(root, "settings.json"), claudePath(root, "rules", "haus.md")];
2446
2607
  const rootClaudeMdPath = await writeRootClaudeMd(root, dryRun);
2447
2608
  const decisionsSeedPath = await writeDecisionsSeed(root, dryRun);
@@ -2465,34 +2626,20 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2465
2626
  await applyProjectSettingsMerge(root);
2466
2627
  await assertPostApplySettingsHausContract(root);
2467
2628
  }
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
- }
2629
+ await removeLegacyManagedStub(
2630
+ root,
2631
+ ["commands", "haus-review.md"],
2632
+ 'Run `haus context --task "code review"` then review diff.',
2633
+ dryRun,
2634
+ say
2635
+ );
2636
+ await removeLegacyManagedStub(
2637
+ root,
2638
+ ["commands", "haus-doctor.md"],
2639
+ "Run `haus doctor`.",
2640
+ dryRun,
2641
+ say
2642
+ );
2496
2643
  await writeManagedText(
2497
2644
  root,
2498
2645
  claudePath(root, "rules", "haus.md"),
@@ -2516,20 +2663,13 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2516
2663
  ].join("\n"),
2517
2664
  dryRun
2518
2665
  );
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
- }
2666
+ await removeLegacyManagedStub(
2667
+ root,
2668
+ ["rules", "security.md"],
2669
+ "- Never read secrets.\n- Block dangerous shell commands.",
2670
+ dryRun,
2671
+ say
2672
+ );
2533
2673
  const LEGACY_PRUNED_ARTIFACTS = [
2534
2674
  "config.json",
2535
2675
  "selected-context.json",
@@ -2541,19 +2681,68 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2541
2681
  ];
2542
2682
  for (const rel of LEGACY_PRUNED_ARTIFACTS) {
2543
2683
  const legacyPath = hausPath(root, rel);
2544
- if (await fs14.pathExists(legacyPath)) {
2684
+ if (await fs18.pathExists(legacyPath)) {
2545
2685
  if (dryRun) {
2546
2686
  say(`[dry-run] would remove stale ${displayPath(root, legacyPath)}`);
2547
2687
  } else {
2548
- await fs14.remove(legacyPath);
2688
+ await fs18.remove(legacyPath);
2549
2689
  }
2550
2690
  }
2551
2691
  }
2552
2692
  const { items: manifestItems, contentRoot } = await loadCatalogContext(root);
2553
2693
  const manifestById = new Map(manifestItems.map((item) => [item.id, item]));
2694
+ const prevLock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
2695
+ const allMigrations = findFormerIdMigrations(
2696
+ prevLock.filter((entry) => Boolean(entry.id)),
2697
+ manifestItems
2698
+ );
2699
+ const migrations = allMigrations.filter(
2700
+ (migration) => selectedIds === void 0 || selectedIds.includes(migration.oldId) || selectedIds.includes(migration.newId)
2701
+ );
2702
+ const migrationByOldId = new Map(
2703
+ migrations.map((migration) => [migration.oldId, migration.newId])
2704
+ );
2705
+ const cleanupManifestById = new Map(manifestById);
2706
+ for (const migration of allMigrations) {
2707
+ const currentItem = manifestById.get(migration.newId);
2708
+ if (currentItem) cleanupManifestById.set(migration.oldId, currentItem);
2709
+ }
2710
+ for (const migration of migrations) {
2711
+ if (dryRun) {
2712
+ say(`[dry-run] would migrate ${migration.oldId} \u2192 ${migration.newId} (upstream rename)`);
2713
+ } else {
2714
+ warn(`migrated ${migration.oldId} \u2192 ${migration.newId} (upstream rename)`);
2715
+ }
2716
+ }
2717
+ await cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, opts.quiet);
2554
2718
  const installedPathsByItem = /* @__PURE__ */ new Map();
2555
2719
  const installedIds = /* @__PURE__ */ new Set();
2556
- const catalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
2720
+ const selectedCatalogItems = selectedIds !== void 0 ? rec.recommended.filter((r) => selectedIds.includes(r.id)) : rec.recommended;
2721
+ const catalogItemsById = /* @__PURE__ */ new Map();
2722
+ for (const recommended of selectedCatalogItems) {
2723
+ const id = migrationByOldId.get(recommended.id) ?? recommended.id;
2724
+ const manifestItem = manifestById.get(id);
2725
+ catalogItemsById.set(id, {
2726
+ ...recommended,
2727
+ id,
2728
+ type: manifestItem?.type ?? recommended.type
2729
+ });
2730
+ }
2731
+ for (const migration of migrations) {
2732
+ if (catalogItemsById.has(migration.newId)) continue;
2733
+ const lockItem = prevLock.find((entry) => entry.id === migration.oldId);
2734
+ const manifestItem = manifestById.get(migration.newId);
2735
+ if (!lockItem || !manifestItem) continue;
2736
+ catalogItemsById.set(migration.newId, {
2737
+ id: migration.newId,
2738
+ type: manifestItem.type,
2739
+ reason: "migrated from former catalog id",
2740
+ reasons: [{ code: "former-id", message: `Renamed from ${migration.oldId}` }],
2741
+ selectionMode: "manual",
2742
+ install: true
2743
+ });
2744
+ }
2745
+ const catalogItems = [...catalogItemsById.values()];
2557
2746
  let curatedReviewStatusSkips = 0;
2558
2747
  let superpowersSharedInstalled = false;
2559
2748
  for (const item of catalogItems) {
@@ -2587,21 +2776,21 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2587
2776
  );
2588
2777
  continue;
2589
2778
  }
2590
- const destination = claudePath(root, target, path14.basename(sourcePath));
2591
- if (await fs14.pathExists(sourcePath)) {
2779
+ const destination = claudePath(root, target, path19.basename(sourcePath));
2780
+ if (await fs18.pathExists(sourcePath)) {
2592
2781
  if (dryRun) {
2593
- const exists = await fs14.pathExists(destination);
2782
+ const exists = await fs18.pathExists(destination);
2594
2783
  say(
2595
2784
  `${displayPath(root, destination)}: ${exists ? "would overwrite" : "would create"} (${item.id})`
2596
2785
  );
2597
2786
  } else if (item.type === "skill") {
2598
- const skillFiles = (await fs14.readdir(sourcePath, { recursive: true })).filter(
2787
+ const skillFiles = (await fs18.readdir(sourcePath, { recursive: true })).filter(
2599
2788
  (f) => typeof f === "string" && f.endsWith(".md")
2600
2789
  );
2601
2790
  let skillValid = true;
2602
2791
  for (const relFile of skillFiles) {
2603
- const mdPath = path14.join(sourcePath, relFile);
2604
- const mdContent = await fs14.readFile(mdPath, "utf8");
2792
+ const mdPath = path19.join(sourcePath, relFile);
2793
+ const mdContent = await fs18.readFile(mdPath, "utf8");
2605
2794
  const mdValidation = validateCatalogItem(
2606
2795
  {
2607
2796
  id: manifestItem.id,
@@ -2625,7 +2814,7 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2625
2814
  dryRun: false
2626
2815
  });
2627
2816
  } else {
2628
- const fileContent = await fs14.readFile(sourcePath, "utf8");
2817
+ const fileContent = await fs18.readFile(sourcePath, "utf8");
2629
2818
  const validation = validateCatalogItem(
2630
2819
  {
2631
2820
  id: manifestItem.id,
@@ -2639,17 +2828,17 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2639
2828
  warn(`Skipping ${item.id}: pre-copy validation failed \u2014 ${validation.reason}`);
2640
2829
  continue;
2641
2830
  }
2642
- await fs14.ensureDir(path14.dirname(destination));
2643
- await fs14.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2831
+ await fs18.ensureDir(path19.dirname(destination));
2832
+ await fs18.copy(sourcePath, destination, { overwrite: true, errorOnExist: false });
2644
2833
  }
2645
2834
  files.push(destination);
2646
- const relPaths = [path14.relative(root, destination)];
2835
+ const relPaths = [path19.relative(root, destination)];
2647
2836
  if (!superpowersSharedInstalled && manifestItem.originSourceId === SUPERPOWERS_ORIGIN_SOURCE_ID && item.type === "skill") {
2648
2837
  const sharedRel = await installSuperpowersShared(contentRoot, root, dryRun);
2649
2838
  if (sharedRel) {
2650
2839
  superpowersSharedInstalled = true;
2651
2840
  relPaths.push(sharedRel);
2652
- files.push(path14.join(root, sharedRel));
2841
+ files.push(path19.join(root, sharedRel));
2653
2842
  }
2654
2843
  }
2655
2844
  const current = installedPathsByItem.get(item.id) ?? [];
@@ -2666,12 +2855,21 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2666
2855
  `${curatedReviewStatusSkips} curated items skipped: reviewStatus is not approved \u2014 possible catalog field rename upstream`
2667
2856
  );
2668
2857
  }
2669
- await cleanupStaleCatalogItems(root, manifestById, dryRun, opts.quiet);
2858
+ await cleanupStaleCatalogItems(root, cleanupManifestById, dryRun, opts.quiet);
2859
+ if (opts.prune) {
2860
+ const notOrphaned = new Set(rec.recommended.map((r) => r.id));
2861
+ for (const migration of allMigrations) notOrphaned.add(migration.oldId);
2862
+ for (const entry of prevLock) {
2863
+ if (!entry.id) continue;
2864
+ const manifestItem = manifestById.get(entry.id);
2865
+ if (!manifestItem || manifestItem.reviewStatus === "deprecated") notOrphaned.add(entry.id);
2866
+ }
2867
+ await pruneOrphanedCatalogItems(root, prevLock, notOrphaned, dryRun, opts.quiet);
2868
+ }
2670
2869
  if (dryRun) return [...new Set(files)];
2671
2870
  const installedItems = catalogItems.filter((r) => installedIds.has(r.id));
2672
- const prevLock = await readJson(hausPath(root, "haus.lock.json"));
2673
2871
  const prevRefById = new Map(
2674
- (prevLock ?? []).filter((e) => e.id && e.catalogRef).map((e) => [e.id, e.catalogRef])
2872
+ prevLock.filter((e) => e.id && e.catalogRef).map((e) => [migrationByOldId.get(e.id) ?? e.id, e.catalogRef])
2675
2873
  );
2676
2874
  const lockCatalogRef = (itemId) => isCatalogRefResolved() ? getResolvedCatalogRef() : prevRefById.get(itemId) ?? getResolvedCatalogRef();
2677
2875
  const lock = await Promise.all(
@@ -2703,6 +2901,44 @@ async function writeClaudeFiles(root, dryRun, selectedIds, opts = {}) {
2703
2901
  await writeManagedJson(root, hausPath(root, "haus.lock.json"), lock, false);
2704
2902
  return [...new Set(files)];
2705
2903
  }
2904
+ async function cleanupMigratedCatalogItems(root, prevLock, migrations, dryRun, quiet) {
2905
+ const say = quiet ? () => {
2906
+ } : log;
2907
+ for (const migration of migrations) {
2908
+ const entry = prevLock.find((candidate) => candidate.id === migration.oldId);
2909
+ if (!entry) continue;
2910
+ const relPaths = entry.paths ?? [];
2911
+ const existing = [];
2912
+ for (const rel of relPaths) {
2913
+ if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
2914
+ }
2915
+ if (existing.length === 0) continue;
2916
+ if (entry.hash === void 0) {
2917
+ warn(
2918
+ `Former catalog item ${migration.oldId} has no lock hash \u2014 leaving old paths in place: ${existing.join(", ")}`
2919
+ );
2920
+ continue;
2921
+ }
2922
+ const currentHash = await hashInstalledPaths(root, existing);
2923
+ if (currentHash !== entry.hash) {
2924
+ warn(
2925
+ `Former catalog item ${migration.oldId} was modified locally \u2014 leaving old paths in place: ${existing.join(", ")}`
2926
+ );
2927
+ continue;
2928
+ }
2929
+ for (const rel of existing) {
2930
+ const abs = path19.join(root, rel);
2931
+ if (dryRun) {
2932
+ say(
2933
+ `[dry-run] would remove renamed ${displayPath(root, abs)} (${migration.oldId} \u2192 ${migration.newId})`
2934
+ );
2935
+ continue;
2936
+ }
2937
+ await fs18.remove(abs);
2938
+ await pruneEmptyDir(path19.dirname(abs));
2939
+ }
2940
+ }
2941
+ }
2706
2942
  async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2707
2943
  const say = quiet ? () => {
2708
2944
  } : log;
@@ -2718,7 +2954,7 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2718
2954
  if (relPaths.length === 0) continue;
2719
2955
  const existing = [];
2720
2956
  for (const rel of relPaths) {
2721
- if (await fs14.pathExists(path14.join(root, rel))) existing.push(rel);
2957
+ if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
2722
2958
  }
2723
2959
  if (existing.length === 0) continue;
2724
2960
  const pruneReason = deprecated ? "deprecated" : "stale";
@@ -2736,45 +2972,111 @@ async function cleanupStaleCatalogItems(root, manifestById, dryRun, quiet) {
2736
2972
  continue;
2737
2973
  }
2738
2974
  for (const rel of existing) {
2739
- const abs = path14.join(root, rel);
2975
+ const abs = path19.join(root, rel);
2740
2976
  if (dryRun) {
2741
2977
  say(`[dry-run] would remove ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2742
2978
  continue;
2743
2979
  }
2744
- await fs14.remove(abs);
2745
- await pruneEmptyDir(path14.dirname(abs));
2980
+ await fs18.remove(abs);
2981
+ await pruneEmptyDir(path19.dirname(abs));
2746
2982
  say(`Removed ${pruneReason} ${displayPath(root, abs)} (${entry.id})`);
2747
2983
  }
2748
2984
  }
2749
2985
  }
2986
+ async function backupBeforePrune(root, absPaths, say) {
2987
+ if (absPaths.length === 0) return;
2988
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
2989
+ const backupRoot = hausPath(root, "backups", `prune-${stamp}`);
2990
+ for (const abs of absPaths) {
2991
+ if (!await fs18.pathExists(abs)) continue;
2992
+ const rel = path19.relative(root, abs);
2993
+ const backupPath = path19.join(backupRoot, rel);
2994
+ await fs18.ensureDir(path19.dirname(backupPath));
2995
+ await fs18.copy(abs, backupPath);
2996
+ }
2997
+ say(`Backed up ${absPaths.length} path(s) to ${path19.relative(root, backupRoot)} before pruning.`);
2998
+ }
2999
+ async function pruneOrphanedCatalogItems(root, prevLock, notOrphanedIds, dryRun, quiet) {
3000
+ const say = quiet ? () => {
3001
+ } : log;
3002
+ const orphaned = findOrphanedLockEntries(prevLock, notOrphanedIds);
3003
+ if (orphaned.length === 0) return;
3004
+ const toRemove = [];
3005
+ for (const entry of orphaned) {
3006
+ if (!entry.id) continue;
3007
+ const relPaths = entry.paths ?? [];
3008
+ if (relPaths.length === 0) continue;
3009
+ const existing = [];
3010
+ for (const rel of relPaths) {
3011
+ if (await fs18.pathExists(path19.join(root, rel))) existing.push(rel);
3012
+ }
3013
+ if (existing.length === 0) continue;
3014
+ if (entry.hash === void 0) {
3015
+ warn(
3016
+ `Orphaned catalog item ${entry.id} has no lock hash \u2014 leaving in place: ${existing.join(", ")}`
3017
+ );
3018
+ continue;
3019
+ }
3020
+ const currentHash = await hashInstalledPaths(root, relPaths);
3021
+ if (currentHash !== entry.hash) {
3022
+ warn(
3023
+ `Orphaned catalog item ${entry.id} was modified locally \u2014 leaving in place: ${existing.join(", ")}`
3024
+ );
3025
+ continue;
3026
+ }
3027
+ toRemove.push({ id: entry.id, existing });
3028
+ }
3029
+ if (toRemove.length === 0) return;
3030
+ if (dryRun) {
3031
+ for (const { id, existing } of toRemove) {
3032
+ for (const rel of existing) {
3033
+ say(`[dry-run] would prune orphaned ${displayPath(root, path19.join(root, rel))} (${id})`);
3034
+ }
3035
+ }
3036
+ return;
3037
+ }
3038
+ const allAbsPaths = toRemove.flatMap(
3039
+ ({ existing }) => existing.map((rel) => path19.join(root, rel))
3040
+ );
3041
+ await backupBeforePrune(root, allAbsPaths, say);
3042
+ for (const { id, existing } of toRemove) {
3043
+ for (const rel of existing) {
3044
+ const abs = path19.join(root, rel);
3045
+ await fs18.remove(abs);
3046
+ await pruneEmptyDir(path19.dirname(abs));
3047
+ say(`Pruned orphaned ${displayPath(root, abs)} (${id})`);
3048
+ }
3049
+ }
3050
+ }
2750
3051
 
2751
3052
  // src/refs/fetch-refs.ts
2752
- import path16 from "node:path";
2753
- import fs16 from "fs-extra";
3053
+ import path21 from "node:path";
3054
+ import fs20 from "fs-extra";
2754
3055
 
2755
3056
  // src/refs/cache-meta.ts
2756
- import path15 from "node:path";
2757
- import fs15 from "fs-extra";
3057
+ import path20 from "node:path";
3058
+ import fs19 from "fs-extra";
2758
3059
  var META_FILENAME = "cache-meta.json";
2759
3060
  async function readCacheMeta(cacheDir) {
2760
- const metaPath = path15.join(cacheDir, META_FILENAME);
3061
+ const metaPath = path20.join(cacheDir, META_FILENAME);
2761
3062
  try {
2762
- const raw = await fs15.readFile(metaPath, "utf8");
3063
+ const raw = await fs19.readFile(metaPath, "utf8");
2763
3064
  return JSON.parse(raw);
2764
3065
  } catch {
2765
3066
  return {};
2766
3067
  }
2767
3068
  }
2768
3069
  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");
3070
+ await fs19.ensureDir(cacheDir);
3071
+ const metaPath = path20.join(cacheDir, META_FILENAME);
3072
+ await fs19.writeFile(metaPath, JSON.stringify(meta, null, 2) + "\n", "utf8");
2772
3073
  }
2773
3074
 
2774
3075
  // src/refs/fetch-refs.ts
2775
3076
  function urlToSlug(url) {
2776
3077
  const u = new URL(url);
2777
- return (u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
3078
+ const protocolPrefix = u.protocol === "https:" ? "" : `${u.protocol.replace(":", "")}-`;
3079
+ return (protocolPrefix + u.host + u.pathname).replace(/[^a-zA-Z0-9]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
2778
3080
  }
2779
3081
  function isLlmsTxtUrl(ref) {
2780
3082
  try {
@@ -2787,8 +3089,8 @@ function isLlmsTxtUrl(ref) {
2787
3089
  async function fetchSingleRef(url, cacheDir, meta) {
2788
3090
  const existing = meta[url];
2789
3091
  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));
3092
+ const safeFile = existing?.file && existing.file === path21.basename(existing.file);
3093
+ const cachedFileExists = safeFile && await fs20.pathExists(path21.join(cacheDir, existing.file));
2792
3094
  if (cachedFileExists) {
2793
3095
  if (existing?.etag) headers["If-None-Match"] = existing.etag;
2794
3096
  if (existing?.lastModified) headers["If-Modified-Since"] = existing.lastModified;
@@ -2799,8 +3101,8 @@ async function fetchSingleRef(url, cacheDir, meta) {
2799
3101
  if (!res.ok) return { result: "failed" };
2800
3102
  const text = await res.text();
2801
3103
  const file = `${urlToSlug(url)}.md`;
2802
- await fs16.ensureDir(cacheDir);
2803
- await fs16.writeFile(path16.join(cacheDir, file), text, "utf8");
3104
+ await fs20.ensureDir(cacheDir);
3105
+ await fs20.writeFile(path21.join(cacheDir, file), text, "utf8");
2804
3106
  meta[url] = {
2805
3107
  url,
2806
3108
  etag: res.headers.get("etag") ?? void 0,
@@ -2823,8 +3125,8 @@ async function pruneOrphanedRefs(cacheDir, keepUrls) {
2823
3125
  if (keepUrls.has(url)) continue;
2824
3126
  delete meta[url];
2825
3127
  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(() => {
3128
+ const safeFile = typeof entry.file === "string" && entry.file === path21.basename(entry.file);
3129
+ if (safeFile) await fs20.remove(path21.join(cacheDir, entry.file)).catch(() => {
2828
3130
  });
2829
3131
  }
2830
3132
  if (removed > 0) await writeCacheMeta(cacheDir, meta);
@@ -2846,10 +3148,10 @@ async function fetchRefsForItems(items, cacheDir) {
2846
3148
  const outcome = await fetchSingleRef(url, cacheDir, meta);
2847
3149
  if (outcome.result === "fetched") {
2848
3150
  summary.fetched++;
2849
- summary.cachedFiles[url] = path16.join(cacheDir, outcome.file);
3151
+ summary.cachedFiles[url] = path21.join(cacheDir, outcome.file);
2850
3152
  } else if (outcome.result === "unchanged") {
2851
3153
  summary.unchanged++;
2852
- if (meta[url]?.file) summary.cachedFiles[url] = path16.join(cacheDir, meta[url].file);
3154
+ if (meta[url]?.file) summary.cachedFiles[url] = path21.join(cacheDir, meta[url].file);
2853
3155
  } else {
2854
3156
  summary.failed++;
2855
3157
  summary.failedUrls.push(url);
@@ -2870,8 +3172,9 @@ function parseIdList(value) {
2870
3172
  }
2871
3173
 
2872
3174
  // src/commands/apply.ts
3175
+ var STALE_CACHE_THRESHOLD_DAYS = 7;
2873
3176
  async function cacheHasItems() {
2874
- const data = await readJson(path17.join(getCacheDir(), "manifest.json"));
3177
+ const data = await readJson(path22.join(getCacheDir(), "manifest.json"));
2875
3178
  return Array.isArray(data?.items) && data.items.length > 0;
2876
3179
  }
2877
3180
  function installableRecommendedItems(recommended) {
@@ -2944,56 +3247,428 @@ async function runApply(options) {
2944
3247
  }
2945
3248
  }
2946
3249
  }
2947
- if (!options.allowEmptyCache && !process.env["HAUS_FIXTURE_CATALOG"]) {
2948
- const rec = await readJson(hausPath(root, "recommendation.json"));
2949
- const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
2950
- 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;
2959
- }
3250
+ if (!process.env["HAUS_FIXTURE_CATALOG"]) {
3251
+ const rec = await readJson(hausPath(root, "recommendation.json"));
3252
+ const installableRecommended = installableRecommendedItems(rec?.recommended ?? []);
3253
+ const catalogItemCount = selectedIds !== void 0 ? selectedIds.length : installableRecommended.length;
3254
+ if (catalogItemCount > 0) {
3255
+ const hasItems = await cacheHasItems();
3256
+ if (!hasItems && !options.allowEmptyCache) {
3257
+ const message = "No catalog content found. Run `haus update` first.";
3258
+ if (isDryRun) {
3259
+ warn(`Catalog cache is empty \u2014 dry-run will skip catalog items. ${message}`);
3260
+ } else {
3261
+ error(message);
3262
+ process.exitCode = 1;
3263
+ return;
3264
+ }
3265
+ } else if (hasItems) {
3266
+ const cacheAgeMs = await getCacheManifestAge();
3267
+ if (cacheAgeMs !== null) {
3268
+ const cacheAgeDays = Math.floor(cacheAgeMs / (1e3 * 60 * 60 * 24));
3269
+ if (cacheAgeDays >= STALE_CACHE_THRESHOLD_DAYS) {
3270
+ warn(
3271
+ `Catalog cache is ${cacheAgeDays} days old \u2014 run \`haus update\` for the latest content.`
3272
+ );
3273
+ }
3274
+ }
3275
+ }
3276
+ }
3277
+ }
3278
+ const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
3279
+ refillConfig: options.refillConfig,
3280
+ force: options.force,
3281
+ prune: options.prune
3282
+ });
3283
+ if (!isDryRun) {
3284
+ try {
3285
+ const cacheDir = hausPath(root, "llms-cache");
3286
+ const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
3287
+ const installedIds = new Set(lock.map((entry) => entry.id));
3288
+ const allItems = await loadCatalog(root);
3289
+ const installedItems = allItems.filter((item) => installedIds.has(item.id));
3290
+ const summary = await fetchRefsForItems(installedItems, cacheDir);
3291
+ if (summary.fetched > 0)
3292
+ log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
3293
+ if (summary.failed > 0)
3294
+ warn(
3295
+ `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
3296
+ );
3297
+ const pruned = await pruneOrphanedRefs(cacheDir, new Set(collectLlmsTxtUrls(installedItems)));
3298
+ if (pruned > 0)
3299
+ log(
3300
+ `Removed ${pruned} orphaned llms.txt cache entr${pruned === 1 ? "y" : "ies"} from ${cacheDir}`
3301
+ );
3302
+ } catch (err) {
3303
+ warn(
3304
+ `Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
3305
+ );
3306
+ }
3307
+ }
3308
+ if (isDryRun) {
3309
+ log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
3310
+ } else {
3311
+ log("Applied files:");
3312
+ files.forEach((f) => log(`- ${displayPath(root, f)}`));
3313
+ }
3314
+ }
3315
+
3316
+ // src/commands/backups.ts
3317
+ import path23 from "node:path";
3318
+ import fs21 from "fs-extra";
3319
+
3320
+ // src/utils/prompts.ts
3321
+ import { stdin as input, stdout as output } from "node:process";
3322
+ import readline from "node:readline/promises";
3323
+ async function ask(question) {
3324
+ const rl = readline.createInterface({ input, output });
3325
+ try {
3326
+ const answer = await rl.question(`${question}
3327
+ > `);
3328
+ return answer.trim();
3329
+ } finally {
3330
+ rl.close();
3331
+ }
3332
+ }
3333
+ async function confirm(question) {
3334
+ const answer = (await ask(`${question} [y/N]`)).toLowerCase();
3335
+ return answer === "y" || answer === "yes";
3336
+ }
3337
+
3338
+ // src/commands/backups.ts
3339
+ var LOCK_BACKUP_RE = /^haus\.lock\.\d+\.json$/;
3340
+ function classify(name) {
3341
+ if (LOCK_BACKUP_RE.test(name)) return "lock";
3342
+ if (name.startsWith("undo-")) return "undo";
3343
+ if (name.startsWith("prune-")) return "prune";
3344
+ return void 0;
3345
+ }
3346
+ async function listBackups(root) {
3347
+ const dir = hausPath(root, "backups");
3348
+ if (!await fs21.pathExists(dir)) return [];
3349
+ const names = await fs21.readdir(dir);
3350
+ const entries = [];
3351
+ for (const name of names) {
3352
+ const kind = classify(name);
3353
+ if (!kind) continue;
3354
+ const absPath = path23.join(dir, name);
3355
+ const stat = await fs21.lstat(absPath);
3356
+ if (stat.isSymbolicLink()) continue;
3357
+ const expectDirectory = kind !== "lock";
3358
+ if (stat.isDirectory() !== expectDirectory) continue;
3359
+ entries.push({ id: name, kind, absPath, mtimeMs: stat.mtimeMs });
3360
+ }
3361
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
3362
+ return entries;
3363
+ }
3364
+ function formatAge(mtimeMs, now) {
3365
+ const minutes = Math.round(Math.max(0, now - mtimeMs) / 6e4);
3366
+ if (minutes < 60) return `${minutes}m ago`;
3367
+ const hours = Math.round(minutes / 60);
3368
+ if (hours < 48) return `${hours}h ago`;
3369
+ return `${Math.round(hours / 24)}d ago`;
3370
+ }
3371
+ async function runList(root) {
3372
+ const entries = await listBackups(root);
3373
+ if (entries.length === 0) {
3374
+ log("No backups found under .haus-workflow/backups/.");
3375
+ return;
3376
+ }
3377
+ const now = Date.now();
3378
+ const newestFirst = [...entries].reverse();
3379
+ for (const entry of newestFirst) {
3380
+ log(`${entry.id} [${entry.kind}] ${formatAge(entry.mtimeMs, now)}`);
3381
+ }
3382
+ }
3383
+ async function isUnsafeWriteTarget(root, target) {
3384
+ const segments = path23.relative(root, target).split(path23.sep).filter(Boolean);
3385
+ let current = root;
3386
+ for (let i = 0; i < segments.length; i++) {
3387
+ current = path23.join(current, segments[i]);
3388
+ const isLastSegment = i === segments.length - 1;
3389
+ try {
3390
+ const stat = await fs21.lstat(current);
3391
+ if (isLastSegment) {
3392
+ if (!stat.isFile()) return true;
3393
+ } else if (!stat.isDirectory()) {
3394
+ return true;
3395
+ }
3396
+ } catch (err) {
3397
+ if (err.code === "ENOENT") return false;
3398
+ throw err;
3399
+ }
3400
+ }
3401
+ return false;
3402
+ }
3403
+ async function lstatIfRegularFile(abs) {
3404
+ try {
3405
+ const stat = await fs21.lstat(abs);
3406
+ return stat.isFile() ? stat : null;
3407
+ } catch (err) {
3408
+ if (err.code === "ENOENT") return null;
3409
+ throw err;
3410
+ }
3411
+ }
3412
+ async function restoreLockBackup(root, entry, yes) {
3413
+ const target = hausPath(root, "haus.lock.json");
3414
+ if (await isUnsafeWriteTarget(root, target)) {
3415
+ error(
3416
+ `Refusing to restore: ${target} (or a parent directory) is a symlink or other non-regular file \u2014 never written through.`
3417
+ );
3418
+ return false;
3419
+ }
3420
+ if (await fs21.pathExists(target)) {
3421
+ const targetStat = await fs21.stat(target);
3422
+ if (targetStat.mtimeMs > entry.mtimeMs) {
3423
+ warn(
3424
+ `Current haus.lock.json (${new Date(targetStat.mtimeMs).toISOString()}) is newer than backup ${entry.id} (${new Date(entry.mtimeMs).toISOString()}) \u2014 it will be overwritten.`
3425
+ );
3426
+ }
3427
+ }
3428
+ if (!yes) {
3429
+ const ok = await confirm(
3430
+ `Restore .haus-workflow/haus.lock.json from backup ${entry.id}? This overwrites the current lockfile.`
3431
+ );
3432
+ if (!ok) {
3433
+ log("Cancelled.");
3434
+ return false;
3435
+ }
3436
+ }
3437
+ if (!await lstatIfRegularFile(entry.absPath) || await isUnsafeWriteTarget(root, target)) {
3438
+ error(`Refusing to restore: ${entry.id} or ${target} changed since being checked.`);
3439
+ return false;
3440
+ }
3441
+ await fs21.copy(entry.absPath, target, { overwrite: true });
3442
+ log(`Restored .haus-workflow/haus.lock.json from ${entry.id}.`);
3443
+ return true;
3444
+ }
3445
+ async function collectFilesRecursive(dir) {
3446
+ const files = [];
3447
+ const skippedLinks = [];
3448
+ const skippedOther = [];
3449
+ const names = await fs21.readdir(dir);
3450
+ for (const name of names) {
3451
+ const abs = path23.join(dir, name);
3452
+ const stat = await fs21.lstat(abs);
3453
+ if (stat.isSymbolicLink()) {
3454
+ skippedLinks.push(abs);
3455
+ continue;
3456
+ }
3457
+ if (stat.isDirectory()) {
3458
+ const nested = await collectFilesRecursive(abs);
3459
+ files.push(...nested.files);
3460
+ skippedLinks.push(...nested.skippedLinks);
3461
+ skippedOther.push(...nested.skippedOther);
3462
+ } else if (stat.isFile()) {
3463
+ files.push(abs);
3464
+ } else {
3465
+ skippedOther.push(abs);
3466
+ }
3467
+ }
3468
+ return { files, skippedLinks, skippedOther };
3469
+ }
3470
+ async function restoreDirBackup(root, entry, yes) {
3471
+ const { files, skippedLinks, skippedOther } = await collectFilesRecursive(entry.absPath);
3472
+ if (skippedLinks.length > 0) {
3473
+ warn(
3474
+ `Skipped ${skippedLinks.length} symlink(s) inside backup ${entry.id} \u2014 never followed: ${skippedLinks.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
3475
+ );
3476
+ }
3477
+ if (skippedOther.length > 0) {
3478
+ warn(
3479
+ `Skipped ${skippedOther.length} non-regular file(s) inside backup ${entry.id} (not restorable): ${skippedOther.map((abs) => path23.relative(entry.absPath, abs)).join(", ")}`
3480
+ );
3481
+ }
3482
+ if (files.length === 0) {
3483
+ log(`Backup ${entry.id} contains no files.`);
3484
+ return false;
3485
+ }
3486
+ const safeFiles = [];
3487
+ const unsafeTargets = [];
3488
+ for (const abs of files) {
3489
+ const rel = path23.relative(entry.absPath, abs);
3490
+ const target = path23.join(root, rel);
3491
+ if (await isUnsafeWriteTarget(root, target)) unsafeTargets.push(rel);
3492
+ else safeFiles.push(abs);
3493
+ }
3494
+ if (unsafeTargets.length > 0) {
3495
+ warn(
3496
+ `Skipped ${unsafeTargets.length} restore target(s) that are a symlink (or under one) rather than a plain path \u2014 never written through: ${unsafeTargets.join(", ")}`
3497
+ );
3498
+ }
3499
+ if (safeFiles.length === 0) {
3500
+ log(`Backup ${entry.id} has no restorable targets.`);
3501
+ return false;
3502
+ }
3503
+ const stale = [];
3504
+ const vanished = [];
3505
+ const toRestore = [];
3506
+ for (const abs of safeFiles) {
3507
+ const rel = path23.relative(entry.absPath, abs);
3508
+ const backupFileStat = await lstatIfRegularFile(abs);
3509
+ if (!backupFileStat) {
3510
+ vanished.push(rel);
3511
+ continue;
3512
+ }
3513
+ toRestore.push(abs);
3514
+ const target = path23.join(root, rel);
3515
+ if (await fs21.pathExists(target)) {
3516
+ const targetStat = await fs21.stat(target);
3517
+ if (targetStat.mtimeMs > backupFileStat.mtimeMs) {
3518
+ stale.push({
3519
+ rel,
3520
+ targetMtimeMs: targetStat.mtimeMs,
3521
+ backupMtimeMs: backupFileStat.mtimeMs
3522
+ });
3523
+ }
3524
+ }
3525
+ }
3526
+ if (vanished.length > 0) {
3527
+ warn(
3528
+ `Skipped ${vanished.length} backup file(s) that changed (no longer a plain file) since being listed: ${vanished.join(", ")}`
3529
+ );
3530
+ }
3531
+ if (toRestore.length === 0) {
3532
+ log(`Backup ${entry.id} has no restorable files left after re-validation.`);
3533
+ return false;
3534
+ }
3535
+ if (stale.length > 0) {
3536
+ const detail = stale.map(
3537
+ (s) => `${s.rel} (destination: ${new Date(s.targetMtimeMs).toISOString()}, backup: ${new Date(s.backupMtimeMs).toISOString()})`
3538
+ ).join("; ");
3539
+ warn(
3540
+ `${stale.length} file(s) at the restore destination are newer than backup ${entry.id} and will be overwritten: ${detail}`
3541
+ );
3542
+ }
3543
+ if (!yes) {
3544
+ const ok = await confirm(
3545
+ `Restore ${toRestore.length} file(s) from backup ${entry.id} to their original paths?`
3546
+ );
3547
+ if (!ok) {
3548
+ log("Cancelled.");
3549
+ return false;
3550
+ }
3551
+ }
3552
+ let restoredCount = 0;
3553
+ const skippedAtWrite = [];
3554
+ for (const abs of toRestore) {
3555
+ const rel = path23.relative(entry.absPath, abs);
3556
+ const target = path23.join(root, rel);
3557
+ if (!await lstatIfRegularFile(abs) || await isUnsafeWriteTarget(root, target)) {
3558
+ skippedAtWrite.push(rel);
3559
+ continue;
3560
+ }
3561
+ await fs21.ensureDir(path23.dirname(target));
3562
+ await fs21.copy(abs, target, { overwrite: true });
3563
+ restoredCount++;
3564
+ }
3565
+ if (skippedAtWrite.length > 0) {
3566
+ warn(
3567
+ `Skipped ${skippedAtWrite.length} file(s) that changed since being checked, right before restoring: ${skippedAtWrite.join(", ")}`
3568
+ );
3569
+ }
3570
+ log(`Restored ${restoredCount} file(s) from ${entry.id}.`);
3571
+ return restoredCount > 0;
3572
+ }
3573
+ async function runRestore(root, id, yes) {
3574
+ const entries = await listBackups(root);
3575
+ const entry = entries.find((e) => e.id === id);
3576
+ if (!entry) {
3577
+ error(`No backup found with id "${id}". Run \`haus backups list\` to see available backups.`);
3578
+ return false;
3579
+ }
3580
+ return entry.kind === "lock" ? restoreLockBackup(root, entry, yes) : restoreDirBackup(root, entry, yes);
3581
+ }
3582
+ async function runPrune(root, opts) {
3583
+ if (opts.olderThanDays === void 0 && opts.keep === void 0) {
3584
+ error("Specify --older-than <days> or --keep <n> \u2014 refusing to prune with no bound.");
3585
+ return false;
3586
+ }
3587
+ const entries = await listBackups(root);
3588
+ const now = Date.now();
3589
+ const toRemove = /* @__PURE__ */ new Set();
3590
+ if (opts.olderThanDays !== void 0) {
3591
+ const cutoff = now - opts.olderThanDays * 24 * 60 * 60 * 1e3;
3592
+ for (const entry of entries) if (entry.mtimeMs < cutoff) toRemove.add(entry.id);
3593
+ }
3594
+ if (opts.keep !== void 0) {
3595
+ const excess = entries.length - opts.keep;
3596
+ if (excess > 0) for (const entry of entries.slice(0, excess)) toRemove.add(entry.id);
3597
+ }
3598
+ if (toRemove.size === 0) {
3599
+ log("Nothing to prune.");
3600
+ return true;
3601
+ }
3602
+ const removeList = entries.filter((e) => toRemove.has(e.id));
3603
+ const keepCount = entries.length - removeList.length;
3604
+ if (!opts.yes) {
3605
+ const ok = await confirm(
3606
+ `Remove ${removeList.length} backup(s)?
3607
+ ${removeList.map((e) => e.id).join("\n ")}
3608
+ Keeping ${keepCount}.`
3609
+ );
3610
+ if (!ok) {
3611
+ log("Cancelled.");
3612
+ return false;
2960
3613
  }
2961
3614
  }
2962
- const files = await writeClaudeFiles(root, isDryRun, selectedIds, {
2963
- refillConfig: options.refillConfig,
2964
- force: options.force
2965
- });
2966
- if (!isDryRun) {
3615
+ const removed = [];
3616
+ const failed = [];
3617
+ for (const entry of removeList) {
2967
3618
  try {
2968
- const cacheDir = hausPath(root, "llms-cache");
2969
- const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
2970
- const installedIds = new Set(lock.map((entry) => entry.id));
2971
- const allItems = await loadCatalog(root);
2972
- const installedItems = allItems.filter((item) => installedIds.has(item.id));
2973
- const summary = await fetchRefsForItems(installedItems, cacheDir);
2974
- if (summary.fetched > 0)
2975
- log(`Fetched ${summary.fetched} llms.txt reference(s) to ${cacheDir}`);
2976
- if (summary.failed > 0)
2977
- warn(
2978
- `Failed to fetch ${summary.failed} llms.txt reference(s): ${summary.failedUrls.join(", ")}`
2979
- );
2980
- const pruned = await pruneOrphanedRefs(cacheDir, new Set(collectLlmsTxtUrls(installedItems)));
2981
- if (pruned > 0)
2982
- log(
2983
- `Removed ${pruned} orphaned llms.txt cache entr${pruned === 1 ? "y" : "ies"} from ${cacheDir}`
2984
- );
3619
+ await fs21.remove(entry.absPath);
3620
+ removed.push(entry.id);
2985
3621
  } catch (err) {
2986
- warn(
2987
- `Could not fetch llms.txt references: ${err instanceof Error ? err.message : String(err)}`
3622
+ failed.push({ id: entry.id, message: err instanceof Error ? err.message : String(err) });
3623
+ }
3624
+ }
3625
+ log(`Removed ${removed.length} backup(s), kept ${entries.length - removed.length}.`);
3626
+ if (failed.length > 0) {
3627
+ warn(
3628
+ `Failed to remove ${failed.length} backup(s): ${failed.map((f) => `${f.id} (${f.message})`).join(", ")}`
3629
+ );
3630
+ return false;
3631
+ }
3632
+ return true;
3633
+ }
3634
+ async function runBackups(action, opts = {}) {
3635
+ const root = opts.root ?? process.cwd();
3636
+ if (action === "list") {
3637
+ await runList(root);
3638
+ return;
3639
+ }
3640
+ if (action === "restore") {
3641
+ if (!opts.id) {
3642
+ error("Usage: haus backups restore <id>");
3643
+ process.exitCode = 1;
3644
+ return;
3645
+ }
3646
+ if (!await runRestore(root, opts.id, Boolean(opts.yes))) process.exitCode = 1;
3647
+ return;
3648
+ }
3649
+ let olderThanDays;
3650
+ if (opts.olderThan !== void 0) {
3651
+ const n = Number(opts.olderThan);
3652
+ if (!Number.isFinite(n) || n < 0) {
3653
+ error(
3654
+ `Invalid --older-than value "${opts.olderThan}" \u2014 expected a non-negative number of days.`
2988
3655
  );
3656
+ process.exitCode = 1;
3657
+ return;
2989
3658
  }
3659
+ olderThanDays = n;
2990
3660
  }
2991
- if (isDryRun) {
2992
- log(`Dry-run complete \u2014 ${files.length} file(s) planned, none written. Run --write to apply.`);
2993
- } else {
2994
- log("Applied files:");
2995
- files.forEach((f) => log(`- ${displayPath(root, f)}`));
3661
+ let keep;
3662
+ if (opts.keep !== void 0) {
3663
+ const n = Number(opts.keep);
3664
+ if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {
3665
+ error(`Invalid --keep value "${opts.keep}" \u2014 expected a non-negative integer.`);
3666
+ process.exitCode = 1;
3667
+ return;
3668
+ }
3669
+ keep = n;
2996
3670
  }
3671
+ if (!await runPrune(root, { olderThanDays, keep, yes: Boolean(opts.yes) })) process.exitCode = 1;
2997
3672
  }
2998
3673
 
2999
3674
  // src/commands/catalog-audit.ts
@@ -3024,7 +3699,7 @@ async function runCatalogAudit() {
3024
3699
  // src/commands/clone.ts
3025
3700
  init_exec();
3026
3701
  import { existsSync as existsSync2 } from "node:fs";
3027
- import path18 from "node:path";
3702
+ import path24 from "node:path";
3028
3703
  var GIT_LOCATION_VARS = [
3029
3704
  "GIT_DIR",
3030
3705
  "GIT_WORK_TREE",
@@ -3044,15 +3719,50 @@ function repoNameFromUrl(url) {
3044
3719
  const tail = trimmed.split(/[/:]/).pop() ?? "";
3045
3720
  return tail || "repo";
3046
3721
  }
3722
+ function normalizeGitUrl(url) {
3723
+ let s = url.trim();
3724
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
3725
+ s = s.replace(/^[^/@]+@/, "");
3726
+ s = s.replace(/:(?=[^/])/, "/");
3727
+ s = s.replace(/\/+$/, "");
3728
+ s = s.replace(/\.git$/i, "");
3729
+ return s.toLowerCase();
3730
+ }
3731
+ async function checkExistingTarget(target, url) {
3732
+ const isRepo = await runGit(["-C", target, "rev-parse", "--is-inside-work-tree"], {
3733
+ env: cloneEnv(),
3734
+ extendEnv: false
3735
+ });
3736
+ if (isRepo.exitCode !== 0) return { kind: "not-a-repo" };
3737
+ const origin = await runGit(["-C", target, "remote", "get-url", "origin"], {
3738
+ env: cloneEnv(),
3739
+ extendEnv: false
3740
+ });
3741
+ if (origin.exitCode !== 0) return { kind: "different-repo", origin: null };
3742
+ const originUrl = origin.stdout.trim();
3743
+ if (normalizeGitUrl(originUrl) === normalizeGitUrl(url)) return { kind: "matches" };
3744
+ return { kind: "different-repo", origin: originUrl };
3745
+ }
3047
3746
  async function runClone(url, opts = {}) {
3048
3747
  if (!url || !url.trim()) {
3049
3748
  error("A git URL is required: `haus clone <url> [dir]`.");
3050
3749
  process.exitCode = 1;
3051
3750
  return;
3052
3751
  }
3053
- const target = path18.resolve(opts.dir?.trim() || repoNameFromUrl(url));
3752
+ const target = path24.resolve(opts.dir?.trim() || repoNameFromUrl(url));
3054
3753
  if (existsSync2(target)) {
3055
- log(`\u2022 ${path18.basename(target)} already present at ${target} \u2014 skipped`);
3754
+ const check = await checkExistingTarget(target, url);
3755
+ if (check.kind === "matches") {
3756
+ log(`\u2022 ${path24.basename(target)} already cloned here, matches ${url} \u2014 skipped`);
3757
+ return;
3758
+ }
3759
+ const message = check.kind === "not-a-repo" ? `${target} already exists and is not a git repository \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].` : `${target} already exists as a different repository (origin: ${check.origin ?? "(no origin remote)"}) \u2014 refusing to clone ${url} there. Remove or rename it, or pass a different [dir].`;
3760
+ if (opts.dryRun) {
3761
+ warn(`[dry-run] ${message}`);
3762
+ return;
3763
+ }
3764
+ error(message);
3765
+ process.exitCode = 1;
3056
3766
  return;
3057
3767
  }
3058
3768
  if (opts.dryRun) {
@@ -3123,8 +3833,8 @@ async function resolveBaseRef(root) {
3123
3833
  }
3124
3834
 
3125
3835
  // src/decisions/check.ts
3126
- import path21 from "node:path";
3127
- import fs19 from "fs-extra";
3836
+ import path27 from "node:path";
3837
+ import fs24 from "fs-extra";
3128
3838
 
3129
3839
  // src/decisions/diff.ts
3130
3840
  init_exec();
@@ -3179,22 +3889,22 @@ function matchesAnyRegex(filePath, patterns) {
3179
3889
  }
3180
3890
 
3181
3891
  // src/decisions/paths.ts
3182
- import path19 from "node:path";
3183
- import fs17 from "fs-extra";
3892
+ import path25 from "node:path";
3893
+ import fs22 from "fs-extra";
3184
3894
  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;
3895
+ const primary = path25.join(root, DECISIONS_TRIGGERS.decisionsDir);
3896
+ if (await fs22.pathExists(primary)) return primary;
3897
+ const legacy = path25.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
3898
+ if (await fs22.pathExists(legacy)) return legacy;
3189
3899
  return primary;
3190
3900
  }
3191
3901
  function relativeDecisionPath(decisionsDir, root) {
3192
- return path19.relative(root, decisionsDir).replace(/\\/g, "/");
3902
+ return path25.relative(root, decisionsDir).replace(/\\/g, "/");
3193
3903
  }
3194
3904
 
3195
3905
  // src/decisions/validate.ts
3196
- import path20 from "node:path";
3197
- import fs18 from "fs-extra";
3906
+ import path26 from "node:path";
3907
+ import fs23 from "fs-extra";
3198
3908
  function validateDecisionContent(content2) {
3199
3909
  const errors = [];
3200
3910
  for (const section of DECISIONS_TRIGGERS.requiredSections) {
@@ -3223,14 +3933,14 @@ function decisionNumberFromFile(name) {
3223
3933
  return match?.[1] ?? null;
3224
3934
  }
3225
3935
  async function validateIndexSync(decisionsDir, decisionFiles) {
3226
- const readmePath = path20.join(decisionsDir, "README.md");
3227
- if (!await fs18.pathExists(readmePath)) {
3936
+ const readmePath = path26.join(decisionsDir, "README.md");
3937
+ if (!await fs23.pathExists(readmePath)) {
3228
3938
  return ["docs/decisions/README.md is missing"];
3229
3939
  }
3230
3940
  const readme = await readText(readmePath) ?? "";
3231
3941
  const errors = [];
3232
3942
  for (const file of decisionFiles) {
3233
- const num = decisionNumberFromFile(path20.basename(file));
3943
+ const num = decisionNumberFromFile(path26.basename(file));
3234
3944
  if (num && !indexReferencesNumber(readme, num)) {
3235
3945
  errors.push(`README.md missing index row for ADR-${num}`);
3236
3946
  }
@@ -3284,22 +3994,22 @@ async function checkSatisfaction(input2) {
3284
3994
  const errors = [];
3285
3995
  const decisionsPrefix = `${input2.decisionsRel}/`;
3286
3996
  const newDecisionFiles = input2.changedFiles.filter(
3287
- (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path21.basename(f))
3997
+ (f) => f.startsWith(decisionsPrefix) && isDecisionFileName(path27.basename(f))
3288
3998
  );
3289
3999
  if (newDecisionFiles.length === 0) {
3290
4000
  errors.push(`no new or updated decision under ${input2.decisionsRel}/`);
3291
4001
  return errors;
3292
4002
  }
3293
- const decisionsDir = path21.join(input2.root, input2.decisionsRel);
4003
+ const decisionsDir = path27.join(input2.root, input2.decisionsRel);
3294
4004
  for (const rel of newDecisionFiles) {
3295
- const abs = path21.join(input2.root, rel);
4005
+ const abs = path27.join(input2.root, rel);
3296
4006
  const content2 = await readText(abs) ?? "";
3297
4007
  const valid = validateDecisionContent(content2);
3298
4008
  if (!valid.ok) errors.push(...valid.errors.map((e) => `${rel}: ${e}`));
3299
4009
  }
3300
4010
  const indexErrors = await validateIndexSync(
3301
4011
  decisionsDir,
3302
- newDecisionFiles.map((f) => path21.basename(f))
4012
+ newDecisionFiles.map((f) => path27.basename(f))
3303
4013
  );
3304
4014
  errors.push(...indexErrors);
3305
4015
  return errors;
@@ -3343,8 +4053,8 @@ ${opts.commitMessages ?? ""}`;
3343
4053
  changedFiles: stats.files
3344
4054
  });
3345
4055
  if (satisfactionErrors.length > 0 && decisionsRel !== legacyRel) {
3346
- const legacyExists = await fs19.pathExists(path21.join(root, legacyRel));
3347
- if (legacyExists && !await fs19.pathExists(decisionsDir)) {
4056
+ const legacyExists = await fs24.pathExists(path27.join(root, legacyRel));
4057
+ if (legacyExists && !await fs24.pathExists(decisionsDir)) {
3348
4058
  satisfactionErrors = await checkSatisfaction({
3349
4059
  root,
3350
4060
  decisionsRel: legacyRel,
@@ -3364,8 +4074,8 @@ ${opts.commitMessages ?? ""}`;
3364
4074
  return { triggered: true, satisfied: false, reasons, securityTriggered };
3365
4075
  }
3366
4076
  async function listDecisionNumbers(decisionsDir) {
3367
- if (!await fs19.pathExists(decisionsDir)) return [];
3368
- const entries = await fs19.readdir(decisionsDir);
4077
+ if (!await fs24.pathExists(decisionsDir)) return [];
4078
+ const entries = await fs24.readdir(decisionsDir);
3369
4079
  return entries.map((name) => decisionNumberFromFile(name)).filter((n) => n != null).map((n) => Number(n));
3370
4080
  }
3371
4081
 
@@ -3407,7 +4117,7 @@ async function runDecisionsGuard(options = {}) {
3407
4117
  }
3408
4118
 
3409
4119
  // src/commands/decisions.ts
3410
- import path22 from "node:path";
4120
+ import path28 from "node:path";
3411
4121
 
3412
4122
  // src/decisions/next-number.ts
3413
4123
  async function nextDecisionNumber(root) {
@@ -3518,7 +4228,7 @@ async function runDecisions(sub, arg, options = {}) {
3518
4228
  return;
3519
4229
  }
3520
4230
  if (sub === "validate") {
3521
- const target = arg ? path22.resolve(root, arg) : void 0;
4231
+ const target = arg ? path28.resolve(root, arg) : void 0;
3522
4232
  if (!target) {
3523
4233
  error("usage: haus decisions validate <path-to-decision.md>");
3524
4234
  process.exitCode = 1;
@@ -3535,18 +4245,18 @@ async function runDecisions(sub, arg, options = {}) {
3535
4245
  }
3536
4246
 
3537
4247
  // src/commands/doctor.ts
3538
- import path28 from "node:path";
3539
- import fs22 from "fs-extra";
4248
+ import path35 from "node:path";
4249
+ import fs28 from "fs-extra";
3540
4250
 
3541
4251
  // src/decisions/doctor.ts
3542
- import path23 from "node:path";
3543
- import fs20 from "fs-extra";
4252
+ import path29 from "node:path";
4253
+ import fs25 from "fs-extra";
3544
4254
  async function auditDecisionsLayout(root) {
3545
4255
  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);
4256
+ const primary = path29.join(root, DECISIONS_TRIGGERS.decisionsDir);
4257
+ const legacy = path29.join(root, DECISIONS_TRIGGERS.legacyDecisionsDir ?? "docs/adr");
4258
+ const hasPrimary = await fs25.pathExists(primary);
4259
+ const hasLegacy = await fs25.pathExists(legacy);
3550
4260
  if (!hasPrimary && hasLegacy) {
3551
4261
  findings.push({
3552
4262
  level: "advisory",
@@ -3559,8 +4269,8 @@ async function auditDecisionsLayout(root) {
3559
4269
  if (!hasPrimary) return findings;
3560
4270
  const decisionsDir = await resolveDecisionsDir(root);
3561
4271
  const rel = relativeDecisionPath(decisionsDir, root);
3562
- const readmePath = path23.join(decisionsDir, "README.md");
3563
- if (!await fs20.pathExists(readmePath)) {
4272
+ const readmePath = path29.join(decisionsDir, "README.md");
4273
+ if (!await fs25.pathExists(readmePath)) {
3564
4274
  findings.push({
3565
4275
  level: "advisory",
3566
4276
  line: `- DECISIONS: missing ${rel}/README.md index`,
@@ -3570,7 +4280,7 @@ async function auditDecisionsLayout(root) {
3570
4280
  return findings;
3571
4281
  }
3572
4282
  const readme = await readText(readmePath);
3573
- const entries = (await fs20.readdir(decisionsDir)).filter(
4283
+ const entries = (await fs25.readdir(decisionsDir)).filter(
3574
4284
  (f) => f.endsWith(".md") && f !== "README.md"
3575
4285
  );
3576
4286
  for (const file of entries) {
@@ -3584,7 +4294,7 @@ async function auditDecisionsLayout(root) {
3584
4294
  });
3585
4295
  }
3586
4296
  }
3587
- const claude = await readText(path23.join(root, "CLAUDE.md"));
4297
+ const claude = await readText(path29.join(root, "CLAUDE.md"));
3588
4298
  if (claude && !claude.includes(`@${rel}/README.md`)) {
3589
4299
  findings.push({
3590
4300
  level: "advisory",
@@ -3614,8 +4324,12 @@ async function auditDecisionsLayout(root) {
3614
4324
  return findings;
3615
4325
  }
3616
4326
 
4327
+ // src/scanner/read-context.ts
4328
+ import path34 from "node:path";
4329
+ import fs27 from "fs-extra";
4330
+
3617
4331
  // src/scanner/scan-project.ts
3618
- import path27 from "node:path";
4332
+ import path33 from "node:path";
3619
4333
 
3620
4334
  // src/utils/versions.ts
3621
4335
  import semver from "semver";
@@ -3637,8 +4351,8 @@ function compareVersions(a, b) {
3637
4351
  }
3638
4352
 
3639
4353
  // src/scanner/detect-package-manager.ts
3640
- import path24 from "node:path";
3641
- import fs21 from "fs-extra";
4354
+ import path30 from "node:path";
4355
+ import fs26 from "fs-extra";
3642
4356
  function detectPackageManager(root, packageManagerField) {
3643
4357
  const field = String(packageManagerField ?? "").trim();
3644
4358
  if (field.startsWith("yarn@")) {
@@ -3656,9 +4370,9 @@ function detectPackageManager(root, packageManagerField) {
3656
4370
  if (satisfiesVersion(version, ">=9")) return "npm";
3657
4371
  return "unknown";
3658
4372
  }
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";
4373
+ if (fs26.existsSync(path30.join(root, "yarn.lock"))) return "yarn";
4374
+ if (fs26.existsSync(path30.join(root, "pnpm-lock.yaml"))) return "pnpm";
4375
+ if (fs26.existsSync(path30.join(root, "package-lock.json"))) return "npm";
3662
4376
  return "unknown";
3663
4377
  }
3664
4378
 
@@ -3860,7 +4574,7 @@ function runDetection(ctx, rules = STACK_RULES) {
3860
4574
  }
3861
4575
 
3862
4576
  // src/scanner/detection.ts
3863
- import path25 from "node:path";
4577
+ import path31 from "node:path";
3864
4578
  var UNSUPPORTED_MARKERS = {
3865
4579
  "requirements.txt": "python",
3866
4580
  "pyproject.toml": "python",
@@ -3914,14 +4628,14 @@ function finalizeRoles(registryRoles, deps, files) {
3914
4628
  function collectUnsupportedSignals(files) {
3915
4629
  return [
3916
4630
  ...new Set(
3917
- files.map((f) => UNSUPPORTED_MARKERS[path25.basename(f)]).filter((s) => Boolean(s))
4631
+ files.map((f) => UNSUPPORTED_MARKERS[path31.basename(f)]).filter((s) => Boolean(s))
3918
4632
  )
3919
4633
  ].sort();
3920
4634
  }
3921
4635
 
3922
4636
  // src/scanner/render.ts
3923
4637
  import { readFile } from "node:fs/promises";
3924
- import path26 from "node:path";
4638
+ import path32 from "node:path";
3925
4639
  async function buildContentBlob(root, files) {
3926
4640
  const candidates = files.filter(
3927
4641
  (f) => f.endsWith(".ts") || f.endsWith(".js") || f.endsWith(".php") || f.endsWith(".json") || f.endsWith(".yml") || f.endsWith(".yaml")
@@ -3929,7 +4643,7 @@ async function buildContentBlob(root, files) {
3929
4643
  const slice = candidates.slice(0, 300);
3930
4644
  const parts = await mapWithConcurrency(slice, async (rel) => {
3931
4645
  try {
3932
- return await readFile(path26.join(root, rel), "utf8");
4646
+ return await readFile(path32.join(root, rel), "utf8");
3933
4647
  } catch {
3934
4648
  return "";
3935
4649
  }
@@ -4016,8 +4730,8 @@ var SAFE_FILES = [
4016
4730
  "Gemfile"
4017
4731
  ];
4018
4732
  async function scanProject(root) {
4019
- const pkg = await readJson(path27.join(root, "package.json"));
4020
- const composer = await readJson(path27.join(root, "composer.json"));
4733
+ const pkg = await readJson(path33.join(root, "package.json"));
4734
+ const composer = await readJson(path33.join(root, "composer.json"));
4021
4735
  const files = await listFiles(root, SAFE_FILES);
4022
4736
  const safeFiles = files.filter((f) => !blocked(f));
4023
4737
  const deps = dependencySet(pkg, composer);
@@ -4044,7 +4758,7 @@ async function scanProject(root) {
4044
4758
  const context = {
4045
4759
  generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4046
4760
  root,
4047
- repoName: String(pkg?.name ?? path27.basename(root)),
4761
+ repoName: String(pkg?.name ?? path33.basename(root)),
4048
4762
  packageManager,
4049
4763
  repoRoles: roles,
4050
4764
  detectedStacks: stacks,
@@ -4061,9 +4775,16 @@ async function scanProject(root) {
4061
4775
  }
4062
4776
 
4063
4777
  // src/scanner/read-context.ts
4778
+ async function isCacheFresh(root, cachePath) {
4779
+ const pkgJsonPath = path34.join(root, "package.json");
4780
+ if (!await fs27.pathExists(pkgJsonPath)) return true;
4781
+ const [cacheStat, pkgStat] = await Promise.all([fs27.stat(cachePath), fs27.stat(pkgJsonPath)]);
4782
+ return cacheStat.mtimeMs >= pkgStat.mtimeMs;
4783
+ }
4064
4784
  async function readContextOrScan(root) {
4065
- const context = await readJson(hausPath(root, "context-map.json"));
4066
- if (context) return context;
4785
+ const cachePath = hausPath(root, "context-map.json");
4786
+ const context = await readJson(cachePath);
4787
+ if (context && await isCacheFresh(root, cachePath)) return context;
4067
4788
  const scan = await scanProject(root);
4068
4789
  return scan;
4069
4790
  }
@@ -4156,7 +4877,7 @@ async function runDoctor(options) {
4156
4877
  } else {
4157
4878
  ok(`- HOOKS OK: ${hooks.message}`);
4158
4879
  }
4159
- const rootClaudeMdPath = path28.join(root, "CLAUDE.md");
4880
+ const rootClaudeMdPath = path35.join(root, "CLAUDE.md");
4160
4881
  const rootClaudeMdContent = await readText(rootClaudeMdPath);
4161
4882
  if (!rootClaudeMdContent) {
4162
4883
  flag(
@@ -4184,7 +4905,7 @@ async function runDoctor(options) {
4184
4905
  const block = rootClaudeMdContent.slice(beginIdx, endIdx + BLOCK_END.length);
4185
4906
  const importTargets = [...block.matchAll(/@\.haus-workflow\/(\S+)/g)].map((m) => m[1]);
4186
4907
  for (const target of importTargets) {
4187
- if (!await fs22.pathExists(hausPath(root, target))) {
4908
+ if (!await fs28.pathExists(hausPath(root, target))) {
4188
4909
  flag(
4189
4910
  `- CLAUDE.md import: @.haus-workflow/${target} does not resolve (run \`haus apply --write\`)`,
4190
4911
  `A file CLAUDE.md links to (${target}) is missing, so part of the guidance won't load`,
@@ -4195,7 +4916,7 @@ async function runDoctor(options) {
4195
4916
  }
4196
4917
  }
4197
4918
  const workflowPath = hausPath(root, "WORKFLOW.md");
4198
- const workflowExists = await fs22.pathExists(workflowPath);
4919
+ const workflowExists = await fs28.pathExists(workflowPath);
4199
4920
  if (!workflowExists) {
4200
4921
  flag(
4201
4922
  "- .haus-workflow/WORKFLOW.md: missing (run `haus apply --write`)",
@@ -4210,43 +4931,36 @@ async function runDoctor(options) {
4210
4931
  } else {
4211
4932
  const storedHashMatch = firstLine.match(/hash=(sha256-[a-f0-9]+)/);
4212
4933
  const bodyContent = workflowContent.slice(firstLine.length + 1);
4213
- const onDiskBodyHash = hashText(normaliseLF(bodyContent));
4214
- if (storedHashMatch && onDiskBodyHash !== storedHashMatch[1]) {
4934
+ const cachePath = path35.join(getCacheDir(), "templates/agentic-workflow-standard.md");
4935
+ const bundledPath = path35.join(
4936
+ packageRoot(),
4937
+ "library",
4938
+ "global",
4939
+ "templates",
4940
+ "agentic-workflow-standard.md"
4941
+ );
4942
+ const templatePath = await fs28.pathExists(cachePath) ? cachePath : bundledPath;
4943
+ const templateContent = await readText(templatePath);
4944
+ const verdict = checkManagedTamper(bodyContent, storedHashMatch?.[1], templateContent ?? null);
4945
+ if (verdict.status === "tampered" || verdict.status === "legacy-diverged") {
4215
4946
  flag(
4216
4947
  "- .haus-workflow/WORKFLOW.md: modified locally (run `haus apply --write --force` to restore)",
4217
4948
  "The workflow standard file was edited after haus wrote it",
4218
4949
  "haus apply --write --force"
4219
4950
  );
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"
4951
+ } else if (verdict.status === "stale") {
4952
+ suggest(
4953
+ "- .haus-workflow/WORKFLOW.md: stale (template updated \u2014 run `haus apply --write`)",
4954
+ "The workflow standard is out of date",
4955
+ "haus apply --write"
4228
4956
  );
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
- }
4957
+ } else {
4958
+ ok("- .haus-workflow/WORKFLOW.md: OK");
4245
4959
  }
4246
4960
  }
4247
4961
  }
4248
4962
  const workflowConfigPath = hausPath(root, "workflow-config.md");
4249
- const workflowConfigExists = await fs22.pathExists(workflowConfigPath);
4963
+ const workflowConfigExists = await fs28.pathExists(workflowConfigPath);
4250
4964
  if (!workflowConfigExists) {
4251
4965
  flag(
4252
4966
  "- .haus-workflow/workflow-config.md: missing (run `haus apply --write`)",
@@ -4254,7 +4968,7 @@ async function runDoctor(options) {
4254
4968
  "haus apply --write"
4255
4969
  );
4256
4970
  } else {
4257
- const cfg = await fs22.readFile(workflowConfigPath, "utf8");
4971
+ const cfg = await fs28.readFile(workflowConfigPath, "utf8");
4258
4972
  const unfilled = cfg.split("\n").filter((l) => l.includes("<!-- fill in")).length;
4259
4973
  if (unfilled > 0) {
4260
4974
  suggest(
@@ -4267,15 +4981,17 @@ async function runDoctor(options) {
4267
4981
  }
4268
4982
  }
4269
4983
  if (workflowExists) {
4270
- const prettierIgnore = await readText(path28.join(root, ".prettierignore")) ?? "";
4271
- if (!prettierIgnore.split("\n").some((l) => l.trim() === ".haus-workflow/")) {
4984
+ const prettierIgnore = await readText(path35.join(root, ".prettierignore")) ?? "";
4985
+ const lines = prettierIgnore.split("\n");
4986
+ const missing = IGNORED_PATHS.filter((p) => !prettierIgnoreProtects(lines, p));
4987
+ if (missing.length > 0) {
4272
4988
  flag(
4273
- "- .prettierignore: not protecting .haus-workflow/ (run `haus apply --write`)",
4989
+ `- .prettierignore: not protecting ${missing.join(", ")} (run \`haus apply --write\`)`,
4274
4990
  'The formatter may reformat managed files and trigger false "modified locally" reports',
4275
4991
  "haus apply --write"
4276
4992
  );
4277
4993
  } else {
4278
- ok("- .prettierignore: protects .haus-workflow/");
4994
+ ok(`- .prettierignore: protects ${IGNORED_PATHS.join(" and ")}`);
4279
4995
  }
4280
4996
  }
4281
4997
  for (const finding of await auditDecisionsLayout(root)) {
@@ -4301,7 +5017,21 @@ async function runDoctor(options) {
4301
5017
  ok(`- CATALOG CACHE: OK (${cacheAgeDays}d old)`);
4302
5018
  }
4303
5019
  }
4304
- const pkgJson = await readJson(path28.join(packageRoot(), "package.json"));
5020
+ if (recommendation) {
5021
+ const lock = await readJson(hausPath(root, "haus.lock.json"));
5022
+ const recommendedIds = new Set(
5023
+ recommendation.recommended.map((r) => r.id).filter((id) => typeof id === "string")
5024
+ );
5025
+ const orphaned = findOrphanedLockEntries(lock ?? [], recommendedIds).map((entry) => entry.id);
5026
+ if (orphaned.length > 0) {
5027
+ suggest(
5028
+ `- CATALOG ITEMS: ${orphaned.length} installed item(s) no longer in the current recommendation (${orphaned.join(", ")})`,
5029
+ `${orphaned.length} installed item(s) are no longer recommended for this project`,
5030
+ "review whether they are still needed; haus apply --write leaves them in place until removed from the catalog"
5031
+ );
5032
+ }
5033
+ }
5034
+ const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
4305
5035
  const currentVersion = pkgJson?.version ?? "0.0.0";
4306
5036
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
4307
5037
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -4517,7 +5247,7 @@ function matchesDenyToken(command, denyPhrase) {
4517
5247
  if (denyPhrase === "sudo") {
4518
5248
  return /(?:^|[|;&]\s*)sudo\b/i.test(command);
4519
5249
  }
4520
- const normalizedCommand = command.toLowerCase().replace(/\s+/g, " ").trim();
5250
+ const normalizedCommand = command.replace(/["'`]/g, "").toLowerCase().replace(/\s+/g, " ").trim();
4521
5251
  return normalizedCommand.includes(denyPhrase.toLowerCase());
4522
5252
  }
4523
5253
  function guardBash(command) {
@@ -4559,41 +5289,29 @@ async function runGuard(kind, _options) {
4559
5289
  }
4560
5290
 
4561
5291
  // 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
- }
5292
+ import path36 from "node:path";
5293
+ import fs29 from "fs-extra";
4582
5294
 
4583
5295
  // src/recommender/git-signal.ts
4584
5296
  init_exec();
4585
- async function readChangedFiles(root) {
4586
- if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
5297
+ async function runGitLines(root, args) {
4587
5298
  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();
5299
+ const result = await runGit(args, { cwd: root, timeout: 3e3 });
5300
+ if (result.exitCode !== 0) return [];
5301
+ return result.stdout.split("\n").map((x) => x.trim()).filter(Boolean);
4593
5302
  } catch {
4594
5303
  return [];
4595
5304
  }
4596
5305
  }
5306
+ async function readChangedFiles(root) {
5307
+ if (process.env.HAUS_DISABLE_GIT_SIGNALS === "1") return [];
5308
+ const [unstaged, staged, untracked] = await Promise.all([
5309
+ runGitLines(root, ["diff", "--name-only"]),
5310
+ runGitLines(root, ["diff", "--cached", "--name-only"]),
5311
+ runGitLines(root, ["ls-files", "--others", "--exclude-standard"])
5312
+ ]);
5313
+ return [.../* @__PURE__ */ new Set([...unstaged, ...staged, ...untracked])].sort();
5314
+ }
4597
5315
 
4598
5316
  // src/recommender/policies.ts
4599
5317
  var UNSUPPORTED = FORBIDDEN_TAGS;
@@ -4668,6 +5386,7 @@ async function recommend(root, context, opts = {}) {
4668
5386
  const recommended = [];
4669
5387
  const skipped = [];
4670
5388
  const sourceTrust = new Map(sources.items.map((x) => [x.source, x.status]));
5389
+ const formerIds = new Set(items.flatMap((item) => normalizeFormerIds(item.formerIds)));
4671
5390
  const changedFiles = await readChangedFiles(root);
4672
5391
  const skip = (id, code, message, signal) => {
4673
5392
  skipped.push({ id, reason: message, skipReasons: [{ code, message, signal }] });
@@ -4675,6 +5394,10 @@ async function recommend(root, context, opts = {}) {
4675
5394
  const roleSignal = (name) => scannerRoles.has(name.toLowerCase()) ? `role:${name}` : `deep:role:${name}`;
4676
5395
  const stackSignal = (name) => scannerStacks.has(name.toLowerCase()) ? `tag:${name}` : `deep:tag:${name}`;
4677
5396
  for (const item of items) {
5397
+ if (formerIds.has(item.id)) {
5398
+ skip(item.id, "former-id", "Catalog id was renamed and cannot be newly recommended");
5399
+ continue;
5400
+ }
4678
5401
  const normSource = typeof item.source === "string" ? item.source.trim() : "";
4679
5402
  if (!normSource) {
4680
5403
  skip(item.id, "invalid-source", "Item source is missing or empty");
@@ -4916,6 +5639,7 @@ function applyCoInstallSuppression(recommended, skipped) {
4916
5639
  }
4917
5640
  }
4918
5641
  var HARD_SKIP_CODES = /* @__PURE__ */ new Set([
5642
+ "former-id",
4919
5643
  "unsupported-policy",
4920
5644
  "deprecated",
4921
5645
  "curated-not-approved",
@@ -4990,7 +5714,7 @@ function buildOptInEligible(items, skipped) {
4990
5714
  return eligible;
4991
5715
  }
4992
5716
 
4993
- // src/commands/setup-core.ts
5717
+ // src/claude/setup-core.ts
4994
5718
  async function runSetupCore(root, opts) {
4995
5719
  const { json, apply, dryRun, quiet, confirm: confirm2 } = opts;
4996
5720
  const say = quiet ? () => {
@@ -5083,8 +5807,8 @@ async function runSetupProject(options) {
5083
5807
  // src/commands/init.ts
5084
5808
  async function runInit(options) {
5085
5809
  const root = process.cwd();
5086
- const hausDir = path29.join(root, ".haus-workflow");
5087
- const alreadyInit = await fs23.pathExists(hausDir);
5810
+ const hausDir = path36.join(root, ".haus-workflow");
5811
+ const alreadyInit = await fs29.pathExists(hausDir);
5088
5812
  if (alreadyInit) {
5089
5813
  log("Haus AI already initialized in this project.");
5090
5814
  log("Run `haus setup-project` to reconfigure.");
@@ -5096,8 +5820,8 @@ async function runInit(options) {
5096
5820
 
5097
5821
  // src/install/apply.ts
5098
5822
  import crypto2 from "node:crypto";
5099
- import path30 from "node:path";
5100
- import fs24 from "fs-extra";
5823
+ import path37 from "node:path";
5824
+ import fs30 from "fs-extra";
5101
5825
 
5102
5826
  // src/install/header.ts
5103
5827
  var MD_PREFIX = "<!-- HAUS-MANAGED";
@@ -5183,57 +5907,57 @@ function hashContent(content2) {
5183
5907
  }
5184
5908
  function sourceVersion() {
5185
5909
  try {
5186
- const pkgPath = path30.join(packageRoot(), "package.json");
5187
- const pkg = JSON.parse(fs24.readFileSync(pkgPath, "utf8"));
5910
+ const pkgPath = path37.join(packageRoot(), "package.json");
5911
+ const pkg = JSON.parse(fs30.readFileSync(pkgPath, "utf8"));
5188
5912
  return `${pkg.name ?? "haus"}@${pkg.version ?? "0.0.0"}`;
5189
5913
  } catch {
5190
5914
  return "haus@0.0.0";
5191
5915
  }
5192
5916
  }
5193
5917
  function globalSrcDir() {
5194
- return path30.join(packageRoot(), "library", "global");
5918
+ return path37.join(packageRoot(), "library", "global");
5195
5919
  }
5196
5920
  function collectSkillAuxFiles(skillsDir, claudeDir, skillName) {
5197
- const skillDir = path30.join(skillsDir, skillName);
5921
+ const skillDir = path37.join(skillsDir, skillName);
5198
5922
  const entries = [];
5199
- for (const rel of fs24.readdirSync(skillDir, { recursive: true })) {
5923
+ for (const rel of fs30.readdirSync(skillDir, { recursive: true })) {
5200
5924
  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("/");
5925
+ const abs = path37.join(skillDir, rel);
5926
+ if (!fs30.statSync(abs).isFile()) continue;
5927
+ const posixRel = rel.split(path37.sep).join("/");
5204
5928
  entries.push({
5205
5929
  stableId: `skill.${skillName}.${posixRel.replace(/\//g, ".")}`,
5206
- srcRelPath: path30.join("library", "global", "skills", skillName, rel),
5207
- destPath: path30.join(claudeDir, "skills", skillName, rel)
5930
+ srcRelPath: path37.join("library", "global", "skills", skillName, rel),
5931
+ destPath: path37.join(claudeDir, "skills", skillName, rel)
5208
5932
  });
5209
5933
  }
5210
5934
  return entries;
5211
5935
  }
5212
5936
  function collectSourceFiles(srcDir, claudeDir) {
5213
5937
  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)) {
5938
+ const skillsDir = path37.join(srcDir, "skills");
5939
+ if (fs30.pathExistsSync(skillsDir)) {
5940
+ for (const skillName of fs30.readdirSync(skillsDir)) {
5941
+ const skillFile = path37.join(skillsDir, skillName, "SKILL.md");
5942
+ if (fs30.pathExistsSync(skillFile)) {
5219
5943
  entries.push({
5220
5944
  stableId: `skill.${skillName}`,
5221
- srcRelPath: path30.join("library", "global", "skills", skillName, "SKILL.md"),
5222
- destPath: path30.join(claudeDir, "skills", skillName, "SKILL.md")
5945
+ srcRelPath: path37.join("library", "global", "skills", skillName, "SKILL.md"),
5946
+ destPath: path37.join(claudeDir, "skills", skillName, "SKILL.md")
5223
5947
  });
5224
5948
  entries.push(...collectSkillAuxFiles(skillsDir, claudeDir, skillName));
5225
5949
  }
5226
5950
  }
5227
5951
  }
5228
- const commandsDir = path30.join(srcDir, "commands");
5229
- if (fs24.pathExistsSync(commandsDir)) {
5230
- for (const fileName of fs24.readdirSync(commandsDir)) {
5952
+ const commandsDir = path37.join(srcDir, "commands");
5953
+ if (fs30.pathExistsSync(commandsDir)) {
5954
+ for (const fileName of fs30.readdirSync(commandsDir)) {
5231
5955
  if (!fileName.endsWith(".md")) continue;
5232
5956
  const commandName = fileName.slice(0, -".md".length);
5233
5957
  entries.push({
5234
5958
  stableId: `command.${commandName}`,
5235
- srcRelPath: path30.join("library", "global", "commands", fileName),
5236
- destPath: path30.join(claudeDir, "commands", fileName)
5959
+ srcRelPath: path37.join("library", "global", "commands", fileName),
5960
+ destPath: path37.join(claudeDir, "commands", fileName)
5237
5961
  });
5238
5962
  }
5239
5963
  }
@@ -5259,7 +5983,7 @@ async function applyInstall(options = {}) {
5259
5983
  };
5260
5984
  const manifestFiles = [];
5261
5985
  for (const entry of sourceFiles) {
5262
- const srcPath = path30.join(packageRoot(), entry.srcRelPath);
5986
+ const srcPath = path37.join(packageRoot(), entry.srcRelPath);
5263
5987
  const rawContent = await readText(srcPath);
5264
5988
  if (rawContent === void 0) {
5265
5989
  warn(`Source file not found: ${entry.srcRelPath}`);
@@ -5279,7 +6003,7 @@ async function applyInstall(options = {}) {
5279
6003
  }
5280
6004
  continue;
5281
6005
  }
5282
- const destExists = fs24.pathExistsSync(entry.destPath);
6006
+ const destExists = fs30.pathExistsSync(entry.destPath);
5283
6007
  if (destExists) {
5284
6008
  const currentContent = await readText(entry.destPath);
5285
6009
  if (currentContent !== void 0) {
@@ -5319,7 +6043,7 @@ async function applyInstall(options = {}) {
5319
6043
  }
5320
6044
  let mergedSettings;
5321
6045
  if (!check) {
5322
- const fragmentPath = path30.join(srcDir, "settings-fragments", "hooks.json");
6046
+ const fragmentPath = path37.join(srcDir, "settings-fragments", "hooks.json");
5323
6047
  const fragments = await loadHooksFragment(fragmentPath);
5324
6048
  const settings = await readSettings();
5325
6049
  const { settings: hookSettings, addedIds } = mergeHooks(settings, fragments);
@@ -5333,13 +6057,13 @@ async function applyInstall(options = {}) {
5333
6057
  const currentDestPaths = new Set(sourceFiles.map((f) => f.destPath));
5334
6058
  for (const entry of existingManifest.files) {
5335
6059
  if (currentDestPaths.has(entry.destPath)) continue;
5336
- if (!fs24.pathExistsSync(entry.destPath)) continue;
6060
+ if (!fs30.pathExistsSync(entry.destPath)) continue;
5337
6061
  const content2 = await readText(entry.destPath);
5338
6062
  if (!content2) continue;
5339
6063
  const hasHeader = parseMarkdownHeader(content2) !== void 0;
5340
6064
  const currentHash = hashContent(content2);
5341
6065
  if (hasHeader && currentHash === entry.hash) {
5342
- if (!dryRun) await fs24.remove(entry.destPath);
6066
+ if (!dryRun) await fs30.remove(entry.destPath);
5343
6067
  result.deleted.push(entry.destPath);
5344
6068
  } else {
5345
6069
  warn(`Orphaned file ${entry.destPath} was user-modified \u2014 leaving in place`);
@@ -5508,8 +6232,8 @@ async function runRefresh() {
5508
6232
  }
5509
6233
 
5510
6234
  // src/install/scaffold.ts
5511
- import path31 from "node:path";
5512
- import fs25 from "fs-extra";
6235
+ import path38 from "node:path";
6236
+ import fs31 from "fs-extra";
5513
6237
  async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5514
6238
  const scaffolded = [];
5515
6239
  const skipped = [];
@@ -5522,27 +6246,27 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5522
6246
  skipped.push(name);
5523
6247
  if (outcome === "skipped-exists") skippedExisting.push(name);
5524
6248
  };
5525
- const realRoot = await fs25.realpath(catalogRoot).catch(() => null);
6249
+ const realRoot = await fs31.realpath(catalogRoot).catch(() => null);
5526
6250
  for (const item of items) {
5527
6251
  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 === "..");
6252
+ const sourcePath = path38.resolve(catalogRoot, item.path);
6253
+ const rel = path38.relative(catalogRoot, sourcePath);
6254
+ const escapes = rel === "" || path38.isAbsolute(rel) || rel.split(/[/\\]/).some((seg) => seg === "..");
5531
6255
  if (escapes) {
5532
6256
  warn(`Skipping ${item.id}: path '${item.path}' escapes the catalog root`);
5533
6257
  continue;
5534
6258
  }
5535
- const realSource = await fs25.realpath(sourcePath).catch(() => null);
6259
+ const realSource = await fs31.realpath(sourcePath).catch(() => null);
5536
6260
  if (!realRoot || !realSource) {
5537
6261
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
5538
6262
  continue;
5539
6263
  }
5540
- const realRel = path31.relative(realRoot, realSource);
5541
- if (realRel !== "" && (path31.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
6264
+ const realRel = path38.relative(realRoot, realSource);
6265
+ if (realRel !== "" && (path38.isAbsolute(realRel) || realRel.split(/[/\\]/)[0] === "..")) {
5542
6266
  warn(`Skipping ${item.id}: path '${item.path}' resolves outside the catalog root`);
5543
6267
  continue;
5544
6268
  }
5545
- const stat = await fs25.lstat(sourcePath).catch(() => null);
6269
+ const stat = await fs31.lstat(sourcePath).catch(() => null);
5546
6270
  if (!stat) {
5547
6271
  warn(`Skipping ${item.id}: source not found at ${sourcePath}`);
5548
6272
  continue;
@@ -5552,14 +6276,14 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5552
6276
  continue;
5553
6277
  }
5554
6278
  if (stat.isFile()) {
5555
- const filename = path31.basename(sourcePath);
5556
- const dest = path31.join(projectRoot, filename);
6279
+ const filename = path38.basename(sourcePath);
6280
+ const dest = path38.join(projectRoot, filename);
5557
6281
  record(filename, await scaffoldFile(sourcePath, dest, item.id, projectRoot, opts));
5558
6282
  } else if (stat.isDirectory()) {
5559
- const entries = await fs25.readdir(sourcePath);
6283
+ const entries = await fs31.readdir(sourcePath);
5560
6284
  for (const entry of entries) {
5561
- const src = path31.join(sourcePath, entry);
5562
- const dest = path31.join(projectRoot, entry);
6285
+ const src = path38.join(sourcePath, entry);
6286
+ const dest = path38.join(projectRoot, entry);
5563
6287
  record(entry, await scaffoldFile(src, dest, item.id, projectRoot, opts));
5564
6288
  }
5565
6289
  }
@@ -5567,42 +6291,42 @@ async function scaffoldConfigItems(projectRoot, catalogRoot, items, opts = {}) {
5567
6291
  return { scaffolded, skipped, skippedExisting };
5568
6292
  }
5569
6293
  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);
6294
+ const destDisplay = path38.relative(projectRoot, dest) || path38.basename(dest);
6295
+ const srcStat = await fs31.lstat(src).catch(() => null);
5572
6296
  if (!srcStat) {
5573
- warn(`Skipping ${path31.basename(src)}: source not found`);
6297
+ warn(`Skipping ${path38.basename(src)}: source not found`);
5574
6298
  return "skipped-refused";
5575
6299
  }
5576
6300
  if (srcStat.isSymbolicLink()) {
5577
- warn(`Skipping ${path31.basename(src)}: source is a symlink`);
6301
+ warn(`Skipping ${path38.basename(src)}: source is a symlink`);
5578
6302
  return "skipped-refused";
5579
6303
  }
5580
- const exists = await fs25.pathExists(dest);
6304
+ const exists = await fs31.pathExists(dest);
5581
6305
  if (exists && !opts.force) {
5582
6306
  warn(`Skipping ${destDisplay}: already exists \u2014 re-run with --force to overwrite it`);
5583
6307
  return "skipped-exists";
5584
6308
  }
5585
6309
  if (opts.dryRun) {
5586
- log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path31.basename(dest)} (${itemId})`);
6310
+ log(`[dry-run] would ${exists ? "overwrite" : "create"} ${path38.basename(dest)} (${itemId})`);
5587
6311
  return "scaffolded";
5588
6312
  }
5589
6313
  if (exists && opts.force) {
5590
- await fs25.remove(dest);
6314
+ await fs31.remove(dest);
5591
6315
  }
5592
- await fs25.ensureDir(path31.dirname(dest));
5593
- await fs25.copy(src, dest, {
6316
+ await fs31.ensureDir(path38.dirname(dest));
6317
+ await fs31.copy(src, dest, {
5594
6318
  overwrite: true,
5595
6319
  dereference: false,
5596
6320
  // A vanished/unreadable entry mid-walk must skip that path, not crash the copy.
5597
6321
  filter: (s) => {
5598
6322
  try {
5599
- return !fs25.lstatSync(s).isSymbolicLink();
6323
+ return !fs31.lstatSync(s).isSymbolicLink();
5600
6324
  } catch {
5601
6325
  return false;
5602
6326
  }
5603
6327
  }
5604
6328
  });
5605
- log(`${exists ? "Overwrote" : "Created"} ${path31.basename(dest)} (${itemId})`);
6329
+ log(`${exists ? "Overwrote" : "Created"} ${path38.basename(dest)} (${itemId})`);
5606
6330
  return "scaffolded";
5607
6331
  }
5608
6332
 
@@ -5659,8 +6383,8 @@ async function runScan(options) {
5659
6383
  }
5660
6384
 
5661
6385
  // src/commands/undo.ts
5662
- import path32 from "node:path";
5663
- import fs26 from "fs-extra";
6386
+ import path39 from "node:path";
6387
+ import fs32 from "fs-extra";
5664
6388
 
5665
6389
  // src/claude/managed-paths.ts
5666
6390
  var PROJECT_MANAGED_CLAUDE_REL = ["rules/haus.md"];
@@ -5672,67 +6396,85 @@ function coreManagedAbsolutePaths(root) {
5672
6396
  }
5673
6397
 
5674
6398
  // src/commands/undo.ts
5675
- async function collectManagedPaths(root) {
5676
- const paths = new Set(coreManagedAbsolutePaths(root));
6399
+ async function collectCoreManagedPaths(root) {
6400
+ const existing = [];
6401
+ for (const abs of coreManagedAbsolutePaths(root)) {
6402
+ if (await fs32.pathExists(abs)) existing.push(abs);
6403
+ }
6404
+ return existing;
6405
+ }
6406
+ async function collectLockTrackedPaths(root) {
5677
6407
  const lock = await readJson(hausPath(root, "haus.lock.json"));
6408
+ const removable = [];
6409
+ const preserved = [];
5678
6410
  for (const row of lock ?? []) {
5679
- for (const rel of row.paths ?? []) {
5680
- paths.add(path32.resolve(root, rel));
6411
+ const relPaths = row.paths ?? [];
6412
+ const existingRel = [];
6413
+ for (const rel of relPaths) {
6414
+ if (await fs32.pathExists(path39.resolve(root, rel))) existingRel.push(rel);
6415
+ }
6416
+ if (existingRel.length === 0) continue;
6417
+ if (row.hash === void 0) {
6418
+ removable.push(...existingRel.map((rel) => path39.resolve(root, rel)));
6419
+ continue;
6420
+ }
6421
+ const currentHash = await hashInstalledPaths(root, existingRel);
6422
+ if (currentHash === row.hash) {
6423
+ removable.push(...existingRel.map((rel) => path39.resolve(root, rel)));
6424
+ } else {
6425
+ preserved.push(...existingRel.map((rel) => path39.resolve(root, rel)));
6426
+ log(`Preserving locally-modified ${row.id ?? "(unknown item)"}: ${existingRel.join(", ")}`);
5681
6427
  }
5682
6428
  }
5683
- const existing = [];
5684
- for (const abs of paths) {
5685
- if (await fs26.pathExists(abs)) existing.push(abs);
5686
- }
5687
- return existing;
6429
+ return { removable, preserved };
5688
6430
  }
5689
6431
  async function settingsHasHausContent(root) {
5690
6432
  const settingsPath = claudePath(root, "settings.json");
5691
- if (!await fs26.pathExists(settingsPath)) return false;
6433
+ if (!await fs32.pathExists(settingsPath)) return false;
5692
6434
  const settings = await readProjectSettings(root);
5693
6435
  return settings._haus != null;
5694
6436
  }
5695
6437
  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");
6438
+ const filePath = path39.join(root, "CLAUDE.md");
6439
+ if (!await fs32.pathExists(filePath)) return false;
6440
+ const text = await fs32.readFile(filePath, "utf8");
5699
6441
  return text.includes(BLOCK_BEGIN);
5700
6442
  }
5701
6443
  async function stripProjectSettings(root) {
5702
6444
  const settingsPath = claudePath(root, "settings.json");
5703
- if (!await fs26.pathExists(settingsPath)) return false;
6445
+ if (!await fs32.pathExists(settingsPath)) return false;
5704
6446
  let settings = await readProjectSettings(root);
5705
6447
  settings = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
5706
6448
  const hasContent = Object.keys(settings).length > 0;
5707
6449
  if (hasContent) {
5708
6450
  await writeProjectSettings(root, settings);
5709
- log(`Stripped haus rules from ${path32.relative(root, settingsPath)} (user settings preserved).`);
6451
+ log(`Stripped haus rules from ${path39.relative(root, settingsPath)} (user settings preserved).`);
5710
6452
  return true;
5711
6453
  }
5712
- await fs26.remove(settingsPath);
5713
- log(`Removed ${path32.relative(root, settingsPath)} (no user-owned settings remained).`);
6454
+ await fs32.remove(settingsPath);
6455
+ log(`Removed ${path39.relative(root, settingsPath)} (no user-owned settings remained).`);
5714
6456
  return true;
5715
6457
  }
5716
6458
  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");
6459
+ const filePath = path39.join(root, "CLAUDE.md");
6460
+ if (!await fs32.pathExists(filePath)) return false;
6461
+ const prev = await fs32.readFile(filePath, "utf8");
5720
6462
  if (!prev.includes(BLOCK_BEGIN)) return false;
5721
6463
  const next = stripHausBlock(prev);
5722
6464
  if (next.length === 0) {
5723
- await fs26.remove(filePath);
6465
+ await fs32.remove(filePath);
5724
6466
  log("Removed CLAUDE.md (only contained haus import block).");
5725
6467
  } else {
5726
- await fs26.writeFile(filePath, next, "utf8");
6468
+ await fs32.writeFile(filePath, next, "utf8");
5727
6469
  log("Removed haus import block from CLAUDE.md (user content preserved).");
5728
6470
  }
5729
6471
  return true;
5730
6472
  }
5731
6473
  async function pruneDirIfEmpty(dir) {
5732
- if (!await fs26.pathExists(dir)) return;
6474
+ if (!await fs32.pathExists(dir)) return;
5733
6475
  try {
5734
- const entries = await fs26.readdir(dir);
5735
- if (entries.length === 0) await fs26.remove(dir);
6476
+ const entries = await fs32.readdir(dir);
6477
+ if (entries.length === 0) await fs32.remove(dir);
5736
6478
  } catch {
5737
6479
  }
5738
6480
  }
@@ -5741,27 +6483,34 @@ async function backupManagedFilesBeforeUndo(root, managedAbsPaths) {
5741
6483
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
5742
6484
  const backupRoot = hausPath(root, "backups", `undo-${stamp}`);
5743
6485
  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);
6486
+ if (!await fs32.pathExists(abs)) continue;
6487
+ const rel = path39.relative(root, abs);
6488
+ const backupPath = path39.join(backupRoot, rel);
6489
+ await fs32.ensureDir(path39.dirname(backupPath));
6490
+ await fs32.copy(abs, backupPath);
5749
6491
  }
5750
- log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path32.relative(root, backupRoot)}.`);
6492
+ log(`Backed up ${managedAbsPaths.length} managed file(s) to ${path39.relative(root, backupRoot)}.`);
5751
6493
  }
5752
6494
  async function runUndo(options) {
5753
- const root = process.cwd();
5754
- const managed = await collectManagedPaths(root);
6495
+ const root = options.root ?? process.cwd();
6496
+ const coreManaged = await collectCoreManagedPaths(root);
6497
+ const { removable: lockRemovable, preserved: lockPreserved } = await collectLockTrackedPaths(root);
6498
+ const managed = [.../* @__PURE__ */ new Set([...coreManaged, ...lockRemovable])];
5755
6499
  const stripSettings = await settingsHasHausContent(root);
5756
6500
  const stripClaudeMd = await claudeMdHasHausBlock(root);
5757
6501
  if (managed.length === 0 && !stripSettings && !stripClaudeMd) {
5758
6502
  log("Nothing to remove: no haus-managed files found in this directory.");
5759
6503
  return;
5760
6504
  }
5761
- const relTargets = managed.map((p) => path32.relative(root, p));
6505
+ const relTargets = managed.map((p) => path39.relative(root, p));
5762
6506
  const summaryParts = [...relTargets];
5763
6507
  if (stripSettings) summaryParts.push(".claude/settings.json (haus rules only)");
5764
6508
  if (stripClaudeMd) summaryParts.push("CLAUDE.md (haus import block only)");
6509
+ if (lockPreserved.length > 0) {
6510
+ summaryParts.push(
6511
+ `(preserving ${lockPreserved.length} locally-modified catalog item file(s) \u2014 not shown above)`
6512
+ );
6513
+ }
5765
6514
  if (!options.yes) {
5766
6515
  const ok = await confirm(
5767
6516
  `Remove haus-managed files?
@@ -5775,9 +6524,9 @@ User-owned .claude/ files will be preserved.`
5775
6524
  }
5776
6525
  await backupManagedFilesBeforeUndo(root, managed);
5777
6526
  for (const abs of managed) {
5778
- if (!await fs26.pathExists(abs)) continue;
5779
- await fs26.remove(abs);
5780
- log(`Removed ${path32.relative(root, abs)}`);
6527
+ if (!await fs32.pathExists(abs)) continue;
6528
+ await fs32.remove(abs);
6529
+ log(`Removed ${path39.relative(root, abs)}`);
5781
6530
  }
5782
6531
  if (stripSettings) await stripProjectSettings(root);
5783
6532
  if (stripClaudeMd) await stripRootClaudeMd(root);
@@ -5788,8 +6537,8 @@ User-owned .claude/ files will be preserved.`
5788
6537
 
5789
6538
  // src/install/uninstall.ts
5790
6539
  import crypto3 from "node:crypto";
5791
- import path33 from "node:path";
5792
- import fs27 from "fs-extra";
6540
+ import path40 from "node:path";
6541
+ import fs33 from "fs-extra";
5793
6542
  async function runUninstall(options = {}) {
5794
6543
  const { force = false } = options;
5795
6544
  const manifest = await readManifest();
@@ -5799,7 +6548,7 @@ async function runUninstall(options = {}) {
5799
6548
  return result;
5800
6549
  }
5801
6550
  for (const entry of manifest.files) {
5802
- const exists = fs27.pathExistsSync(entry.destPath);
6551
+ const exists = fs33.pathExistsSync(entry.destPath);
5803
6552
  if (!exists) continue;
5804
6553
  const content2 = await readText(entry.destPath);
5805
6554
  if (content2 === void 0) continue;
@@ -5817,23 +6566,23 @@ async function runUninstall(options = {}) {
5817
6566
  result.skipped.push(entry.destPath);
5818
6567
  continue;
5819
6568
  }
5820
- await fs27.remove(entry.destPath);
5821
- await pruneEmptyDir(path33.dirname(entry.destPath));
6569
+ await fs33.remove(entry.destPath);
6570
+ await pruneEmptyDir(path40.dirname(entry.destPath));
5822
6571
  result.deleted.push(entry.destPath);
5823
6572
  }
5824
6573
  const settings = await readSettings();
5825
6574
  const stripped = stripHausHooks(stripHausAsk(stripHausAllow(stripHausDeny(settings))));
5826
6575
  await writeSettings(stripped);
5827
6576
  result.hooksStripped = true;
5828
- const hausDir = path33.join(globalClaudeDir(), "haus");
6577
+ const hausDir = path40.join(globalClaudeDir(), "haus");
5829
6578
  const manifestPath2 = hausManifestPath();
5830
- if (fs27.pathExistsSync(manifestPath2)) {
5831
- await fs27.remove(manifestPath2);
6579
+ if (fs33.pathExistsSync(manifestPath2)) {
6580
+ await fs33.remove(manifestPath2);
5832
6581
  }
5833
- if (fs27.pathExistsSync(hausDir)) {
6582
+ if (fs33.pathExistsSync(hausDir)) {
5834
6583
  try {
5835
- const remaining = await fs27.readdir(hausDir);
5836
- if (remaining.length === 0) await fs27.remove(hausDir);
6584
+ const remaining = await fs33.readdir(hausDir);
6585
+ if (remaining.length === 0) await fs33.remove(hausDir);
5837
6586
  } catch {
5838
6587
  }
5839
6588
  }
@@ -5866,12 +6615,12 @@ async function runUninstallCommand(options) {
5866
6615
  }
5867
6616
 
5868
6617
  // src/commands/update.ts
5869
- import path35 from "node:path";
6618
+ import path42 from "node:path";
5870
6619
 
5871
6620
  // src/claude/refresh-project.ts
5872
- import fs28 from "fs-extra";
6621
+ import fs34 from "fs-extra";
5873
6622
  async function isHausProject(root) {
5874
- if (await fs28.pathExists(hausPath(root, "recommendation.json"))) return true;
6623
+ if (await fs34.pathExists(hausPath(root, "recommendation.json"))) return true;
5875
6624
  const result = await readJsonDetailed(claudePath(root, "settings.json"));
5876
6625
  if (result.status === "ok") return result.value._haus != null;
5877
6626
  return false;
@@ -5906,7 +6655,7 @@ function summarizeLockDiff(before, after) {
5906
6655
 
5907
6656
  // src/update/lockfile.ts
5908
6657
  import { mkdir, readFile as readFile2, copyFile } from "node:fs/promises";
5909
- import path34 from "node:path";
6658
+ import path41 from "node:path";
5910
6659
  async function readLockSummary(root) {
5911
6660
  const lock = await readJson(hausPath(root, "haus.lock.json")) ?? [];
5912
6661
  const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
@@ -5917,7 +6666,7 @@ async function checkLock(root) {
5917
6666
  const hasValidVersions = lock.every(
5918
6667
  (item) => !item.version || normalizeVersion(item.version) !== null
5919
6668
  );
5920
- const catalogRef = lock[0]?.catalogRef ?? null;
6669
+ const catalogRef = lock.find((item) => item.catalogRef)?.catalogRef ?? null;
5921
6670
  const drift = [];
5922
6671
  for (const item of lock) {
5923
6672
  if (!item.hash) continue;
@@ -5942,7 +6691,7 @@ async function applyLock(root) {
5942
6691
  try {
5943
6692
  const backupDir = hausPath(root, "backups");
5944
6693
  await mkdir(backupDir, { recursive: true });
5945
- await copyFile(lockPath, path34.join(backupDir, `haus.lock.${Date.now()}.json`));
6694
+ await copyFile(lockPath, path41.join(backupDir, `haus.lock.${Date.now()}.json`));
5946
6695
  } catch {
5947
6696
  }
5948
6697
  const enriched = await Promise.all(
@@ -5964,7 +6713,7 @@ function diffLock(before, after) {
5964
6713
  }
5965
6714
  async function hasLocalOverrides(root) {
5966
6715
  try {
5967
- await readFile2(path34.join(root, ".claude", "settings.json"), "utf8");
6716
+ await readFile2(path41.join(root, ".claude", "settings.json"), "utf8");
5968
6717
  return true;
5969
6718
  } catch {
5970
6719
  return false;
@@ -5980,23 +6729,28 @@ async function runUpdate(options) {
5980
6729
  return;
5981
6730
  }
5982
6731
  if (options.check) {
5983
- const pkgJson2 = await readJson(path35.join(packageRoot(), "package.json"));
6732
+ const pkgJson2 = await readJson(path42.join(packageRoot(), "package.json"));
5984
6733
  const currentVersion2 = pkgJson2?.version ?? "0.0.0";
5985
- const [status, npmVersion, latestCatalogTag, globalInstallDrift] = await Promise.all([
5986
- checkLock(root),
6734
+ const [status, npmVersion, latestCatalogTag, globalInstallDrift, catalogItems, lockItems] = await Promise.all([
6735
+ options.fast ? fastLockStatus(root) : checkLock(root),
5987
6736
  fetchNpmVersionStatus(currentVersion2),
5988
6737
  fetchLatestCatalogTag(),
5989
- detectGlobalInstallDrift()
6738
+ options.fast ? Promise.resolve(null) : detectGlobalInstallDrift(),
6739
+ loadCatalog(root),
6740
+ readJson(hausPath(root, "haus.lock.json"))
5990
6741
  ]);
5991
- const installedRef = status.catalogRef ?? "main";
5992
- const catalogRefBehind = latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
6742
+ const formerIdMigrations = findFormerIdMigrations(lockItems ?? [], catalogItems);
6743
+ const installedRef = options.fast ? status.catalogRef : status.catalogRef ?? "main";
6744
+ const catalogRefBehind = installedRef !== null && latestCatalogTag !== null && installedRef !== latestCatalogTag ? `installed from ${installedRef}, latest tag is ${latestCatalogTag}` : false;
5993
6745
  log(
5994
6746
  JSON.stringify(
5995
6747
  {
5996
6748
  ...status,
6749
+ checkMode: options.fast ? "fast" : "full",
5997
6750
  installedCatalogRef: installedRef,
5998
6751
  latestCatalogTag,
5999
6752
  catalogRefBehind,
6753
+ formerIdMigrations,
6000
6754
  globalInstallDrift,
6001
6755
  localOverrides: await hasLocalOverrides(root),
6002
6756
  summary: diffGeneratedFiles(),
@@ -6006,10 +6760,11 @@ async function runUpdate(options) {
6006
6760
  2
6007
6761
  )
6008
6762
  );
6009
- if (status.count > 0 && !status.ok) process.exitCode = 1;
6763
+ const hasHashDrift = !options.fast && status.count > 0 && "ok" in status && !status.ok;
6764
+ if (hasHashDrift || formerIdMigrations.length > 0) process.exitCode = 1;
6010
6765
  return;
6011
6766
  }
6012
- const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
6767
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6013
6768
  const currentVersion = pkgJson?.version ?? "0.0.0";
6014
6769
  const npmStatus = await fetchNpmVersionStatus(currentVersion);
6015
6770
  if (npmStatus.updateAvailable && npmStatus.latest !== null) {
@@ -6048,7 +6803,7 @@ async function runFromHookCheck(root) {
6048
6803
  try {
6049
6804
  const summary = await readLockSummary(root);
6050
6805
  if (summary.count === 0) return;
6051
- const pkgJson = await readJson(path35.join(packageRoot(), "package.json"));
6806
+ const pkgJson = await readJson(path42.join(packageRoot(), "package.json"));
6052
6807
  const currentVersion = pkgJson?.version ?? "0.0.0";
6053
6808
  const [npmVersion, latestCatalogTag] = await Promise.all([
6054
6809
  fetchNpmVersionStatus(currentVersion),
@@ -6114,19 +6869,18 @@ async function detectGlobalInstallDrift() {
6114
6869
  return null;
6115
6870
  }
6116
6871
  }
6872
+ async function fastLockStatus(root) {
6873
+ const summary = await readLockSummary(root);
6874
+ return { count: summary.count, catalogRef: summary.catalogRef, drift: [], driftCount: 0 };
6875
+ }
6117
6876
 
6118
6877
  // src/commands/validate-catalog.ts
6119
- import path37 from "node:path";
6878
+ import path44 from "node:path";
6120
6879
 
6121
6880
  // src/catalog/validate-core.ts
6122
- import fs29 from "node:fs";
6123
- import path36 from "node:path";
6881
+ import fs35 from "node:fs";
6882
+ import path43 from "node:path";
6124
6883
  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
6884
  function auditForbiddenStacks(items) {
6131
6885
  const failures = [];
6132
6886
  for (const item of items) {
@@ -6206,7 +6960,7 @@ function auditManifestStructure(manifestVersion, items) {
6206
6960
  if (item.type === "skill" || item.type === "agent" || item.type === "template" || item.type === "command" || item.type === "config") {
6207
6961
  if (!item.path) {
6208
6962
  failures.push(`${item.id}: missing path`);
6209
- } else if (!isSafeCatalogPath2(item.path)) {
6963
+ } else if (!isSafeCatalogPath(item.path)) {
6210
6964
  failures.push(`${item.id}: path "${item.path}" is not a safe relative path`);
6211
6965
  } else {
6212
6966
  const norm = item.path.replace(/\\/g, "/");
@@ -6235,14 +6989,14 @@ function checkRequiredFrontmatter(text, label) {
6235
6989
  }
6236
6990
  function readForAudit(absPath, label, failures) {
6237
6991
  try {
6238
- return fs29.readFileSync(absPath, "utf8");
6992
+ return fs35.readFileSync(absPath, "utf8");
6239
6993
  } catch (err) {
6240
6994
  failures.push(`${label}: unreadable (${err instanceof Error ? err.message : String(err)})`);
6241
6995
  return null;
6242
6996
  }
6243
6997
  }
6244
6998
  function auditTemplateContent(manifestDir, absPath, itemId) {
6245
- const rel = path36.relative(manifestDir, absPath);
6999
+ const rel = path43.relative(manifestDir, absPath);
6246
7000
  const failures = [];
6247
7001
  const text = readForAudit(absPath, `${itemId}: ${rel}`, failures);
6248
7002
  if (text === null) return failures;
@@ -6263,48 +7017,48 @@ function auditTemplateContent(manifestDir, absPath, itemId) {
6263
7017
  function auditShippedFiles(manifestDir, items) {
6264
7018
  const failures = [];
6265
7019
  for (const item of items) {
6266
- if (!item.path || !isSafeCatalogPath2(item.path)) continue;
6267
- const absPath = path36.join(manifestDir, item.path);
7020
+ if (!item.path || !isSafeCatalogPath(item.path)) continue;
7021
+ const absPath = path43.join(manifestDir, item.path);
6268
7022
  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)}`);
7023
+ const skillMd = path43.join(absPath, "SKILL.md");
7024
+ if (!fs35.existsSync(skillMd)) {
7025
+ failures.push(`${item.id}: missing ${path43.relative(manifestDir, skillMd)}`);
6272
7026
  continue;
6273
7027
  }
6274
7028
  const text = readForAudit(skillMd, `${item.id}: SKILL.md`, failures);
6275
7029
  if (text === null) continue;
6276
7030
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: SKILL.md`));
6277
7031
  failures.push(
6278
- ...auditForbiddenTagsInText(text, `${item.id}: ${path36.relative(manifestDir, skillMd)}`)
7032
+ ...auditForbiddenTagsInText(text, `${item.id}: ${path43.relative(manifestDir, skillMd)}`)
6279
7033
  );
6280
7034
  } else if (item.type === "agent") {
6281
- if (!fs29.existsSync(absPath)) {
7035
+ if (!fs35.existsSync(absPath)) {
6282
7036
  failures.push(`${item.id}: missing agent file ${item.path}`);
6283
7037
  continue;
6284
7038
  }
6285
- const rel = path36.relative(manifestDir, absPath);
7039
+ const rel = path43.relative(manifestDir, absPath);
6286
7040
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6287
7041
  if (text === null) continue;
6288
7042
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
6289
7043
  failures.push(...auditForbiddenTagsInText(text, `${item.id}: ${rel}`));
6290
7044
  } else if (item.type === "template") {
6291
- if (!fs29.existsSync(absPath)) {
7045
+ if (!fs35.existsSync(absPath)) {
6292
7046
  failures.push(`${item.id}: missing template file ${item.path}`);
6293
7047
  continue;
6294
7048
  }
6295
7049
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
6296
7050
  } else if (item.type === "command") {
6297
- if (!fs29.existsSync(absPath)) {
7051
+ if (!fs35.existsSync(absPath)) {
6298
7052
  failures.push(`${item.id}: missing command file ${item.path}`);
6299
7053
  continue;
6300
7054
  }
6301
- const rel = path36.relative(manifestDir, absPath);
7055
+ const rel = path43.relative(manifestDir, absPath);
6302
7056
  const text = readForAudit(absPath, `${item.id}: ${rel}`, failures);
6303
7057
  if (text === null) continue;
6304
7058
  failures.push(...checkRequiredFrontmatter(text, `${item.id}: ${rel}`));
6305
7059
  failures.push(...auditTemplateContent(manifestDir, absPath, item.id));
6306
7060
  } else if (item.type === "config") {
6307
- if (!fs29.existsSync(absPath)) {
7061
+ if (!fs35.existsSync(absPath)) {
6308
7062
  failures.push(`${item.id}: missing config path ${item.path}`);
6309
7063
  }
6310
7064
  }
@@ -6320,7 +7074,7 @@ var DIR_ITEM_TYPE = {
6320
7074
  function walkMd(dir, fn, failures) {
6321
7075
  let entries;
6322
7076
  try {
6323
- entries = fs29.readdirSync(dir, { withFileTypes: true });
7077
+ entries = fs35.readdirSync(dir, { withFileTypes: true });
6324
7078
  } catch (err) {
6325
7079
  failures.push(
6326
7080
  `${dir}: unreadable subtree (${err instanceof Error ? err.message : String(err)})`
@@ -6328,7 +7082,7 @@ function walkMd(dir, fn, failures) {
6328
7082
  return;
6329
7083
  }
6330
7084
  for (const entry of entries) {
6331
- const full = path36.join(dir, entry.name);
7085
+ const full = path43.join(dir, entry.name);
6332
7086
  if (entry.isDirectory()) walkMd(full, fn, failures);
6333
7087
  else if (entry.name.endsWith(".md")) fn(full);
6334
7088
  }
@@ -6337,13 +7091,13 @@ function auditMarkdownContent(manifestDir, items) {
6337
7091
  const failures = [];
6338
7092
  const pathSourceMap = buildItemPathSourceMap(items);
6339
7093
  for (const dir of ["skills", "agents", "templates", "commands"]) {
6340
- const abs = path36.join(manifestDir, dir);
6341
- if (!fs29.existsSync(abs)) continue;
7094
+ const abs = path43.join(manifestDir, dir);
7095
+ if (!fs35.existsSync(abs)) continue;
6342
7096
  const dirType = DIR_ITEM_TYPE[dir] ?? "";
6343
7097
  walkMd(
6344
7098
  abs,
6345
7099
  (file) => {
6346
- const rel = path36.relative(manifestDir, file);
7100
+ const rel = path43.relative(manifestDir, file);
6347
7101
  const text = readForAudit(file, rel, failures);
6348
7102
  if (text === null) return;
6349
7103
  const norm = rel.replace(/\\/g, "/");
@@ -6368,6 +7122,7 @@ function auditMarkdownContent(manifestDir, items) {
6368
7122
  function validateCatalogData(manifestDir, manifestVersion, items) {
6369
7123
  const failures = [
6370
7124
  ...auditManifestStructure(manifestVersion, items),
7125
+ ...validateFormerIds(items),
6371
7126
  ...auditOptInMetadata(items),
6372
7127
  ...auditForbiddenStacks(items),
6373
7128
  ...auditShippedFiles(manifestDir, items),
@@ -6384,8 +7139,8 @@ async function runValidateCatalog(manifestPath2) {
6384
7139
  process.exitCode = 1;
6385
7140
  return;
6386
7141
  }
6387
- const abs = path37.resolve(process.cwd(), manifestPath2);
6388
- const manifestDir = path37.dirname(abs);
7142
+ const abs = path44.resolve(process.cwd(), manifestPath2);
7143
+ const manifestDir = path44.dirname(abs);
6389
7144
  const data = await readJson(abs);
6390
7145
  if (!data?.items) {
6391
7146
  error(`Could not read catalog manifest at ${abs}`);
@@ -6402,8 +7157,8 @@ async function runValidateCatalog(manifestPath2) {
6402
7157
  }
6403
7158
 
6404
7159
  // src/commands/workspace.ts
6405
- import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
6406
- import path44 from "node:path";
7160
+ import { existsSync as existsSync6, statSync as statSync3 } from "node:fs";
7161
+ import path52 from "node:path";
6407
7162
 
6408
7163
  // src/commands/workspace/aggregate.ts
6409
7164
  async function writeWorkspaceArtifacts(workspaceRoot, repos, relationships = []) {
@@ -6457,7 +7212,7 @@ ${summaries.map(
6457
7212
  }
6458
7213
 
6459
7214
  // src/commands/workspace/config.ts
6460
- import path38 from "node:path";
7215
+ import path45 from "node:path";
6461
7216
  import YAML from "yaml";
6462
7217
  var WORKSPACE_FILE = "haus.workspace.yaml";
6463
7218
  function parseWorkspaceConfig(text) {
@@ -6480,11 +7235,11 @@ function parseWorkspaceConfig(text) {
6480
7235
  };
6481
7236
  }
6482
7237
  async function readWorkspaceConfig(workspaceRoot) {
6483
- return parseWorkspaceConfig(await readText(path38.join(workspaceRoot, WORKSPACE_FILE)));
7238
+ return parseWorkspaceConfig(await readText(path45.join(workspaceRoot, WORKSPACE_FILE)));
6484
7239
  }
6485
7240
 
6486
7241
  // src/commands/workspace/discover.ts
6487
- import path39 from "node:path";
7242
+ import path46 from "node:path";
6488
7243
  import fg4 from "fast-glob";
6489
7244
  import YAML2 from "yaml";
6490
7245
  var DEFAULT_MAX_DEPTH = 3;
@@ -6515,8 +7270,8 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
6515
7270
  const gitDirs = /* @__PURE__ */ new Set();
6516
7271
  const manifestDirs = /* @__PURE__ */ new Set();
6517
7272
  for (const match of matches) {
6518
- const base = path39.posix.basename(match);
6519
- const dir = path39.posix.dirname(match);
7273
+ const base = path46.posix.basename(match);
7274
+ const dir = path46.posix.dirname(match);
6520
7275
  const owner = dir === "." ? "." : dir;
6521
7276
  if (base === ".git") gitDirs.add(owner);
6522
7277
  else manifestDirs.add(owner);
@@ -6532,9 +7287,9 @@ async function discoverRepos(workspaceRoot, maxDepth = DEFAULT_MAX_DEPTH) {
6532
7287
  }
6533
7288
  repoRoots.sort((a, b) => a.localeCompare(b));
6534
7289
  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);
7290
+ const absDir = path46.resolve(workspaceRoot, relDir);
7291
+ const pkg = await readJson(path46.join(absDir, "package.json"));
7292
+ const name = typeof pkg?.name === "string" && pkg.name.length > 0 ? pkg.name : path46.basename(relDir === "." ? workspaceRoot : absDir);
6538
7293
  let role = "auto";
6539
7294
  try {
6540
7295
  const scan = await scanProject(absDir);
@@ -6577,7 +7332,7 @@ function renderWorkspaceYaml(config) {
6577
7332
  });
6578
7333
  }
6579
7334
  async function runDiscover(workspaceRoot, opts = {}) {
6580
- const yamlPath = path39.join(workspaceRoot, "haus.workspace.yaml");
7335
+ const yamlPath = path46.join(workspaceRoot, "haus.workspace.yaml");
6581
7336
  const existingText = await readText(yamlPath);
6582
7337
  const existing = parseWorkspaceConfig(existingText);
6583
7338
  if (existingText && !existing) {
@@ -6614,18 +7369,18 @@ async function runDiscover(workspaceRoot, opts = {}) {
6614
7369
 
6615
7370
  // src/commands/workspace/doctor.ts
6616
7371
  import { existsSync as existsSync3 } from "node:fs";
6617
- import path41 from "node:path";
7372
+ import path48 from "node:path";
6618
7373
 
6619
7374
  // src/commands/workspace/manifest.ts
6620
7375
  import { readFileSync as readFileSync3 } from "node:fs";
6621
- import path40 from "node:path";
7376
+ import path47 from "node:path";
6622
7377
  var MANIFEST_FILE = "workspace.manifest.json";
6623
7378
  function manifestPath(workspaceRoot) {
6624
7379
  return hausPath(workspaceRoot, MANIFEST_FILE);
6625
7380
  }
6626
7381
  function hausVersion() {
6627
7382
  try {
6628
- const pkg = JSON.parse(readFileSync3(path40.join(packageRoot(), "package.json"), "utf8"));
7383
+ const pkg = JSON.parse(readFileSync3(path47.join(packageRoot(), "package.json"), "utf8"));
6629
7384
  return pkg.version ?? "0.0.0";
6630
7385
  } catch {
6631
7386
  return "0.0.0";
@@ -6690,8 +7445,9 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
6690
7445
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
6691
7446
  }
6692
7447
  const manifestByName = new Map(manifest.repos.map((r) => [r.name, r]));
7448
+ const catalogRefByRepo = [];
6693
7449
  for (const repo of config.repos) {
6694
- const repoRoot = path41.resolve(workspaceRoot, repo.path);
7450
+ const repoRoot = path48.resolve(workspaceRoot, repo.path);
6695
7451
  const entry = manifestByName.get(repo.name);
6696
7452
  if (!entry) {
6697
7453
  flag({
@@ -6724,6 +7480,7 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
6724
7480
  });
6725
7481
  }
6726
7482
  const lock = await checkLock(repoRoot);
7483
+ if (lock.catalogRef) catalogRefByRepo.push({ repo: repo.name, ref: lock.catalogRef });
6727
7484
  if (!existsSync3(hausPath(repoRoot, "haus.lock.json"))) {
6728
7485
  flag({
6729
7486
  repo: repo.name,
@@ -6741,6 +7498,14 @@ async function runWorkspaceDoctor(workspaceRoot, opts = {}) {
6741
7498
  ok(`- ${repo.name}: OK (${lock.count} lock item(s))`);
6742
7499
  }
6743
7500
  }
7501
+ const distinctRefs = new Set(catalogRefByRepo.map((r) => r.ref));
7502
+ if (distinctRefs.size > 1) {
7503
+ flag({
7504
+ repo: "(workspace)",
7505
+ kind: "catalog-ref-mismatch",
7506
+ 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.`
7507
+ });
7508
+ }
6744
7509
  return emit({ workspaceRoot, manifest, drift, detail, json: opts.json });
6745
7510
  }
6746
7511
  function emit(args) {
@@ -6765,11 +7530,11 @@ function emit(args) {
6765
7530
 
6766
7531
  // src/commands/workspace/setup.ts
6767
7532
  import { existsSync as existsSync4, statSync } from "node:fs";
6768
- import path43 from "node:path";
7533
+ import path50 from "node:path";
6769
7534
 
6770
7535
  // src/claude/write-workspace-claude-md.ts
6771
- import path42 from "node:path";
6772
- import fs30 from "fs-extra";
7536
+ import path49 from "node:path";
7537
+ import fs36 from "fs-extra";
6773
7538
  function buildWorkspaceImportBlock(client, members) {
6774
7539
  const memberLines = members.map((m) => `- ${m.name} (${m.path})`);
6775
7540
  const body = [
@@ -6787,15 +7552,16 @@ ${BLOCK_END}`;
6787
7552
  async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
6788
7553
  const block = buildWorkspaceImportBlock(opts.client, opts.members);
6789
7554
  const dryRun = opts.dryRun ?? false;
6790
- const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path42.join(workspaceRoot, "CLAUDE.md");
7555
+ const filePath = opts.collision ? hausPath(workspaceRoot, "WORKSPACE.md") : path49.join(workspaceRoot, "CLAUDE.md");
6791
7556
  const say = opts.quiet ? () => {
6792
7557
  } : log;
6793
- const prev = await fs30.pathExists(filePath) ? await fs30.readFile(filePath, "utf8") : "";
7558
+ const existed = await fs36.pathExists(filePath);
7559
+ const prev = existed ? await fs36.readFile(filePath, "utf8") : "";
6794
7560
  const next = opts.collision ? `${block}
6795
7561
  ` : injectHausBlock(prev, block);
6796
7562
  const printable = displayPath(workspaceRoot, filePath);
6797
7563
  if (dryRun) {
6798
- if (!prev) {
7564
+ if (!existed) {
6799
7565
  say(createUnifiedDiff(printable, "", next));
6800
7566
  } else if (hasTextChanged(prev, next)) {
6801
7567
  say(createUnifiedDiff(printable, prev, next));
@@ -6804,31 +7570,33 @@ async function writeWorkspaceClaudeMd(workspaceRoot, opts) {
6804
7570
  }
6805
7571
  return filePath;
6806
7572
  }
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})`);
7573
+ if (!existed || hasTextChanged(prev, next)) {
7574
+ if (existed) {
7575
+ const diffText = createUnifiedDiff(printable, prev, next);
7576
+ const summary = summarizeDiff(diffText);
7577
+ say(`Overwriting ${printable} (diff +${summary.additions} -${summary.deletions})`);
7578
+ }
7579
+ await writeText(filePath, next);
6811
7580
  }
6812
- await writeText(filePath, next);
6813
7581
  return filePath;
6814
7582
  }
6815
7583
 
6816
7584
  // src/commands/workspace/setup.ts
6817
7585
  function resolveWorkspaceRoot(start = process.cwd()) {
6818
- let dir = path43.resolve(start);
7586
+ let dir = path50.resolve(start);
6819
7587
  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);
7588
+ if (existsSync4(path50.join(dir, WORKSPACE_FILE))) return dir;
7589
+ const parent = path50.dirname(dir);
7590
+ if (parent === dir) return path50.resolve(start);
6823
7591
  dir = parent;
6824
7592
  }
6825
7593
  }
6826
7594
  function isRootRepo(workspaceRoot, repoPath) {
6827
- return path43.resolve(workspaceRoot, repoPath) === path43.resolve(workspaceRoot);
7595
+ return path50.resolve(workspaceRoot, repoPath) === path50.resolve(workspaceRoot);
6828
7596
  }
6829
7597
  async function runWorkspaceSetup(workspaceRoot, options = {}) {
6830
7598
  const apply = options.write ?? false;
6831
- const configText = await readText(path43.join(workspaceRoot, WORKSPACE_FILE));
7599
+ const configText = await readText(path50.join(workspaceRoot, WORKSPACE_FILE));
6832
7600
  if (!configText) {
6833
7601
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
6834
7602
  process.exitCode = 1;
@@ -6855,7 +7623,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
6855
7623
  const statuses = [];
6856
7624
  const aggregateInputs = [];
6857
7625
  for (const repo of repos) {
6858
- const repoRoot = path43.resolve(workspaceRoot, repo.path);
7626
+ const repoRoot = path50.resolve(workspaceRoot, repo.path);
6859
7627
  say(`
6860
7628
  \u2192 ${repo.name} (${repo.path})`);
6861
7629
  try {
@@ -6926,7 +7694,7 @@ async function runWorkspaceSetup(workspaceRoot, options = {}) {
6926
7694
  const status = statusByName.get(repo.name);
6927
7695
  const role = repo.role ?? status?.roles?.[0] ?? "auto";
6928
7696
  if (status?.status === "ok") {
6929
- const lock = await checkLock(path43.resolve(workspaceRoot, repo.path));
7697
+ const lock = await checkLock(path50.resolve(workspaceRoot, repo.path));
6930
7698
  manifestRepos.push({
6931
7699
  name: repo.name,
6932
7700
  path: repo.path,
@@ -6988,6 +7756,91 @@ Workspace setup complete: ${ok} ok, ${failed} failed. Wrote ${written.length} fi
6988
7756
  return result;
6989
7757
  }
6990
7758
 
7759
+ // src/commands/workspace/undo.ts
7760
+ import { existsSync as existsSync5, statSync as statSync2 } from "node:fs";
7761
+ import path51 from "node:path";
7762
+ import fs37 from "fs-extra";
7763
+ var WORKSPACE_AGGREGATE_FILES = [
7764
+ "workspace-summary.json",
7765
+ "dependency-ownership-map.json",
7766
+ "cross-repo-summary.md",
7767
+ "workspace-context-map.json"
7768
+ ];
7769
+ async function runWorkspaceUndo(workspaceRoot, options = {}) {
7770
+ const config = await readWorkspaceConfig(workspaceRoot);
7771
+ if (!config) {
7772
+ error(`Missing or malformed ${WORKSPACE_FILE}. Nothing to undo.`);
7773
+ process.exitCode = 1;
7774
+ return;
7775
+ }
7776
+ if (config.repos.length === 0) {
7777
+ log(`No repos configured in ${WORKSPACE_FILE}.`);
7778
+ }
7779
+ if (!options.yes) {
7780
+ const ok = await confirm(
7781
+ `Undo haus setup for ${config.repos.length} repo(s) in this workspace, plus workspace-root artifacts?`
7782
+ );
7783
+ if (!ok) {
7784
+ log("Cancelled.");
7785
+ return;
7786
+ }
7787
+ }
7788
+ let anyRepoFailed = false;
7789
+ for (const repo of config.repos) {
7790
+ const repoRoot = path51.resolve(workspaceRoot, repo.path);
7791
+ log(`
7792
+ \u2192 ${repo.name} (${repo.path})`);
7793
+ try {
7794
+ if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
7795
+ throw new Error(`Repo path is not a directory: ${repo.path}`);
7796
+ }
7797
+ await runUndo({ yes: true, root: repoRoot });
7798
+ } catch (err) {
7799
+ anyRepoFailed = true;
7800
+ warn(`Undo failed for ${repo.name}: ${err instanceof Error ? err.message : String(err)}`);
7801
+ }
7802
+ }
7803
+ for (const rel of WORKSPACE_AGGREGATE_FILES) {
7804
+ const abs = hausPath(workspaceRoot, rel);
7805
+ if (await fs37.pathExists(abs)) {
7806
+ await fs37.remove(abs);
7807
+ log(`Removed ${path51.relative(workspaceRoot, abs)}`);
7808
+ }
7809
+ }
7810
+ const manifestFile = manifestPath(workspaceRoot);
7811
+ if (await fs37.pathExists(manifestFile)) {
7812
+ await fs37.remove(manifestFile);
7813
+ log(`Removed ${path51.relative(workspaceRoot, manifestFile)}`);
7814
+ }
7815
+ const workspaceMdPath = hausPath(workspaceRoot, "WORKSPACE.md");
7816
+ if (await fs37.pathExists(workspaceMdPath)) {
7817
+ await fs37.remove(workspaceMdPath);
7818
+ log(`Removed ${path51.relative(workspaceRoot, workspaceMdPath)}`);
7819
+ } else {
7820
+ const claudeMdPath = path51.join(workspaceRoot, "CLAUDE.md");
7821
+ if (existsSync5(claudeMdPath)) {
7822
+ const prev = await readText(claudeMdPath);
7823
+ if (prev?.includes(BLOCK_BEGIN)) {
7824
+ const next = stripHausBlock(prev);
7825
+ if (next.length === 0) {
7826
+ await fs37.remove(claudeMdPath);
7827
+ log("Removed CLAUDE.md (only contained the workspace haus import block).");
7828
+ } else {
7829
+ await fs37.writeFile(claudeMdPath, next, "utf8");
7830
+ log("Removed haus import block from CLAUDE.md (user content preserved).");
7831
+ }
7832
+ }
7833
+ }
7834
+ }
7835
+ log(`${WORKSPACE_FILE} left in place \u2014 it's your own config, not haus-owned output.`);
7836
+ if (anyRepoFailed) {
7837
+ process.exitCode = 1;
7838
+ warn("Workspace undo finished with at least one repo failure \u2014 see above.");
7839
+ } else {
7840
+ log("Workspace undo complete.");
7841
+ }
7842
+ }
7843
+
6991
7844
  // src/commands/workspace.ts
6992
7845
  function normalizeOnly(only) {
6993
7846
  if (!only) return void 0;
@@ -7016,7 +7869,7 @@ relationships: []
7016
7869
  log("Workspace initialized.");
7017
7870
  }
7018
7871
  async function scanWorkspace(workspaceRoot, opts) {
7019
- const configText = await readText(path44.join(workspaceRoot, WORKSPACE_FILE));
7872
+ const configText = await readText(path52.join(workspaceRoot, WORKSPACE_FILE));
7020
7873
  if (!configText) {
7021
7874
  error(`Missing ${WORKSPACE_FILE}. Run \`haus workspace discover\` or \`init\` first.`);
7022
7875
  process.exitCode = 1;
@@ -7038,9 +7891,9 @@ async function scanWorkspace(workspaceRoot, opts) {
7038
7891
  const inputs = [];
7039
7892
  const failures = [];
7040
7893
  for (const repo of config.repos) {
7041
- const repoRoot = path44.resolve(workspaceRoot, repo.path);
7894
+ const repoRoot = path52.resolve(workspaceRoot, repo.path);
7042
7895
  try {
7043
- if (!existsSync5(repoRoot) || !statSync2(repoRoot).isDirectory()) {
7896
+ if (!existsSync6(repoRoot) || !statSync3(repoRoot).isDirectory()) {
7044
7897
  throw new Error("path is not a directory");
7045
7898
  }
7046
7899
  const result = await scanProject(repoRoot);
@@ -7092,13 +7945,16 @@ async function runWorkspace(action, options = {}) {
7092
7945
  case "doctor":
7093
7946
  await runWorkspaceDoctor(workspaceRoot, { json: options.json });
7094
7947
  return;
7948
+ case "undo":
7949
+ await runWorkspaceUndo(workspaceRoot, { yes: options.yes });
7950
+ return;
7095
7951
  }
7096
7952
  }
7097
7953
 
7098
7954
  // src/cli.ts
7099
7955
  function cliVersion() {
7100
7956
  try {
7101
- const pkgPath = path45.join(packageRoot(), "package.json");
7957
+ const pkgPath = path53.join(packageRoot(), "package.json");
7102
7958
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
7103
7959
  return pkg.version ?? "0.0.0";
7104
7960
  } catch {
@@ -7108,7 +7964,7 @@ function cliVersion() {
7108
7964
  var program = new Command();
7109
7965
  function validateRuntimeNodeVersion() {
7110
7966
  try {
7111
- const pkgPath = path45.join(packageRoot(), "package.json");
7967
+ const pkgPath = path53.join(packageRoot(), "package.json");
7112
7968
  const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
7113
7969
  const requiredRange = pkg.engines?.node;
7114
7970
  if (requiredRange && !satisfiesVersion(process.version, requiredRange)) {
@@ -7139,8 +7995,17 @@ program.command("apply").option("--dry-run").option("--write").option("--select"
7139
7995
  ).option(
7140
7996
  "--refill-config",
7141
7997
  "Fill still-blank fields in an existing workflow-config.md without touching edited ones"
7142
- ).option("--force", "Overwrite user-modified managed workflow files").action(runApply);
7998
+ ).option("--force", "Overwrite user-modified managed workflow files").option(
7999
+ "--prune",
8000
+ "Remove lock-tracked catalog items no longer in the current recommendation (hash-gated, backed up first)"
8001
+ ).action(runApply);
7143
8002
  program.command("undo").option("-y, --yes", "Skip confirmation").action(runUndo);
8003
+ var backups = program.command("backups");
8004
+ backups.command("list").description("List backups under .haus-workflow/backups/").action(() => runBackups("list"));
8005
+ backups.command("restore <id>").description("Restore a specific backup by id (see `haus backups list`)").option("-y, --yes", "Skip confirmation").action((id, opts) => runBackups("restore", { id, yes: opts.yes }));
8006
+ backups.command("prune").description("Delete old backups by age and/or count (requires --older-than or --keep)").option("--older-than <days>", "Remove backups older than N days").option("--keep <n>", "Keep only the N most recent backups").option("-y, --yes", "Skip confirmation").action(
8007
+ (opts) => runBackups("prune", { olderThan: opts.olderThan, keep: opts.keep, yes: opts.yes })
8008
+ );
7144
8009
  program.command("explain-recommendation").option("--json").action(runExplainRecommendation);
7145
8010
  program.command("init").option("--json").action(runInit);
7146
8011
  program.command("clone").description("Clone a single git repository by URL").argument("<url>", "Git URL to clone").argument("[dir]", "Target directory (default: repo name derived from the URL)").option("--dry-run", "Print what would happen without cloning").action(
@@ -7149,7 +8014,7 @@ program.command("clone").description("Clone a single git repository by URL").arg
7149
8014
  program.command("refresh").action(runRefresh);
7150
8015
  program.command("catalog-audit").action(runCatalogAudit);
7151
8016
  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);
8017
+ 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
8018
  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
8019
  program.command("scaffold [ids...]").description(
7155
8020
  "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 +8041,7 @@ workspace.command("discover").description("Auto-find member repos and write/merg
7176
8041
  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
8042
  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
8043
  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));
8044
+ 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
8045
  program.parseAsync(process.argv).catch((err) => {
7180
8046
  const message = err instanceof Error ? err.message : String(err);
7181
8047
  error(message);