mikoshi-construct 0.2.0 → 0.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
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path21 from "path";
4
+ import path22 from "path";
5
5
  import process6 from "process";
6
6
  import { isTTY } from "@clack/prompts";
7
7
  import { defineCommand, runMain } from "citty";
@@ -258,10 +258,50 @@ import process from "process";
258
258
 
259
259
  // src/manifest.ts
260
260
  import { createHash } from "crypto";
261
- import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync } from "fs";
261
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync } from "fs";
262
+ import path4 from "path";
263
+
264
+ // src/detect/existing.ts
265
+ import { existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
262
266
  import path3 from "path";
267
+ var ESLINT_CONFIGS = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml"];
268
+ var DEFAULT_COMPOSITION_DIR = "architecture/composition";
269
+ var COMPOSITION_CANDIDATES = [DEFAULT_COMPOSITION_DIR, "docs/architecture/composition", "docs/composition", "composition"];
270
+ var OPENAPI_CANDIDATES = ["contracts/api/openapi.yaml", "contracts/api/openapi.yml", "contracts/openapi.yaml", "openapi.yaml", "openapi.yml", "openapi.json", "api/openapi.yaml", "docs/openapi.yaml"];
271
+ function anyExists(dir, candidates) {
272
+ return candidates.some((candidate) => existsSync3(path3.join(dir, candidate)));
273
+ }
274
+ function firstExisting(dir, candidates) {
275
+ return candidates.find((candidate) => existsSync3(path3.join(dir, candidate))) ?? null;
276
+ }
277
+ function compositionDir(dir) {
278
+ return COMPOSITION_CANDIDATES.find((candidate) => {
279
+ const absolute = path3.join(dir, candidate);
280
+ return existsSync3(absolute) && readdirSync2(absolute).some((file) => file.endsWith(".yaml") || file.endsWith(".yml"));
281
+ }) ?? null;
282
+ }
283
+ function hasWorkflows(dir) {
284
+ const workflows = path3.join(dir, ".github", "workflows");
285
+ return existsSync3(workflows) && readdirSync2(workflows).some((file) => file.endsWith(".yml") || file.endsWith(".yaml"));
286
+ }
287
+ function detectExisting(dir) {
288
+ return {
289
+ packageJson: existsSync3(path3.join(dir, "package.json")),
290
+ tsconfig: existsSync3(path3.join(dir, "tsconfig.json")),
291
+ eslintConfig: anyExists(dir, ESLINT_CONFIGS),
292
+ githubWorkflows: hasWorkflows(dir),
293
+ claudeMd: existsSync3(path3.join(dir, "CLAUDE.md")),
294
+ agentsMd: existsSync3(path3.join(dir, "AGENTS.md")),
295
+ cursorRules: existsSync3(path3.join(dir, ".cursor", "rules")),
296
+ openapi: firstExisting(dir, OPENAPI_CANDIDATES),
297
+ compositionDir: compositionDir(dir),
298
+ constructJson: existsSync3(path3.join(dir, "construct.json"))
299
+ };
300
+ }
301
+
302
+ // src/manifest.ts
263
303
  var MANIFEST_FILE = "construct.json";
264
- var MANIFEST_VERSION = 2;
304
+ var MANIFEST_VERSION = 4;
265
305
  var DISCOVERY_MARKERS = [
266
306
  "product",
267
307
  "module-map",
@@ -277,7 +317,7 @@ var DISCOVERY_MARKERS = [
277
317
  function sha256(content) {
278
318
  return createHash("sha256").update(content).digest("hex");
279
319
  }
280
- function markerFile(marker, compositionDir2 = "architecture/composition") {
320
+ function markerFile(marker, compositionDir2) {
281
321
  switch (marker) {
282
322
  case "composition":
283
323
  return compositionDir2;
@@ -288,9 +328,10 @@ function markerFile(marker, compositionDir2 = "architecture/composition") {
288
328
  }
289
329
  }
290
330
  function buildManifest(input) {
291
- const files = {};
331
+ const written = {};
292
332
  for (const op of input.written)
293
- files[op.target] = sha256(op.content);
333
+ written[op.target] = sha256(op.content);
334
+ const files = { ...input.previous?.files, ...written };
294
335
  const markers = Object.fromEntries(DISCOVERY_MARKERS.map((marker) => [marker, {
295
336
  file: markerFile(marker, input.vars.compositionDir),
296
337
  authoredBy: "unknown",
@@ -298,8 +339,8 @@ function buildManifest(input) {
298
339
  }]));
299
340
  return {
300
341
  manifestVersion: MANIFEST_VERSION,
301
- construct: input.version,
302
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
342
+ construct: input.previous?.construct ?? input.version,
343
+ createdAt: input.previous?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
303
344
  preset: input.preset,
304
345
  ai: input.ai,
305
346
  review: input.review === "none" ? null : { provider: input.review, model: input.vars.reviewModel },
@@ -308,9 +349,21 @@ function buildManifest(input) {
308
349
  contracts: input.contracts ? { path: input.vars.contractPath, types: input.vars.contractTypesOutput } : null,
309
350
  vars: input.vars,
310
351
  files,
311
- discovery: { baseSha: null, filledAt: null, markers }
352
+ variants: { ...input.previous?.variants, ...variantsOf(input.written) },
353
+ discovery: input.previous?.discovery ?? { baseSha: null, filledAt: null, markers },
354
+ sync: input.previous?.sync ?? null
312
355
  };
313
356
  }
357
+ function variantsOf(written) {
358
+ return Object.fromEntries(written.flatMap((op) => op.variant == null ? [] : [[op.target, op.variant]]));
359
+ }
360
+ function isTemplateVariant(value) {
361
+ return value === "default" || value === "existing";
362
+ }
363
+ function upgradeVariants(raw) {
364
+ const value = raw ?? {};
365
+ return Object.fromEntries(Object.entries(value).flatMap(([target, variant]) => isTemplateVariant(variant) ? [[target, variant]] : []));
366
+ }
314
367
  function upgradeMarker(recorded, file) {
315
368
  if (typeof recorded === "string")
316
369
  return { file: recorded, authoredBy: "unknown", sha: null };
@@ -321,31 +374,63 @@ function upgradeMarker(recorded, file) {
321
374
  sha: typeof value.sha === "string" ? value.sha : null
322
375
  };
323
376
  }
377
+ function upgradeSync(raw) {
378
+ const value = raw ?? {};
379
+ if (typeof value.ranAt !== "string" || typeof value.fromVersion !== "string" || typeof value.toVersion !== "string")
380
+ return null;
381
+ return {
382
+ ranAt: value.ranAt,
383
+ fromVersion: value.fromVersion,
384
+ toVersion: value.toVersion,
385
+ files: typeof value.files === "object" && value.files != null ? { ...value.files } : {},
386
+ variants: upgradeVariants(value.variants)
387
+ };
388
+ }
324
389
  function upgradeManifest(raw) {
325
390
  const manifest = raw;
326
391
  const discovery = manifest.discovery ?? {};
327
392
  const recorded = discovery.markers ?? discovery;
328
393
  const markers = Object.fromEntries(DISCOVERY_MARKERS.map((marker) => [
329
394
  marker,
330
- upgradeMarker(recorded[marker], markerFile(marker, manifest.vars?.compositionDir))
395
+ upgradeMarker(recorded[marker], markerFile(marker, manifest.vars?.compositionDir ?? DEFAULT_COMPOSITION_DIR))
331
396
  ]));
332
397
  return {
333
398
  ...manifest,
334
399
  manifestVersion: MANIFEST_VERSION,
400
+ variants: upgradeVariants(manifest.variants),
335
401
  discovery: {
336
402
  baseSha: typeof discovery.baseSha === "string" ? discovery.baseSha : null,
337
403
  filledAt: typeof discovery.filledAt === "string" ? discovery.filledAt : null,
338
404
  markers
405
+ },
406
+ sync: upgradeSync(manifest.sync)
407
+ };
408
+ }
409
+ function recordSync(manifest, run) {
410
+ return {
411
+ ...manifest,
412
+ sync: {
413
+ ranAt: run.ranAt,
414
+ fromVersion: manifest.construct,
415
+ toVersion: run.toVersion,
416
+ files: { ...manifest.sync?.files, ...run.files },
417
+ variants: { ...manifest.sync?.variants, ...run.variants }
339
418
  }
340
419
  };
341
420
  }
421
+ function recordedShas(manifest) {
422
+ return { ...manifest.files, ...manifest.sync?.files };
423
+ }
424
+ function recordedVariants(manifest) {
425
+ return { ...manifest.variants, ...manifest.sync?.variants };
426
+ }
342
427
  function writeManifest(root, manifest) {
343
- writeFileSync(path3.join(root, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
428
+ writeFileSync(path4.join(root, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
344
429
  `);
345
430
  }
346
431
  function readManifest(root) {
347
- const file = path3.join(root, MANIFEST_FILE);
348
- if (!existsSync3(file))
432
+ const file = path4.join(root, MANIFEST_FILE);
433
+ if (!existsSync4(file))
349
434
  return null;
350
435
  return upgradeManifest(JSON.parse(readFileSync3(file, "utf8")));
351
436
  }
@@ -466,17 +551,35 @@ function costReport(cwd, options = {}) {
466
551
  };
467
552
  }
468
553
 
554
+ // src/version.ts
555
+ import { readFileSync as readFileSync4 } from "fs";
556
+ import path5 from "path";
557
+ import { fileURLToPath } from "url";
558
+ var HERE = path5.dirname(fileURLToPath(import.meta.url));
559
+ function readVersion() {
560
+ for (const candidate of ["../package.json", "../../package.json"]) {
561
+ try {
562
+ const parsed = JSON.parse(readFileSync4(path5.resolve(HERE, candidate), "utf8"));
563
+ if (parsed.name === "mikoshi-construct" && parsed.version != null)
564
+ return parsed.version;
565
+ } catch {
566
+ }
567
+ }
568
+ return "0.0.0";
569
+ }
570
+ var VERSION = readVersion();
571
+
469
572
  // src/commands/doctor/baseline.ts
470
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
471
- import path4 from "path";
573
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
574
+ import path6 from "path";
472
575
  function baselineVerdict(root, manifest) {
473
576
  const missingFiles = [];
474
577
  const modifiedFiles = [];
475
578
  for (const [file, hash] of Object.entries(manifest.files)) {
476
- const absolute = path4.join(root, file);
477
- if (!existsSync4(absolute))
579
+ const absolute = path6.join(root, file);
580
+ if (!existsSync5(absolute))
478
581
  missingFiles.push(file);
479
- else if (sha256(readFileSync4(absolute, "utf8")) !== hash)
582
+ else if (sha256(readFileSync5(absolute, "utf8")) !== hash)
480
583
  modifiedFiles.push(file);
481
584
  }
482
585
  return { missingFiles, modifiedFiles };
@@ -512,8 +615,8 @@ function ciCheck(evidence) {
512
615
  }
513
616
 
514
617
  // src/commands/doctor/runner.ts
515
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
516
- import path5 from "path";
618
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
619
+ import path7 from "path";
517
620
  var RUNNER_CONFIG_FILES = [
518
621
  "vitest.config.ts",
519
622
  "vitest.config.mts",
@@ -601,7 +704,7 @@ function matchesAnyGlob(file, globs) {
601
704
  }
602
705
  function readRunnerFacts(root, harnessText) {
603
706
  const invokedByHarness = harnessText.includes("vitest");
604
- const file = RUNNER_CONFIG_FILES.find((candidate) => existsSync5(path5.join(root, candidate))) ?? null;
707
+ const file = RUNNER_CONFIG_FILES.find((candidate) => existsSync6(path7.join(root, candidate))) ?? null;
605
708
  if (file == null) {
606
709
  return {
607
710
  file: null,
@@ -612,7 +715,7 @@ function readRunnerFacts(root, harnessText) {
612
715
  }
613
716
  let source;
614
717
  try {
615
- source = readFileSync5(path5.join(root, file), "utf8");
718
+ source = readFileSync6(path7.join(root, file), "utf8");
616
719
  } catch {
617
720
  return { file, globs: null, note: `${file} cannot be read, so the include list is unknown`, invokedByHarness };
618
721
  }
@@ -678,8 +781,16 @@ function redGateCheck(evidence) {
678
781
  }
679
782
 
680
783
  // src/commands/doctor/discovery.ts
681
- import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync6 } from "fs";
682
- import path6 from "path";
784
+ import { existsSync as existsSync7, readdirSync as readdirSync3, readFileSync as readFileSync7 } from "fs";
785
+ import path8 from "path";
786
+
787
+ // src/detect/facts.ts
788
+ function factsTheRepositoryEstablishes(root) {
789
+ const existing = detectExisting(root);
790
+ return existing.compositionDir == null ? {} : { compositionDir: existing.compositionDir };
791
+ }
792
+
793
+ // src/commands/doctor/discovery.ts
683
794
  var DISCOVERY_PLACEHOLDER = "_Not discovered yet \u2014 run `/construct-discover`._";
684
795
  function markerOpen(marker) {
685
796
  return `<!-- construct:discover:${marker} -->`;
@@ -696,33 +807,42 @@ function blockBody(document, marker) {
696
807
  return body === "" || body === DISCOVERY_PLACEHOLDER ? null : body;
697
808
  }
698
809
  function compositionBody(directory) {
699
- if (!existsSync6(directory))
810
+ if (!existsSync7(directory))
700
811
  return null;
701
- const models = readdirSync2(directory).filter((file) => file.endsWith(".yaml")).sort();
812
+ const models = readdirSync3(directory).filter((file) => file.endsWith(".yaml")).sort();
702
813
  if (models.length === 0)
703
814
  return null;
704
815
  return models.map((model) => `${model}
705
- ${readFileSync6(path6.join(directory, model), "utf8")}`).join("\n");
816
+ ${readFileSync7(path8.join(directory, model), "utf8")}`).join("\n");
706
817
  }
707
818
  function markerBody(root, marker, file) {
708
- const location = path6.join(root, file);
819
+ const location = path8.join(root, file);
709
820
  if (marker === "composition")
710
821
  return compositionBody(location);
711
- if (!existsSync6(location))
822
+ if (!existsSync7(location))
712
823
  return null;
713
- return blockBody(readFileSync6(location, "utf8"), marker);
824
+ return blockBody(readFileSync7(location, "utf8"), marker);
825
+ }
826
+ function markerFileFor(root, manifest, marker) {
827
+ const recordedFile = manifest.discovery.markers[marker].file;
828
+ if (marker !== "composition")
829
+ return recordedFile;
830
+ const decided = manifest.vars?.compositionDir;
831
+ if (decided != null && decided !== "")
832
+ return decided;
833
+ return factsTheRepositoryEstablishes(root).compositionDir ?? recordedFile;
714
834
  }
715
835
  function missingDiscovery(root, manifest) {
716
- return DISCOVERY_MARKERS.filter((marker) => markerBody(root, marker, manifest.discovery.markers[marker].file) == null);
836
+ return DISCOVERY_MARKERS.filter((marker) => markerBody(root, marker, markerFileFor(root, manifest, marker)) == null);
717
837
  }
718
838
 
719
839
  // src/commands/doctor/evidence.ts
720
- import { readFileSync as readFileSync10 } from "fs";
721
- import path10 from "path";
840
+ import { readFileSync as readFileSync11 } from "fs";
841
+ import path12 from "path";
722
842
 
723
843
  // src/commands/doctor/harness.ts
724
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
725
- import path7 from "path";
844
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
845
+ import path9 from "path";
726
846
  var REQUIRED_QUALITY_STEPS = ["lint", "typecheck", "test"];
727
847
  var SCRIPT_REFERENCE = /(?:^|&&|\|\||;)\s*(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/g;
728
848
  function harnessScriptName(command) {
@@ -740,8 +860,8 @@ function expandScript(scripts, name, seen) {
740
860
  }
741
861
  function readHarnessFacts(root, command) {
742
862
  const script = harnessScriptName(command);
743
- const manifestPath = path7.join(root, "package.json");
744
- const packageJson = existsSync7(manifestPath) ? JSON.parse(readFileSync7(manifestPath, "utf8")) : null;
863
+ const manifestPath = path9.join(root, "package.json");
864
+ const packageJson = existsSync8(manifestPath) ? JSON.parse(readFileSync8(manifestPath, "utf8")) : null;
745
865
  const scripts = packageJson?.scripts ?? {};
746
866
  const body = scripts[script] ?? null;
747
867
  return {
@@ -760,7 +880,7 @@ function runsHarnessCommand(text2, forms) {
760
880
  function contractProblems(root, contracts, script, body) {
761
881
  if (contracts == null)
762
882
  return [];
763
- const problems = [contracts.path, contracts.types].filter((file) => !existsSync7(path7.join(root, file))).map((file) => `${file} is missing (construct.json \u2192 contracts)`);
883
+ const problems = [contracts.path, contracts.types].filter((file) => !existsSync8(path9.join(root, file))).map((file) => `${file} is missing (construct.json \u2192 contracts)`);
764
884
  if (!body.includes("contracts:check"))
765
885
  problems.push(`"${script}" does not run contracts:check`);
766
886
  return problems;
@@ -778,8 +898,8 @@ function harnessProblems(root, manifest, facts) {
778
898
  }
779
899
 
780
900
  // src/commands/doctor/hooks.ts
781
- import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync8, statSync as statSync2 } from "fs";
782
- import path8 from "path";
901
+ import { existsSync as existsSync9, readdirSync as readdirSync4, readFileSync as readFileSync9, statSync as statSync2 } from "fs";
902
+ import path10 from "path";
783
903
  var LEFTHOOK_FILES = ["lefthook.yml", "lefthook.yaml", "lefthook.toml", "lefthook.json", ".lefthook.yml", ".lefthook.yaml"];
784
904
  var SIMPLE_GIT_HOOKS_FILES = [".simple-git-hooks.js", ".simple-git-hooks.cjs", ".simple-git-hooks.mjs", ".simple-git-hooks.json", "simple-git-hooks.json"];
785
905
  var HUSKY_DIR = ".husky";
@@ -787,20 +907,20 @@ var GIT_CONFIG = ".git/config";
787
907
  var HOOK_SCRIPTS = ["precommit", "pre-commit", "prepush", "pre-push"];
788
908
  function read(root, file) {
789
909
  try {
790
- return readFileSync8(path8.join(root, file), "utf8");
910
+ return readFileSync9(path10.join(root, file), "utf8");
791
911
  } catch {
792
912
  return null;
793
913
  }
794
914
  }
795
915
  function huskyHooks(root) {
796
- const directory = path8.join(root, HUSKY_DIR);
797
- if (!existsSync8(directory) || !statSync2(directory).isDirectory())
916
+ const directory = path10.join(root, HUSKY_DIR);
917
+ if (!existsSync9(directory) || !statSync2(directory).isDirectory())
798
918
  return [];
799
- return readdirSync3(directory).filter((entry) => !entry.startsWith("_") && !entry.startsWith(".")).sort().map((entry) => `${HUSKY_DIR}/${entry}`);
919
+ return readdirSync4(directory).filter((entry) => !entry.startsWith("_") && !entry.startsWith(".")).sort().map((entry) => `${HUSKY_DIR}/${entry}`);
800
920
  }
801
921
  function managerFiles(root, packageJson) {
802
922
  const files = [...huskyHooks(root)];
803
- files.push(...[...LEFTHOOK_FILES, ...SIMPLE_GIT_HOOKS_FILES].filter((file) => existsSync8(path8.join(root, file))));
923
+ files.push(...[...LEFTHOOK_FILES, ...SIMPLE_GIT_HOOKS_FILES].filter((file) => existsSync9(path10.join(root, file))));
804
924
  if (packageJson != null && "simple-git-hooks" in packageJson)
805
925
  files.push("package.json (simple-git-hooks)");
806
926
  const gitConfig = read(root, GIT_CONFIG);
@@ -823,8 +943,8 @@ function readHookFacts(root, packageJson, scripts, commandForms) {
823
943
  }
824
944
 
825
945
  // src/commands/doctor/workflows.ts
826
- import { existsSync as existsSync9, readdirSync as readdirSync4, readFileSync as readFileSync9 } from "fs";
827
- import path9 from "path";
946
+ import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10 } from "fs";
947
+ import path11 from "path";
828
948
  var WORKFLOWS_DIR = ".github/workflows";
829
949
  var RUN_STEP = /(?:^|\s)run:[ \t]*(\S.*)$/;
830
950
  function runStepTexts(source) {
@@ -851,16 +971,16 @@ function runStepTexts(source) {
851
971
  return steps;
852
972
  }
853
973
  function readWorkflowFacts(root, commandForms) {
854
- const directory = path9.join(root, WORKFLOWS_DIR);
855
- if (!existsSync9(directory))
974
+ const directory = path11.join(root, WORKFLOWS_DIR);
975
+ if (!existsSync10(directory))
856
976
  return { directory: WORKFLOWS_DIR, files: [], harnessWorkflow: null, unreadable: [] };
857
- const files = readdirSync4(directory).filter((file) => file.endsWith(".yml") || file.endsWith(".yaml")).sort();
977
+ const files = readdirSync5(directory).filter((file) => file.endsWith(".yml") || file.endsWith(".yaml")).sort();
858
978
  const unreadable = [];
859
979
  let harnessWorkflow = null;
860
980
  for (const file of files) {
861
981
  let source;
862
982
  try {
863
- source = readFileSync9(path9.join(directory, file), "utf8");
983
+ source = readFileSync10(path11.join(directory, file), "utf8");
864
984
  } catch {
865
985
  unreadable.push(`${WORKFLOWS_DIR}/${file}`);
866
986
  continue;
@@ -887,7 +1007,7 @@ function gatherEvidence(root, manifest) {
887
1007
  const unreadable = [...workflows.unreadable];
888
1008
  for (const file of recordedTests) {
889
1009
  try {
890
- const source = readFileSync10(path10.join(root, file), "utf8");
1010
+ const source = readFileSync11(path12.join(root, file), "utf8");
891
1011
  if (LINT_POLICY_MARKERS.some((marker) => source.includes(marker)))
892
1012
  policyTests.push(file);
893
1013
  } catch {
@@ -909,7 +1029,7 @@ function discoveryProvenance(root, manifest) {
909
1029
  return {
910
1030
  marker,
911
1031
  file: recorded.file,
912
- authorship: markerAuthorship(recorded, markerBody(root, marker, recorded.file))
1032
+ authorship: markerAuthorship(recorded, markerBody(root, marker, markerFileFor(root, manifest, marker)))
913
1033
  };
914
1034
  });
915
1035
  }
@@ -943,541 +1063,256 @@ function weakestLink(checks) {
943
1063
  return { id: weakest.id, level: weakest.level };
944
1064
  }
945
1065
 
946
- // src/commands/doctor/report.ts
947
- function checkLine(ui2, check) {
948
- return ` ${check.id.padEnd(16)} ${check.level} ${check.state.padEnd(8)} ${ui2.theme.dim(check.evidence)}`;
1066
+ // src/sync/replay.ts
1067
+ import { existsSync as existsSync13, readFileSync as readFileSync13 } from "fs";
1068
+ import { tmpdir } from "os";
1069
+ import path15 from "path";
1070
+
1071
+ // src/materialize/plan.ts
1072
+ import { existsSync as existsSync12, readFileSync as readFileSync12 } from "fs";
1073
+ import path14 from "path";
1074
+
1075
+ // src/materialize/rules.ts
1076
+ var CLAUDE_RULES_DIR = ".claude/rules/";
1077
+ var CURSOR_RULES_DIR = ".cursor/rules/";
1078
+ var DISCOVERY_PROTOCOL = ".claude/commands/construct-discover.md";
1079
+ var CURSOR_DISCOVERY_RULE = ".cursor/rules/construct-discover.mdc";
1080
+ var FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
1081
+ function unquote(value) {
1082
+ return value.trim().replace(/^(["'])(.*)\1$/, "$2");
949
1083
  }
950
- function printChecks(ui2, checks) {
951
- ui2.line();
952
- ui2.line(ui2.theme.accent(ui2.lore.enforcement));
953
- for (const check of checks)
954
- ui2.line(checkLine(ui2, check));
1084
+ function parseFrontmatter(block) {
1085
+ const paths = [];
1086
+ let description;
1087
+ let inPaths = false;
1088
+ for (const line of block.split("\n")) {
1089
+ const item = /^\s*-\s+(\S.*)$/.exec(line);
1090
+ if (inPaths && item != null) {
1091
+ paths.push(unquote(item[1]));
1092
+ continue;
1093
+ }
1094
+ inPaths = /^paths:\s*$/.test(line);
1095
+ const scalar = /^description:\s*(\S.*)$/.exec(line);
1096
+ if (scalar != null)
1097
+ description = unquote(scalar[1]);
1098
+ }
1099
+ return { description, paths };
955
1100
  }
956
- function printProvenance(ui2, provenance) {
957
- const authored = constructAuthored(provenance);
958
- if (authored.length === 0)
959
- return;
960
- ui2.line();
961
- ui2.line(ui2.theme.accent(ui2.lore.provenance));
962
- for (const reading of authored)
963
- ui2.line(` ${reading.marker.padEnd(20)} ${ui2.theme.dim(reading.file)}`);
964
- ui2.line(ui2.theme.dim(` ${ui2.lore.stillConstructAuthored(authored.length)}`));
1101
+ function firstHeading(body) {
1102
+ return /^#\s+(\S.*)$/m.exec(body)?.[1].trim();
965
1103
  }
966
- function printWeakestLink(ui2, weakest) {
967
- ui2.line();
968
- if (weakest == null) {
969
- ui2.line(ui2.theme.bold(ui2.lore.weakestLinkNone));
970
- return;
971
- }
972
- const lift = ui2.lore.levelLift[weakest.level];
973
- ui2.line(`${ui2.theme.bold(ui2.lore.weakestLink(weakest.id, weakest.level))}${lift == null ? "" : ui2.theme.dim(` \u2014 ${lift}`)}`);
1104
+ function parseClaudeRule(content) {
1105
+ const match = FRONTMATTER.exec(content);
1106
+ const body = match == null ? content : content.slice(match[0].length).replace(/^\n+/, "");
1107
+ const front = match == null ? { paths: [] } : parseFrontmatter(match[1]);
1108
+ return { description: front.description ?? firstHeading(body) ?? "Project rule", paths: front.paths, body };
974
1109
  }
975
- function printDoctor(ui2, result) {
976
- if (result == null) {
977
- ui2.flatline("No construct.json here. Run `construct init` first.");
978
- return 1;
979
- }
980
- if (result.harnessProblems.length > 0)
981
- ui2.glitch("Harness is broken.", result.harnessProblems);
982
- if (result.missingFiles.length > 0)
983
- ui2.glitch("Baseline files are missing.", result.missingFiles);
984
- if (result.missingDiscovery.length > 0)
985
- ui2.glitch(ui2.lore.discoveryIncomplete, ["", "Missing:", ...result.missingDiscovery.map((marker) => ` ${marker}`), "", "Run: claude \u2192 /construct-discover"]);
986
- if (result.warnings.length > 0)
987
- ui2.glitch(ui2.lore.typecheckCaveat, result.warnings);
988
- if (result.modifiedFiles.length > 0)
989
- ui2.line(ui2.theme.dim(` ${result.modifiedFiles.length} baseline files modified since init (expected once the project evolves).`));
990
- if (result.ok)
991
- ui2.ok(ui2.lore.stable);
992
- printProvenance(ui2, result.provenance);
993
- printChecks(ui2, result.checks);
994
- printWeakestLink(ui2, result.weakestLink);
995
- return result.ok ? 0 : 1;
1110
+ function isClaudeRule(target) {
1111
+ return target.startsWith(CLAUDE_RULES_DIR) && target.endsWith(".md");
996
1112
  }
997
-
998
- // src/commands/doctor/index.ts
999
- function runDoctor(root) {
1000
- const manifest = readManifest(root);
1001
- if (manifest == null)
1002
- return null;
1003
- const evidence = gatherEvidence(root, manifest);
1004
- const baseline = baselineVerdict(root, manifest);
1005
- const problems = harnessProblems(root, manifest, evidence.harness);
1006
- const checks = [
1007
- lintPolicyCheck(evidence),
1008
- constructTestsCheck(evidence),
1009
- ciCheck(evidence),
1010
- hookCheck(evidence),
1011
- redGateCheck(evidence)
1012
- ];
1013
- return {
1014
- ok: baseline.missingFiles.length === 0 && problems.length === 0,
1015
- missingFiles: baseline.missingFiles,
1016
- modifiedFiles: baseline.modifiedFiles,
1017
- missingDiscovery: missingDiscovery(root, manifest),
1018
- provenance: discoveryProvenance(root, manifest),
1019
- harnessProblems: problems,
1020
- warnings: typecheckWarnings(manifest.preset, evidence),
1021
- checks,
1022
- weakestLink: weakestLink(checks)
1023
- };
1113
+ function cursorRuleTarget(target) {
1114
+ return `${CURSOR_RULES_DIR}${target.slice(CLAUDE_RULES_DIR.length, -".md".length)}.mdc`;
1024
1115
  }
1116
+ function toCursorRule(content) {
1117
+ const rule = parseClaudeRule(content);
1118
+ const always = rule.paths.length === 0;
1119
+ const globs = always ? "" : `globs: ${rule.paths.join(", ")}
1120
+ `;
1121
+ return `---
1122
+ description: ${rule.description}
1123
+ ${globs}alwaysApply: ${always}
1124
+ ---
1025
1125
 
1026
- // src/commands/init.ts
1027
- import { mkdirSync as mkdirSync2 } from "fs";
1028
- import path20 from "path";
1029
-
1030
- // src/detect/index.ts
1031
- import { existsSync as existsSync14 } from "fs";
1032
- import path15 from "path";
1033
- import process3 from "process";
1126
+ ${rule.body}`;
1127
+ }
1128
+ function toCursorDiscoveryRule(content) {
1129
+ const rule = parseClaudeRule(content);
1130
+ const body = rule.body.replaceAll("$ARGUMENTS", "what the user asked to (re)discover");
1131
+ return `---
1132
+ description: ${rule.description}
1133
+ alwaysApply: false
1134
+ ---
1034
1135
 
1035
- // src/detect/existing.ts
1036
- import { existsSync as existsSync10, readdirSync as readdirSync5 } from "fs";
1037
- import path11 from "path";
1038
- var ESLINT_CONFIGS = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml"];
1039
- var COMPOSITION_CANDIDATES = ["architecture/composition", "docs/architecture/composition", "docs/composition", "composition"];
1040
- var OPENAPI_CANDIDATES = ["contracts/api/openapi.yaml", "contracts/api/openapi.yml", "contracts/openapi.yaml", "openapi.yaml", "openapi.yml", "openapi.json", "api/openapi.yaml", "docs/openapi.yaml"];
1041
- function anyExists(dir, candidates) {
1042
- return candidates.some((candidate) => existsSync10(path11.join(dir, candidate)));
1136
+ ${body}`;
1043
1137
  }
1044
- function firstExisting(dir, candidates) {
1045
- return candidates.find((candidate) => existsSync10(path11.join(dir, candidate))) ?? null;
1138
+ function mapRulesForTargets(files, ai) {
1139
+ const result = /* @__PURE__ */ new Map();
1140
+ for (const [target, content] of files) {
1141
+ const rule = isClaudeRule(target);
1142
+ const protocol = target === DISCOVERY_PROTOCOL;
1143
+ if (!rule && !protocol) {
1144
+ result.set(target, content);
1145
+ continue;
1146
+ }
1147
+ if (ai !== "cursor")
1148
+ result.set(target, content);
1149
+ if (ai !== "claude")
1150
+ result.set(protocol ? CURSOR_DISCOVERY_RULE : cursorRuleTarget(target), protocol ? toCursorDiscoveryRule(content) : toCursorRule(content));
1151
+ }
1152
+ return result;
1046
1153
  }
1047
- function compositionDir(dir) {
1048
- return COMPOSITION_CANDIDATES.find((candidate) => {
1049
- const absolute = path11.join(dir, candidate);
1050
- return existsSync10(absolute) && readdirSync5(absolute).some((file) => file.endsWith(".yaml") || file.endsWith(".yml"));
1051
- }) ?? null;
1154
+
1155
+ // src/materialize/strategies.ts
1156
+ var MERGE_JSON = /* @__PURE__ */ new Set(["package.json"]);
1157
+ var APPEND_BLOCK = /* @__PURE__ */ new Set([".gitignore", "CLAUDE.md", "AGENTS.md"]);
1158
+ var BLOCK_BEGIN = "<!-- construct:begin -->";
1159
+ var BLOCK_END = "<!-- construct:end -->";
1160
+ var GITIGNORE_BEGIN = "# construct:begin";
1161
+ var GITIGNORE_END = "# construct:end";
1162
+ function strategyFor(target) {
1163
+ const basename = target.split("/").at(-1) ?? target;
1164
+ if (MERGE_JSON.has(basename))
1165
+ return "merge-json";
1166
+ if (APPEND_BLOCK.has(basename))
1167
+ return "append-block";
1168
+ return "create";
1052
1169
  }
1053
- function hasWorkflows(dir) {
1054
- const workflows = path11.join(dir, ".github", "workflows");
1055
- return existsSync10(workflows) && readdirSync5(workflows).some((file) => file.endsWith(".yml") || file.endsWith(".yaml"));
1170
+ function isJsonObject(value) {
1171
+ return typeof value === "object" && value != null && !Array.isArray(value);
1056
1172
  }
1057
- function detectExisting(dir) {
1058
- return {
1059
- packageJson: existsSync10(path11.join(dir, "package.json")),
1060
- tsconfig: existsSync10(path11.join(dir, "tsconfig.json")),
1061
- eslintConfig: anyExists(dir, ESLINT_CONFIGS),
1062
- githubWorkflows: hasWorkflows(dir),
1063
- claudeMd: existsSync10(path11.join(dir, "CLAUDE.md")),
1064
- agentsMd: existsSync10(path11.join(dir, "AGENTS.md")),
1065
- cursorRules: existsSync10(path11.join(dir, ".cursor", "rules")),
1066
- openapi: firstExisting(dir, OPENAPI_CANDIDATES),
1067
- compositionDir: compositionDir(dir),
1068
- constructJson: existsSync10(path11.join(dir, "construct.json"))
1069
- };
1173
+ function mergeJson(existing, incoming, conflicts, prefix = "") {
1174
+ const result = { ...existing };
1175
+ for (const [key, value] of Object.entries(incoming)) {
1176
+ const at = prefix === "" ? key : `${prefix}.${key}`;
1177
+ if (!(key in existing)) {
1178
+ result[key] = value;
1179
+ continue;
1180
+ }
1181
+ const current = existing[key];
1182
+ if (isJsonObject(current) && isJsonObject(value)) {
1183
+ result[key] = mergeJson(current, value, conflicts, at);
1184
+ continue;
1185
+ }
1186
+ if (JSON.stringify(current) !== JSON.stringify(value))
1187
+ conflicts.push(at);
1188
+ }
1189
+ return result;
1070
1190
  }
1071
-
1072
- // src/detect/layout.ts
1073
- import { existsSync as existsSync12, readdirSync as readdirSync6, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
1074
- import path13 from "path";
1075
-
1076
- // src/detect/workspaces.ts
1077
- import { existsSync as existsSync11, readFileSync as readFileSync11 } from "fs";
1078
- import path12 from "path";
1079
- var TOP_LEVEL_PACKAGES_KEY = /^packages:(.*)$/m;
1080
- var EMPTY_FLOW_SEQUENCE = /^\[\s*\]$/;
1081
- var BLOCK_SEQUENCE_ENTRY = /^[ \t]+-[ \t]*\S/;
1082
- function readIfPresent(file) {
1083
- if (!existsSync11(file))
1084
- return null;
1085
- try {
1086
- return readFileSync11(file, "utf8");
1087
- } catch {
1191
+ function blockMarkers(target) {
1192
+ return target.endsWith(".gitignore") ? [GITIGNORE_BEGIN, GITIGNORE_END] : [BLOCK_BEGIN, BLOCK_END];
1193
+ }
1194
+ var DISCOVERY_OPEN = /<!-- construct:discover:([\w-]+) -->/g;
1195
+ function discoveryBlock(document, marker) {
1196
+ const open = `<!-- construct:discover:${marker} -->`;
1197
+ const close = `<!-- /construct:discover:${marker} -->`;
1198
+ const start = document.indexOf(open);
1199
+ const end = document.indexOf(close);
1200
+ if (start === -1 || end === -1 || end < start)
1088
1201
  return null;
1089
- }
1202
+ return { start: start + open.length, end, body: document.slice(start + open.length, end) };
1090
1203
  }
1091
- function startsBlockSequence(rest) {
1092
- for (const line of rest.split("\n")) {
1093
- if (line.trim() === "" || line.trimStart().startsWith("#"))
1204
+ function withoutDiscoveryBodies(document) {
1205
+ let result = document;
1206
+ for (const [, marker] of document.matchAll(DISCOVERY_OPEN)) {
1207
+ const block = discoveryBlock(result, marker);
1208
+ if (block == null)
1094
1209
  continue;
1095
- return BLOCK_SEQUENCE_ENTRY.test(line);
1210
+ result = `${result.slice(0, block.start)}${result.slice(block.end)}`;
1096
1211
  }
1097
- return false;
1098
- }
1099
- function declaresPnpmPackages(dir) {
1100
- const content = readIfPresent(path12.join(dir, "pnpm-workspace.yaml"));
1101
- if (content == null)
1102
- return false;
1103
- const match = TOP_LEVEL_PACKAGES_KEY.exec(content);
1104
- if (match == null)
1105
- return false;
1106
- const inline = match[1].trim();
1107
- if (inline.startsWith("["))
1108
- return !EMPTY_FLOW_SEQUENCE.test(inline);
1109
- if (inline !== "")
1110
- return false;
1111
- return startsBlockSequence(content.slice(match.index + match[0].length));
1212
+ return result;
1112
1213
  }
1113
- function declaresNpmWorkspaces(dir) {
1114
- const content = readIfPresent(path12.join(dir, "package.json"));
1115
- if (content == null)
1116
- return false;
1117
- let workspaces;
1118
- try {
1119
- ({ workspaces } = JSON.parse(content));
1120
- } catch {
1121
- return false;
1214
+ function preserveDiscovery(existing, incoming) {
1215
+ let result = incoming;
1216
+ for (const [, marker] of existing.matchAll(DISCOVERY_OPEN)) {
1217
+ const previous = discoveryBlock(existing, marker);
1218
+ const next = discoveryBlock(result, marker);
1219
+ if (previous == null || next == null || previous.body.trim() === "" || previous.body.includes("_Not discovered yet"))
1220
+ continue;
1221
+ result = `${result.slice(0, next.start)}${previous.body}${result.slice(next.end)}`;
1122
1222
  }
1123
- if (Array.isArray(workspaces))
1124
- return workspaces.length > 0;
1125
- const packages = workspaces?.packages;
1126
- return Array.isArray(packages) && packages.length > 0;
1127
- }
1128
-
1129
- // src/detect/layout.ts
1130
- var IGNORED_ENTRIES = /* @__PURE__ */ new Set([".git", ".DS_Store", ".gitignore", ".gitattributes", "LICENSE", "README.md", ".idea", ".vscode"]);
1131
- function isEmptyDir(dir) {
1132
- if (!existsSync12(dir))
1133
- return true;
1134
- return readdirSync6(dir).every((entry) => IGNORED_ENTRIES.has(entry));
1223
+ return result;
1135
1224
  }
1136
- function detectMonorepoTools(dir) {
1137
- const tools = [];
1138
- if (declaresPnpmPackages(dir))
1139
- tools.push("pnpm-workspace");
1140
- if (declaresNpmWorkspaces(dir))
1141
- tools.push("npm-workspaces");
1142
- if (existsSync12(path13.join(dir, "turbo.json")))
1143
- tools.push("turbo");
1144
- if (existsSync12(path13.join(dir, "nx.json")))
1145
- tools.push("nx");
1146
- return tools;
1225
+ function substituteBlock(existing, produced, target) {
1226
+ const [begin, end] = blockMarkers(target);
1227
+ const opening = existing.indexOf(begin) + begin.length;
1228
+ const closing = existing.indexOf(end);
1229
+ const incoming = produced.slice(produced.indexOf(begin) + begin.length, produced.indexOf(end));
1230
+ return `${existing.slice(0, opening)}${preserveDiscovery(existing, incoming)}${existing.slice(closing)}`;
1147
1231
  }
1148
- function detectWorkspaceDirs(dir) {
1149
- return ["apps", "packages", "libs", "services"].filter((name) => existsSync12(path13.join(dir, name)) && statSync3(path13.join(dir, name)).isDirectory());
1232
+ function withoutSecondH1(existing, block) {
1233
+ if (existing.trim() === "" || !/^# /m.test(existing))
1234
+ return block;
1235
+ return block.replace(/^# (.*)$/m, "## $1");
1150
1236
  }
1151
- function packageName(dir) {
1152
- try {
1153
- const parsed = JSON.parse(readFileSync12(path13.join(dir, "package.json"), "utf8"));
1154
- return typeof parsed.name === "string" && parsed.name !== "" ? parsed.name : null;
1155
- } catch {
1156
- return null;
1157
- }
1237
+ function appendBlock(existing, block, target) {
1238
+ const [begin, end] = blockMarkers(target);
1239
+ const wrapped = `${begin}
1240
+ ${preserveDiscovery(existing, withoutSecondH1(existing, block)).trimEnd()}
1241
+ ${end}
1242
+ `;
1243
+ const start = existing.indexOf(begin);
1244
+ const stop = existing.indexOf(end);
1245
+ if (start !== -1 && stop !== -1 && stop > start)
1246
+ return `${existing.slice(0, start)}${wrapped}${existing.slice(stop + end.length).replace(/^\n/, "")}`;
1247
+ const separator = existing.length === 0 || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
1248
+ return `${existing}${separator}${wrapped}`;
1158
1249
  }
1159
- function detectWorkspacePackages(root, workspaceDirs) {
1160
- return workspaceDirs.flatMap((parent) => readdirSync6(path13.join(root, parent)).sort().map((entry) => `${parent}/${entry}`).filter((dir) => existsSync12(path13.join(root, dir, "package.json"))).map((dir) => ({ dir, name: packageName(path13.join(root, dir)) ?? dir.split("/").at(-1) ?? dir })));
1250
+
1251
+ // src/materialize/templates.ts
1252
+ import { existsSync as existsSync11, readdirSync as readdirSync6, statSync as statSync3 } from "fs";
1253
+ import path13 from "path";
1254
+ import { fileURLToPath as fileURLToPath2 } from "url";
1255
+ var HERE2 = path13.dirname(fileURLToPath2(import.meta.url));
1256
+ function templatesRoot() {
1257
+ const candidates = [path13.resolve(HERE2, "../templates"), path13.resolve(HERE2, "../../templates")];
1258
+ const found = candidates.find((candidate) => existsSync11(candidate));
1259
+ if (found == null)
1260
+ throw new Error(`templates directory not found next to ${HERE2}`);
1261
+ return found;
1161
1262
  }
1162
- function detectLayout(dir, monorepoTools, workspaceDirs, hasSrc) {
1163
- if (isEmptyDir(dir))
1164
- return "empty";
1165
- if (monorepoTools.length > 0 || workspaceDirs.length > 0)
1166
- return "monorepo";
1167
- if (hasSrc || existsSync12(path13.join(dir, "package.json")))
1168
- return "single";
1169
- return "unknown";
1263
+ var EXISTING_SUFFIX = ".existing.eta";
1264
+ function toTargetPath(relative) {
1265
+ const segments = relative.split(path13.sep).map((segment) => segment.startsWith("_") ? `.${segment.slice(1)}` : segment);
1266
+ const joined = segments.join("/");
1267
+ if (joined.endsWith(EXISTING_SUFFIX))
1268
+ return { target: joined.slice(0, -EXISTING_SUFFIX.length), rendered: true, variant: "existing" };
1269
+ return joined.endsWith(".eta") ? { target: joined.slice(0, -".eta".length), rendered: true, variant: "default" } : { target: joined, rendered: false, variant: "default" };
1170
1270
  }
1171
-
1172
- // src/detect/package-manager.ts
1173
- import { execFileSync } from "child_process";
1174
- import { existsSync as existsSync13, readFileSync as readFileSync13 } from "fs";
1175
- import path14 from "path";
1176
- var LOCKFILES = [
1177
- ["pnpm-lock.yaml", "pnpm"],
1178
- ["bun.lockb", "bun"],
1179
- ["bun.lock", "bun"],
1180
- ["yarn.lock", "yarn"],
1181
- ["package-lock.json", "npm"]
1182
- ];
1183
- function fromPackageManagerField(dir) {
1184
- const manifest = path14.join(dir, "package.json");
1185
- if (!existsSync13(manifest))
1186
- return null;
1187
- try {
1188
- const parsed = JSON.parse(readFileSync13(manifest, "utf8"));
1189
- const name = parsed.packageManager?.split("@")[0];
1190
- return name === "pnpm" || name === "npm" || name === "yarn" || name === "bun" ? name : null;
1191
- } catch {
1192
- return null;
1271
+ function walk(root, current, files) {
1272
+ for (const entry of readdirSync6(current).sort()) {
1273
+ const absolute = path13.join(current, entry);
1274
+ if (statSync3(absolute).isDirectory())
1275
+ walk(root, absolute, files);
1276
+ else if (entry !== ".DS_Store")
1277
+ files.push(path13.relative(root, absolute));
1193
1278
  }
1194
1279
  }
1195
- function detectPackageManager(dir) {
1196
- const declared = fromPackageManagerField(dir);
1197
- if (declared != null)
1198
- return declared;
1199
- for (const [lockfile, manager] of LOCKFILES) {
1200
- if (existsSync13(path14.join(dir, lockfile)))
1201
- return manager;
1202
- }
1203
- return existsSync13(path14.join(dir, "package.json")) ? "npm" : "none";
1280
+ function listTemplateFiles(group) {
1281
+ const root = path13.join(templatesRoot(), group);
1282
+ if (!existsSync11(root))
1283
+ throw new Error(`template group "${group}" does not exist`);
1284
+ const files = [];
1285
+ walk(root, root, files);
1286
+ return files.map((relative) => {
1287
+ const { target, rendered, variant } = toTargetPath(relative);
1288
+ return { group, source: path13.join(root, relative), target, rendered, variant };
1289
+ });
1204
1290
  }
1205
- var pnpmVersionCache;
1206
- function detectPnpmVersion() {
1207
- if (pnpmVersionCache !== void 0)
1208
- return pnpmVersionCache;
1209
- try {
1210
- pnpmVersionCache = execFileSync("pnpm", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
1211
- } catch {
1212
- pnpmVersionCache = null;
1213
- }
1214
- return pnpmVersionCache;
1291
+ var BLOCK = /^[ \t]*\{\{#(if|unless) (\w+)\}\}[ \t]*\n([\s\S]*?)^[ \t]*\{\{\/\1\}\}[ \t]*\n/gm;
1292
+ var VARIABLE = /\{\{\s*(\w+)\s*\}\}/g;
1293
+ function lookup(vars, name, match) {
1294
+ const value = vars[name];
1295
+ if (value == null)
1296
+ throw new Error(`template variable "${name}" is not defined (${match})`);
1297
+ return value;
1215
1298
  }
1216
-
1217
- // src/detect/index.ts
1218
- function detect(dir) {
1219
- const root = path15.resolve(dir);
1220
- const monorepoTools = detectMonorepoTools(root);
1221
- const workspaceDirs = detectWorkspaceDirs(root);
1222
- const hasSrc = existsSync14(path15.join(root, "src"));
1223
- return {
1224
- dir: root,
1225
- packageManager: detectPackageManager(root),
1226
- pnpmVersion: detectPnpmVersion(),
1227
- layout: detectLayout(root, monorepoTools, workspaceDirs, hasSrc),
1228
- monorepoTools,
1229
- workspaceDirs,
1230
- workspacePackages: detectWorkspacePackages(root, workspaceDirs),
1231
- hasSrc,
1232
- nodeMajor: Number(process3.versions.node.split(".")[0]),
1233
- existing: detectExisting(root)
1234
- };
1299
+ function isTruthy(value) {
1300
+ return value !== "" && value !== "false";
1235
1301
  }
1236
-
1237
- // src/materialize/apply.ts
1238
- import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1239
- import path16 from "path";
1240
- function applyPlan(root, ops) {
1241
- const written = [];
1242
- for (const op of ops) {
1243
- if (op.action === "skip")
1244
- continue;
1245
- const absolute = path16.join(root, op.target);
1246
- mkdirSync(path16.dirname(absolute), { recursive: true });
1247
- writeFileSync2(absolute, op.content);
1248
- written.push(op);
1249
- }
1250
- return written;
1302
+ function render(template, vars) {
1303
+ const expanded = template.replaceAll(BLOCK, (match, kind, name, body) => isTruthy(lookup(vars, name, match)) === (kind === "if") ? body : "");
1304
+ return expanded.replaceAll(VARIABLE, (match, name) => lookup(vars, name, match));
1251
1305
  }
1252
1306
 
1253
1307
  // src/materialize/plan.ts
1254
- import { existsSync as existsSync16, readFileSync as readFileSync14 } from "fs";
1255
- import path18 from "path";
1256
-
1257
- // src/materialize/rules.ts
1258
- var CLAUDE_RULES_DIR = ".claude/rules/";
1259
- var CURSOR_RULES_DIR = ".cursor/rules/";
1260
- var DISCOVERY_PROTOCOL = ".claude/commands/construct-discover.md";
1261
- var CURSOR_DISCOVERY_RULE = ".cursor/rules/construct-discover.mdc";
1262
- var FRONTMATTER = /^---\n([\s\S]*?)\n---\n/;
1263
- function unquote(value) {
1264
- return value.trim().replace(/^(["'])(.*)\1$/, "$2");
1265
- }
1266
- function parseFrontmatter(block) {
1267
- const paths = [];
1268
- let description;
1269
- let inPaths = false;
1270
- for (const line of block.split("\n")) {
1271
- const item = /^\s*-\s+(\S.*)$/.exec(line);
1272
- if (inPaths && item != null) {
1273
- paths.push(unquote(item[1]));
1274
- continue;
1275
- }
1276
- inPaths = /^paths:\s*$/.test(line);
1277
- const scalar = /^description:\s*(\S.*)$/.exec(line);
1278
- if (scalar != null)
1279
- description = unquote(scalar[1]);
1280
- }
1281
- return { description, paths };
1282
- }
1283
- function firstHeading(body) {
1284
- return /^#\s+(\S.*)$/m.exec(body)?.[1].trim();
1285
- }
1286
- function parseClaudeRule(content) {
1287
- const match = FRONTMATTER.exec(content);
1288
- const body = match == null ? content : content.slice(match[0].length).replace(/^\n+/, "");
1289
- const front = match == null ? { paths: [] } : parseFrontmatter(match[1]);
1290
- return { description: front.description ?? firstHeading(body) ?? "Project rule", paths: front.paths, body };
1291
- }
1292
- function isClaudeRule(target) {
1293
- return target.startsWith(CLAUDE_RULES_DIR) && target.endsWith(".md");
1294
- }
1295
- function cursorRuleTarget(target) {
1296
- return `${CURSOR_RULES_DIR}${target.slice(CLAUDE_RULES_DIR.length, -".md".length)}.mdc`;
1297
- }
1298
- function toCursorRule(content) {
1299
- const rule = parseClaudeRule(content);
1300
- const always = rule.paths.length === 0;
1301
- const globs = always ? "" : `globs: ${rule.paths.join(", ")}
1302
- `;
1303
- return `---
1304
- description: ${rule.description}
1305
- ${globs}alwaysApply: ${always}
1306
- ---
1307
-
1308
- ${rule.body}`;
1309
- }
1310
- function toCursorDiscoveryRule(content) {
1311
- const rule = parseClaudeRule(content);
1312
- const body = rule.body.replaceAll("$ARGUMENTS", "what the user asked to (re)discover");
1313
- return `---
1314
- description: ${rule.description}
1315
- alwaysApply: false
1316
- ---
1317
-
1318
- ${body}`;
1319
- }
1320
- function mapRulesForTargets(files, ai) {
1321
- const result = /* @__PURE__ */ new Map();
1322
- for (const [target, content] of files) {
1323
- const rule = isClaudeRule(target);
1324
- const protocol = target === DISCOVERY_PROTOCOL;
1325
- if (!rule && !protocol) {
1326
- result.set(target, content);
1327
- continue;
1328
- }
1329
- if (ai !== "cursor")
1330
- result.set(target, content);
1331
- if (ai !== "claude")
1332
- result.set(protocol ? CURSOR_DISCOVERY_RULE : cursorRuleTarget(target), protocol ? toCursorDiscoveryRule(content) : toCursorRule(content));
1333
- }
1334
- return result;
1335
- }
1336
-
1337
- // src/materialize/strategies.ts
1338
- var MERGE_JSON = /* @__PURE__ */ new Set(["package.json"]);
1339
- var APPEND_BLOCK = /* @__PURE__ */ new Set([".gitignore", "CLAUDE.md", "AGENTS.md"]);
1340
- var BLOCK_BEGIN = "<!-- construct:begin -->";
1341
- var BLOCK_END = "<!-- construct:end -->";
1342
- var GITIGNORE_BEGIN = "# construct:begin";
1343
- var GITIGNORE_END = "# construct:end";
1344
- function strategyFor(target) {
1345
- const basename = target.split("/").at(-1) ?? target;
1346
- if (MERGE_JSON.has(basename))
1347
- return "merge-json";
1348
- if (APPEND_BLOCK.has(basename))
1349
- return "append-block";
1350
- return "create";
1351
- }
1352
- function isObject(value) {
1353
- return typeof value === "object" && value != null && !Array.isArray(value);
1354
- }
1355
- function mergeJson(existing, incoming, conflicts, prefix = "") {
1356
- const result = { ...existing };
1357
- for (const [key, value] of Object.entries(incoming)) {
1358
- const at = prefix === "" ? key : `${prefix}.${key}`;
1359
- if (!(key in existing)) {
1360
- result[key] = value;
1361
- continue;
1362
- }
1363
- const current = existing[key];
1364
- if (isObject(current) && isObject(value)) {
1365
- result[key] = mergeJson(current, value, conflicts, at);
1366
- continue;
1367
- }
1368
- if (JSON.stringify(current) !== JSON.stringify(value))
1369
- conflicts.push(at);
1370
- }
1371
- return result;
1372
- }
1373
- function markersFor(target) {
1374
- return target.endsWith(".gitignore") ? [GITIGNORE_BEGIN, GITIGNORE_END] : [BLOCK_BEGIN, BLOCK_END];
1375
- }
1376
- var DISCOVERY_OPEN = /<!-- construct:discover:([\w-]+) -->/g;
1377
- function discoveryBlock(document, marker) {
1378
- const open = `<!-- construct:discover:${marker} -->`;
1379
- const close = `<!-- /construct:discover:${marker} -->`;
1380
- const start = document.indexOf(open);
1381
- const end = document.indexOf(close);
1382
- if (start === -1 || end === -1 || end < start)
1383
- return null;
1384
- return { start: start + open.length, end, body: document.slice(start + open.length, end) };
1385
- }
1386
- function preserveDiscovery(existing, incoming) {
1387
- let result = incoming;
1388
- for (const [, marker] of existing.matchAll(DISCOVERY_OPEN)) {
1389
- const previous = discoveryBlock(existing, marker);
1390
- const next = discoveryBlock(result, marker);
1391
- if (previous == null || next == null || previous.body.trim() === "" || previous.body.includes("_Not discovered yet"))
1392
- continue;
1393
- result = `${result.slice(0, next.start)}${previous.body}${result.slice(next.end)}`;
1394
- }
1395
- return result;
1396
- }
1397
- function withoutSecondH1(existing, block) {
1398
- if (existing.trim() === "" || !/^# /m.test(existing))
1399
- return block;
1400
- return block.replace(/^# (.*)$/m, "## $1");
1401
- }
1402
- function appendBlock(existing, block, target) {
1403
- const [begin, end] = markersFor(target);
1404
- const wrapped = `${begin}
1405
- ${preserveDiscovery(existing, withoutSecondH1(existing, block)).trimEnd()}
1406
- ${end}
1407
- `;
1408
- const start = existing.indexOf(begin);
1409
- const stop = existing.indexOf(end);
1410
- if (start !== -1 && stop !== -1 && stop > start)
1411
- return `${existing.slice(0, start)}${wrapped}${existing.slice(stop + end.length).replace(/^\n/, "")}`;
1412
- const separator = existing.length === 0 || existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
1413
- return `${existing}${separator}${wrapped}`;
1414
- }
1415
-
1416
- // src/materialize/templates.ts
1417
- import { existsSync as existsSync15, readdirSync as readdirSync7, statSync as statSync4 } from "fs";
1418
- import path17 from "path";
1419
- import { fileURLToPath } from "url";
1420
- var HERE = path17.dirname(fileURLToPath(import.meta.url));
1421
- function templatesRoot() {
1422
- const candidates = [path17.resolve(HERE, "../templates"), path17.resolve(HERE, "../../templates")];
1423
- const found = candidates.find((candidate) => existsSync15(candidate));
1424
- if (found == null)
1425
- throw new Error(`templates directory not found next to ${HERE}`);
1426
- return found;
1427
- }
1428
- var EXISTING_SUFFIX = ".existing.eta";
1429
- function toTargetPath(relative) {
1430
- const segments = relative.split(path17.sep).map((segment) => segment.startsWith("_") ? `.${segment.slice(1)}` : segment);
1431
- const joined = segments.join("/");
1432
- if (joined.endsWith(EXISTING_SUFFIX))
1433
- return { target: joined.slice(0, -EXISTING_SUFFIX.length), rendered: true, variant: "existing" };
1434
- return joined.endsWith(".eta") ? { target: joined.slice(0, -".eta".length), rendered: true, variant: "default" } : { target: joined, rendered: false, variant: "default" };
1435
- }
1436
- function walk(root, current, files) {
1437
- for (const entry of readdirSync7(current).sort()) {
1438
- const absolute = path17.join(current, entry);
1439
- if (statSync4(absolute).isDirectory())
1440
- walk(root, absolute, files);
1441
- else if (entry !== ".DS_Store")
1442
- files.push(path17.relative(root, absolute));
1443
- }
1444
- }
1445
- function listTemplateFiles(group) {
1446
- const root = path17.join(templatesRoot(), group);
1447
- if (!existsSync15(root))
1448
- throw new Error(`template group "${group}" does not exist`);
1449
- const files = [];
1450
- walk(root, root, files);
1451
- return files.map((relative) => {
1452
- const { target, rendered, variant } = toTargetPath(relative);
1453
- return { group, source: path17.join(root, relative), target, rendered, variant };
1454
- });
1455
- }
1456
- var BLOCK = /^[ \t]*\{\{#(if|unless) (\w+)\}\}[ \t]*\n([\s\S]*?)^[ \t]*\{\{\/\1\}\}[ \t]*\n/gm;
1457
- var VARIABLE = /\{\{\s*(\w+)\s*\}\}/g;
1458
- function lookup(vars, name, match) {
1459
- const value = vars[name];
1460
- if (value == null)
1461
- throw new Error(`template variable "${name}" is not defined (${match})`);
1462
- return value;
1463
- }
1464
- function isTruthy(value) {
1465
- return value !== "" && value !== "false";
1466
- }
1467
- function render(template, vars) {
1468
- const expanded = template.replaceAll(BLOCK, (match, kind, name, body) => isTruthy(lookup(vars, name, match)) === (kind === "if") ? body : "");
1469
- return expanded.replaceAll(VARIABLE, (match, name) => lookup(vars, name, match));
1470
- }
1471
-
1472
- // src/materialize/plan.ts
1473
- function toMount(group) {
1474
- return typeof group === "string" ? { group } : group;
1308
+ function toMount(group) {
1309
+ return typeof group === "string" ? { group } : group;
1475
1310
  }
1476
1311
  function mountTarget(mount, target) {
1477
1312
  return mount.into == null || mount.into === "." ? target : `${mount.into.replace(/\/$/, "")}/${target}`;
1478
1313
  }
1479
1314
  function readTemplate(source, rendered, vars) {
1480
- const raw = readFileSync14(source, "utf8");
1315
+ const raw = readFileSync12(source, "utf8");
1481
1316
  return rendered ? render(raw, vars) : raw;
1482
1317
  }
1483
1318
  var SORTED_SECTIONS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
@@ -1535,12 +1370,13 @@ function layerJson(earlier, later) {
1535
1370
  var NOT_ADDED_TO_EXISTING_MANIFEST = ["version"];
1536
1371
  function planOne(root, target, content, conflicts, existingVariant) {
1537
1372
  const strategy = strategyFor(target);
1538
- const absolute = path18.join(root, target);
1539
- const exists = existsSync16(absolute);
1540
- if (!exists)
1541
- return { target, strategy, action: "create", content: strategy === "append-block" ? appendBlock("", content, target) : content };
1373
+ const absolute = path14.join(root, target);
1374
+ const exists = existsSync12(absolute);
1375
+ if (!exists) {
1376
+ return strategy === "append-block" ? { target, strategy, action: "create", content: appendBlock("", content, target), variant: "default" } : { target, strategy, action: "create", content };
1377
+ }
1542
1378
  if (strategy === "merge-json") {
1543
- const existing = JSON.parse(readFileSync14(absolute, "utf8"));
1379
+ const existing = JSON.parse(readFileSync12(absolute, "utf8"));
1544
1380
  const incoming = JSON.parse(content);
1545
1381
  for (const key of NOT_ADDED_TO_EXISTING_MANIFEST)
1546
1382
  delete incoming[key];
@@ -1551,8 +1387,14 @@ function planOne(root, target, content, conflicts, existingVariant) {
1551
1387
  ` };
1552
1388
  }
1553
1389
  if (strategy === "append-block") {
1554
- const existing = readFileSync14(absolute, "utf8");
1555
- return { target, strategy, action: "append", content: appendBlock(existing, existingVariant ?? content, target) };
1390
+ const existing = readFileSync12(absolute, "utf8");
1391
+ return {
1392
+ target,
1393
+ strategy,
1394
+ action: "append",
1395
+ content: appendBlock(existing, existingVariant ?? content, target),
1396
+ variant: existingVariant == null ? "default" : "existing"
1397
+ };
1556
1398
  }
1557
1399
  return { target, strategy, action: "skip", content, note: "exists, review manually" };
1558
1400
  }
@@ -1577,142 +1419,743 @@ function planMaterialize(root, groups, vars, options) {
1577
1419
  layered.set(target, previous == null || strategyFor(target) !== "merge-json" ? content : layerJson(previous, content));
1578
1420
  }
1579
1421
  }
1580
- const ops = [...mapRulesForTargets(layered, options.ai).entries()].map(([target, content]) => planOne(root, target, content, conflicts, existingVariants.get(target))).sort((a, b) => a.target.localeCompare(b.target));
1581
- return { ops, conflicts, omittedGroups };
1422
+ const ops = [...mapRulesForTargets(layered, options.ai).entries()].map(([target, content]) => planOne(root, target, content, conflicts, existingVariants.get(target))).sort((a, b) => a.target.localeCompare(b.target));
1423
+ return { ops, conflicts, omittedGroups, existingVariants: Object.fromEntries(existingVariants) };
1424
+ }
1425
+
1426
+ // src/presets/index.ts
1427
+ var AI_TARGET_LABELS = {
1428
+ claude: "Claude Code",
1429
+ cursor: "Cursor",
1430
+ both: "Claude Code, Cursor"
1431
+ };
1432
+ var EXPRESS_APP = "stacks/express-api/app";
1433
+ var EXPRESS_REPO = "stacks/express-api/repo";
1434
+ var HTTP_CONTRACT = "stacks/http-contract";
1435
+ function sampleWorkspace(scope) {
1436
+ return [
1437
+ { dir: "packages/shared", name: `${scope}/shared` },
1438
+ { dir: "apps/api", name: `${scope}/api` }
1439
+ ];
1440
+ }
1441
+ function quote(value) {
1442
+ return `'${value}'`;
1443
+ }
1444
+ function renderWorkspacePolicy(packages, sample) {
1445
+ const names = packages.map((pkg) => pkg.name);
1446
+ const allowedFor = (pkg) => {
1447
+ if (sample)
1448
+ return pkg.dir.startsWith("apps/") ? names.filter((name) => name !== pkg.name) : [];
1449
+ return names.filter((name) => name !== pkg.name);
1450
+ };
1451
+ const lines = packages.map((pkg) => ` ${quote(pkg.dir)}: [${allowedFor(pkg).map(quote).join(", ")}],`);
1452
+ return {
1453
+ workspacePackages: `[${names.map(quote).join(", ")}]`,
1454
+ allowedWorkspaceImports: `{
1455
+ ${lines.join("\n")}
1456
+ }`
1457
+ };
1458
+ }
1459
+ var PRESETS = {
1460
+ "node-backend": {
1461
+ id: "node-backend",
1462
+ label: "Node.js backend",
1463
+ description: "Express + TypeScript, contract-first HTTP API, composition root, harness",
1464
+ groups: [
1465
+ "base",
1466
+ "harness",
1467
+ HTTP_CONTRACT,
1468
+ { group: EXPRESS_APP, onlyWhenEmpty: true },
1469
+ { group: EXPRESS_REPO, onlyWhenEmpty: true },
1470
+ { group: "presets/node-backend/sample", onlyWhenEmpty: true },
1471
+ "presets/node-backend/baseline"
1472
+ ],
1473
+ contracts: true,
1474
+ available: true,
1475
+ vars: () => ({
1476
+ contractPath: "contracts/api/openapi.yaml",
1477
+ contractTypesOutput: "src/contracts/openapi.ts",
1478
+ contractTypesImport: "./openapi.js",
1479
+ contractPathFromConfig: "../contracts/api/openapi.yaml",
1480
+ appRoot: ""
1481
+ })
1482
+ },
1483
+ "node-frontend": {
1484
+ id: "node-frontend",
1485
+ label: "Node.js frontend",
1486
+ description: "Vite + TypeScript, platform CSS rules, composition root, harness; no API contract",
1487
+ groups: [
1488
+ "base",
1489
+ "harness",
1490
+ { group: "presets/node-frontend/sample", onlyWhenEmpty: true },
1491
+ "presets/node-frontend/baseline"
1492
+ ],
1493
+ contracts: false,
1494
+ available: true,
1495
+ vars: () => ({
1496
+ contractPath: "",
1497
+ contractTypesOutput: ""
1498
+ })
1499
+ },
1500
+ "node-library": {
1501
+ id: "node-library",
1502
+ label: "Node.js library or CLI",
1503
+ description: "TypeScript package with no HTTP contract: architecture policy, composition models, harness",
1504
+ groups: ["base", "harness"],
1505
+ contracts: false,
1506
+ available: true,
1507
+ vars: () => ({
1508
+ contractPath: "",
1509
+ contractTypesOutput: ""
1510
+ })
1511
+ },
1512
+ "monorepo": {
1513
+ id: "monorepo",
1514
+ label: "pnpm monorepo",
1515
+ description: "apps/* + packages/*, catalog:, contract types in packages/shared, dependency policy in lint",
1516
+ groups: [
1517
+ "base",
1518
+ "harness",
1519
+ HTTP_CONTRACT,
1520
+ { group: EXPRESS_APP, into: "apps/api", onlyWhenEmpty: true },
1521
+ { group: EXPRESS_REPO, onlyWhenEmpty: true },
1522
+ { group: "presets/monorepo/sample", onlyWhenEmpty: true },
1523
+ "presets/monorepo/baseline"
1524
+ ],
1525
+ contracts: true,
1526
+ available: true,
1527
+ vars: (report, projectName) => {
1528
+ const detected = report.workspacePackages;
1529
+ const packages = detected.length > 0 ? detected : sampleWorkspace(`@${projectName}`);
1530
+ return {
1531
+ contractPath: "contracts/api/openapi.yaml",
1532
+ contractTypesOutput: "packages/shared/src/api/openapi.ts",
1533
+ contractTypesImport: `@${projectName}/shared`,
1534
+ contractPathFromConfig: "../../../contracts/api/openapi.yaml",
1535
+ appRoot: "apps/api/",
1536
+ ...renderWorkspacePolicy(packages, detected.length === 0)
1537
+ };
1538
+ }
1539
+ }
1540
+ };
1541
+ var PRESET_IDS = Object.keys(PRESETS);
1542
+ var PRESET_LIST = Object.values(PRESETS);
1543
+ function isPresetId(value) {
1544
+ return value in PRESETS;
1545
+ }
1546
+ function getPreset(id) {
1547
+ return PRESETS[id];
1548
+ }
1549
+ function aiGroups(target) {
1550
+ return target === "both" ? ["ai/shared", "ai/claude", "ai/cursor"] : ["ai/shared", `ai/${target}`];
1551
+ }
1552
+ var DEFAULT_REVIEW_MODEL = "claude-sonnet-5";
1553
+ function reviewGroups(provider) {
1554
+ return provider === "claude" ? ["ai/review"] : [];
1555
+ }
1556
+ function defaultProjectName(dir) {
1557
+ const base = dir.split(/[\\/]/).filter(Boolean).at(-1) ?? "project";
1558
+ return base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
1559
+ }
1560
+
1561
+ // src/sync/ownership.ts
1562
+ function ownedText(target, content) {
1563
+ if (strategyFor(target) !== "append-block")
1564
+ return content;
1565
+ if (!carriesConstructBlock(target, content))
1566
+ return "";
1567
+ const [begin, end] = blockMarkers(target);
1568
+ return withoutDiscoveryBodies(content.slice(content.indexOf(begin) + begin.length, content.indexOf(end)));
1569
+ }
1570
+ function blockSpansDocument(target, content) {
1571
+ const [begin, end] = blockMarkers(target);
1572
+ const before = content.slice(0, content.indexOf(begin));
1573
+ const after = content.slice(content.indexOf(end) + end.length);
1574
+ return `${before}${after}`.trim() === "";
1575
+ }
1576
+ function carriesConstructBlock(target, content) {
1577
+ const [begin, end] = blockMarkers(target);
1578
+ const start = content.indexOf(begin);
1579
+ const stop = content.indexOf(end);
1580
+ return start !== -1 && stop > start;
1581
+ }
1582
+ function ownedSha(target, content) {
1583
+ return sha256(ownedText(target, content));
1584
+ }
1585
+ function matchesRecordedSha(recorded, target, content) {
1586
+ const asOwnedViewWrittenBySync = ownedSha(target, content);
1587
+ const asWholeFileWrittenByInit = sha256(content);
1588
+ return recorded === asOwnedViewWrittenBySync || recorded === asWholeFileWrittenByInit;
1589
+ }
1590
+ function parseObject(content) {
1591
+ try {
1592
+ const parsed = JSON.parse(content);
1593
+ return isJsonObject(parsed) ? parsed : null;
1594
+ } catch {
1595
+ return null;
1596
+ }
1597
+ }
1598
+ function compareKeys(present, produced, prefix) {
1599
+ const owned = [];
1600
+ for (const [key, value] of Object.entries(produced)) {
1601
+ const at = prefix === "" ? key : `${prefix}.${key}`;
1602
+ if (!(key in present)) {
1603
+ owned.push({ key: at, class: "add" });
1604
+ continue;
1605
+ }
1606
+ const current = present[key];
1607
+ if (isJsonObject(current) && isJsonObject(value)) {
1608
+ owned.push(...compareKeys(current, value, at));
1609
+ continue;
1610
+ }
1611
+ owned.push({ key: at, class: JSON.stringify(current) === JSON.stringify(value) ? "keep" : "conflict" });
1612
+ }
1613
+ return owned;
1614
+ }
1615
+ function ownedKeys(present, produced) {
1616
+ const current = parseObject(present);
1617
+ const template = parseObject(produced);
1618
+ if (current == null || template == null)
1619
+ return null;
1620
+ return compareKeys(current, template, "");
1621
+ }
1622
+
1623
+ // src/sync/classify.ts
1624
+ var PATH_CLASSES = ["add", "keep", "update", "conflict", "unknown", "removed", "orphaned", "foreign"];
1625
+ var BLOCK_REPLACED_WHOLE_DISCOVERY_BODIES_CARRIED_OVER = "block-replaced-whole-discovery-bodies-carried-over";
1626
+ function classFromKeys(keys) {
1627
+ if (keys.some((key) => key.class === "conflict"))
1628
+ return "conflict";
1629
+ return keys.some((key) => key.class === "add") ? "update" : "keep";
1630
+ }
1631
+ function compareDeclaredBlock(target, present, produced, variant) {
1632
+ if (!carriesConstructBlock(target, present))
1633
+ return "conflict";
1634
+ if (variant == null)
1635
+ return "unknown";
1636
+ return ownedText(target, present) === ownedText(target, produced) ? "keep" : "update";
1637
+ }
1638
+ function shapeSuggests(target, present) {
1639
+ return blockSpansDocument(target, present) ? "default" : "existing";
1640
+ }
1641
+ function compareOwnedView(target, recordedSha, present, produced) {
1642
+ if (ownedText(target, present) === ownedText(target, produced))
1643
+ return "keep";
1644
+ if (matchesRecordedSha(recordedSha, target, present))
1645
+ return "update";
1646
+ return "conflict";
1647
+ }
1648
+ function classifyPath(state) {
1649
+ const { target, recordedSha, present, produced } = state;
1650
+ const variant = state.variant ?? null;
1651
+ const strategy = strategyFor(target);
1652
+ const classified = (value, keys = []) => ({
1653
+ target,
1654
+ strategy,
1655
+ class: value,
1656
+ keys,
1657
+ writeEffect: writeEffectFor(strategy, value),
1658
+ ...strategy === "append-block" ? { variant } : {},
1659
+ ...value === "unknown" && present != null ? { shape: shapeSuggests(target, present) } : {}
1660
+ });
1661
+ if (present == null) {
1662
+ if (recordedSha != null)
1663
+ return classified("removed");
1664
+ return produced == null ? null : classified("add");
1665
+ }
1666
+ if (produced == null)
1667
+ return classified(recordedSha == null ? "foreign" : "orphaned");
1668
+ if (strategy === "merge-json") {
1669
+ const keys = ownedKeys(present, produced);
1670
+ if (keys == null)
1671
+ return classified("conflict");
1672
+ return classified(recordedSha == null ? "conflict" : classFromKeys(keys), keys);
1673
+ }
1674
+ if (recordedSha == null)
1675
+ return classified("conflict");
1676
+ if (strategy === "append-block")
1677
+ return classified(compareDeclaredBlock(target, present, produced, variant));
1678
+ return classified(compareOwnedView(target, recordedSha, present, produced));
1679
+ }
1680
+ var WRITABLE_CLASSES = /* @__PURE__ */ new Set(["add", "update"]);
1681
+ var WRITABLE_STRATEGIES = /* @__PURE__ */ new Set(["create", "append-block"]);
1682
+ function willBeWritten(strategy, value) {
1683
+ return WRITABLE_CLASSES.has(value) && WRITABLE_STRATEGIES.has(strategy);
1684
+ }
1685
+ function writeEffectFor(strategy, value) {
1686
+ return strategy === "append-block" && willBeWritten(strategy, value) ? BLOCK_REPLACED_WHOLE_DISCOVERY_BODIES_CARRIED_OVER : null;
1687
+ }
1688
+ function isWritable(classification) {
1689
+ return willBeWritten(classification.strategy, classification.class);
1690
+ }
1691
+ function classifyRepository(state) {
1692
+ const targets = [.../* @__PURE__ */ new Set([...Object.keys(state.recorded), ...Object.keys(state.present), ...Object.keys(state.produced)])].sort();
1693
+ return targets.flatMap((target) => {
1694
+ const classification = classifyPath({
1695
+ target,
1696
+ recordedSha: state.recorded[target] ?? null,
1697
+ present: state.present[target] ?? null,
1698
+ produced: state.produced[target] ?? null,
1699
+ variant: state.variants?.[target] ?? null
1700
+ });
1701
+ return classification == null ? [] : [classification];
1702
+ });
1703
+ }
1704
+
1705
+ // src/sync/variant.ts
1706
+ function existingForm(target, template) {
1707
+ return appendBlock("", template, target);
1708
+ }
1709
+ function shasOfDefaultForm(target, producedDefault) {
1710
+ return [sha256(producedDefault), ownedSha(target, producedDefault)];
1711
+ }
1712
+ function shasOfExistingForm(target, template, present) {
1713
+ return [sha256(appendBlock(present, template, target)), ownedSha(target, existingForm(target, template))];
1714
+ }
1715
+ function establishVariant(input) {
1716
+ if (input.recordedVariant != null)
1717
+ return { variant: input.recordedVariant, evidence: "recorded" };
1718
+ if (input.existingTemplate == null)
1719
+ return { variant: "default", evidence: "sole-variant" };
1720
+ if (input.recordedSha == null)
1721
+ return null;
1722
+ const reconstructed = [
1723
+ ["default", shasOfDefaultForm(input.target, input.producedDefault)],
1724
+ ["existing", shasOfExistingForm(input.target, input.existingTemplate, input.present)]
1725
+ ];
1726
+ const matched = reconstructed.filter(([, shas]) => shas.includes(input.recordedSha)).map(([variant]) => variant);
1727
+ return matched.length === 1 ? { variant: matched[0], evidence: "reconstructed" } : null;
1728
+ }
1729
+
1730
+ // src/sync/replay.ts
1731
+ var NO_TREE_TO_PLAN_AGAINST = path15.join(tmpdir(), "mikoshi-construct-replay-renders-against-no-tree");
1732
+ function replayedGroups(manifest) {
1733
+ const preset = getPreset(manifest.preset);
1734
+ return [...preset.groups, ...aiGroups(manifest.ai), ...reviewGroups(manifest.review?.provider ?? "none")];
1735
+ }
1736
+ function varsRecordingMisses(manifest, version, facts, missed) {
1737
+ const asRecordedExceptTheRunningVersion = { ...manifest.vars, constructVersion: version };
1738
+ return new Proxy(asRecordedExceptTheRunningVersion, {
1739
+ get(target, key) {
1740
+ if (typeof key !== "string")
1741
+ return Reflect.get(target, key);
1742
+ const value = target[key];
1743
+ if (value != null)
1744
+ return value;
1745
+ const established = facts[key];
1746
+ if (established != null)
1747
+ return established;
1748
+ missed.add(key);
1749
+ return "";
1750
+ }
1751
+ });
1752
+ }
1753
+ function missingVariables(manifest, missed) {
1754
+ const names = [...missed].sort().join(", ");
1755
+ return `construct.json was written by construct ${manifest.construct} and carries no value for ${names}, which today's ${manifest.preset} templates render. Add ${missed.size === 1 ? "it" : "each of them"} under "vars" in construct.json and run the sync again.`;
1756
+ }
1757
+ function contentByTarget(ops) {
1758
+ return Object.fromEntries(ops.map((op) => [op.target, op.content]));
1759
+ }
1760
+ function producedByTemplates(manifest, vars, recorded) {
1761
+ const groups = replayedGroups(manifest);
1762
+ const plan = (emptyTarget) => planMaterialize(NO_TREE_TO_PLAN_AGAINST, groups, vars, { emptyTarget, ai: manifest.ai });
1763
+ const withoutSamples = plan(false);
1764
+ const kept = contentByTarget(withoutSamples.ops);
1765
+ if (withoutSamples.omittedGroups.length === 0)
1766
+ return { produced: kept, existingVariants: withoutSamples.existingVariants };
1767
+ const withSamples = plan(true);
1768
+ const sampled = contentByTarget(withSamples.ops);
1769
+ const sampleWasMaterialized = Object.keys(sampled).some((target) => !(target in kept) && recorded[target] != null);
1770
+ return sampleWasMaterialized ? { produced: sampled, existingVariants: withSamples.existingVariants } : { produced: kept, existingVariants: withoutSamples.existingVariants };
1771
+ }
1772
+ function establishedVariants(input) {
1773
+ const recordedVariant = recordedVariants(input.manifest);
1774
+ const established = {};
1775
+ for (const [target, producedDefault] of Object.entries(input.templates.produced)) {
1776
+ const present = input.present[target];
1777
+ if (strategyFor(target) !== "append-block" || present == null)
1778
+ continue;
1779
+ const variant = establishVariant({
1780
+ target,
1781
+ recordedVariant: recordedVariant[target] ?? null,
1782
+ recordedSha: input.recorded[target] ?? null,
1783
+ present,
1784
+ producedDefault,
1785
+ existingTemplate: input.templates.existingVariants[target] ?? null
1786
+ });
1787
+ if (variant != null)
1788
+ established[target] = variant;
1789
+ }
1790
+ return established;
1791
+ }
1792
+ function producedInTheVariantThatWroteIt(templates, variants) {
1793
+ const produced = { ...templates.produced };
1794
+ for (const [target, established] of Object.entries(variants)) {
1795
+ const template = templates.existingVariants[target];
1796
+ if (established.variant === "existing" && template != null)
1797
+ produced[target] = existingForm(target, template);
1798
+ }
1799
+ return produced;
1800
+ }
1801
+ function presentInTree(root, targets) {
1802
+ const present = {};
1803
+ for (const target of new Set(targets)) {
1804
+ const absolute = path15.join(root, target);
1805
+ if (existsSync13(absolute))
1806
+ present[target] = readFileSync13(absolute, "utf8");
1807
+ }
1808
+ return present;
1809
+ }
1810
+ function replay(input) {
1811
+ const missed = /* @__PURE__ */ new Set();
1812
+ const vars = varsRecordingMisses(input.manifest, input.version, input.facts, missed);
1813
+ const recorded = recordedShas(input.manifest);
1814
+ const templates = producedByTemplates(input.manifest, vars, recorded);
1815
+ if (missed.size > 0)
1816
+ throw new Error(missingVariables(input.manifest, missed));
1817
+ const present = presentInTree(input.root, [...Object.keys(recorded), ...Object.keys(templates.produced)]);
1818
+ const variants = establishedVariants({ manifest: input.manifest, recorded, present, templates });
1819
+ const produced = producedInTheVariantThatWroteIt(templates, variants);
1820
+ return {
1821
+ fromVersion: input.manifest.construct,
1822
+ toVersion: input.version,
1823
+ present,
1824
+ produced,
1825
+ variants,
1826
+ classifications: classifyRepository({ recorded, present, produced, variants })
1827
+ };
1828
+ }
1829
+
1830
+ // src/sync/write.ts
1831
+ var PENDING_CLASSES = ["add", "update"];
1832
+ function isPending(classification) {
1833
+ return PENDING_CLASSES.includes(classification.class);
1834
+ }
1835
+ function contentToWrite(classification, input) {
1836
+ const produced = input.produced[classification.target] ?? "";
1837
+ const present = input.present[classification.target];
1838
+ if (classification.strategy !== "append-block" || present == null)
1839
+ return produced;
1840
+ return substituteBlock(present, produced, classification.target);
1841
+ }
1842
+ function variantWritten(classification, input) {
1843
+ if (classification.strategy !== "append-block")
1844
+ return null;
1845
+ return classification.variant?.variant ?? (input.present[classification.target] == null ? "default" : null);
1846
+ }
1847
+ function plannedWrite(classification, input) {
1848
+ const content = contentToWrite(classification, input);
1849
+ return {
1850
+ target: classification.target,
1851
+ strategy: classification.strategy,
1852
+ content,
1853
+ ownedSha: ownedSha(classification.target, content),
1854
+ variant: variantWritten(classification, input),
1855
+ writeEffect: classification.writeEffect
1856
+ };
1857
+ }
1858
+ function planWrites(input) {
1859
+ const writes = [];
1860
+ const refused = [];
1861
+ for (const classification of input.classifications) {
1862
+ if (isWritable(classification))
1863
+ writes.push(plannedWrite(classification, input));
1864
+ else if (isPending(classification))
1865
+ refused.push(classification);
1866
+ }
1867
+ return { writes, refused };
1868
+ }
1869
+
1870
+ // src/commands/doctor/version-gap.ts
1871
+ function versionGap(root, manifest, version) {
1872
+ try {
1873
+ const { fromVersion, toVersion, classifications } = replay({ root, manifest, version, facts: factsTheRepositoryEstablishes(root) });
1874
+ return { materializedBy: fromVersion, readBy: toVersion, pending: classifications.filter(isPending).length };
1875
+ } catch {
1876
+ return { materializedBy: manifest.construct, readBy: version, pending: null };
1877
+ }
1878
+ }
1879
+
1880
+ // src/commands/doctor/report.ts
1881
+ function checkLine(ui2, check) {
1882
+ return ` ${check.id.padEnd(16)} ${check.level} ${check.state.padEnd(8)} ${ui2.theme.dim(check.evidence)}`;
1883
+ }
1884
+ function printChecks(ui2, checks) {
1885
+ ui2.line();
1886
+ ui2.line(ui2.theme.accent(ui2.lore.enforcement));
1887
+ for (const check of checks)
1888
+ ui2.line(checkLine(ui2, check));
1889
+ }
1890
+ function printProvenance(ui2, provenance) {
1891
+ const authored = constructAuthored(provenance);
1892
+ if (authored.length === 0)
1893
+ return;
1894
+ ui2.line();
1895
+ ui2.line(ui2.theme.accent(ui2.lore.provenance));
1896
+ for (const reading of authored)
1897
+ ui2.line(` ${reading.marker.padEnd(20)} ${ui2.theme.dim(reading.file)}`);
1898
+ ui2.line(ui2.theme.dim(` ${ui2.lore.stillConstructAuthored(authored.length)}`));
1899
+ }
1900
+ function gapReading(ui2, gap) {
1901
+ if (gap.pending == null)
1902
+ return ui2.lore.baselineGapUnknown;
1903
+ return gap.pending === 0 ? ui2.lore.baselineCurrent : ui2.lore.baselineMoved(gap.pending);
1904
+ }
1905
+ function printVersionGap(ui2, gap) {
1906
+ ui2.line(ui2.theme.dim(` ${ui2.lore.syncVersionGap(gap.materializedBy, gap.readBy)}`));
1907
+ ui2.line(ui2.theme.dim(` ${gapReading(ui2, gap)}`));
1908
+ }
1909
+ function printWeakestLink(ui2, weakest) {
1910
+ ui2.line();
1911
+ if (weakest == null) {
1912
+ ui2.line(ui2.theme.bold(ui2.lore.weakestLinkNone));
1913
+ return;
1914
+ }
1915
+ const lift = ui2.lore.levelLift[weakest.level];
1916
+ ui2.line(`${ui2.theme.bold(ui2.lore.weakestLink(weakest.id, weakest.level))}${lift == null ? "" : ui2.theme.dim(` \u2014 ${lift}`)}`);
1917
+ }
1918
+ function printDoctor(ui2, result) {
1919
+ if (result == null) {
1920
+ ui2.flatline("No construct.json here. Run `construct init` first.");
1921
+ return 1;
1922
+ }
1923
+ if (result.harnessProblems.length > 0)
1924
+ ui2.glitch("Harness is broken.", result.harnessProblems);
1925
+ if (result.missingFiles.length > 0)
1926
+ ui2.glitch("Baseline files are missing.", result.missingFiles);
1927
+ if (result.missingDiscovery.length > 0)
1928
+ ui2.glitch(ui2.lore.discoveryIncomplete, ["", "Missing:", ...result.missingDiscovery.map((marker) => ` ${marker}`), "", "Run: claude \u2192 /construct-discover"]);
1929
+ if (result.warnings.length > 0)
1930
+ ui2.glitch(ui2.lore.typecheckCaveat, result.warnings);
1931
+ if (result.modifiedFiles.length > 0)
1932
+ ui2.line(ui2.theme.dim(` ${result.modifiedFiles.length} baseline files modified since init (expected once the project evolves).`));
1933
+ printVersionGap(ui2, result.versionGap);
1934
+ if (result.ok)
1935
+ ui2.ok(ui2.lore.stable);
1936
+ printProvenance(ui2, result.provenance);
1937
+ printChecks(ui2, result.checks);
1938
+ printWeakestLink(ui2, result.weakestLink);
1939
+ return result.ok ? 0 : 1;
1940
+ }
1941
+
1942
+ // src/commands/doctor/index.ts
1943
+ function runDoctor(root, version = VERSION) {
1944
+ const manifest = readManifest(root);
1945
+ if (manifest == null)
1946
+ return null;
1947
+ const evidence = gatherEvidence(root, manifest);
1948
+ const baseline = baselineVerdict(root, manifest);
1949
+ const problems = harnessProblems(root, manifest, evidence.harness);
1950
+ const checks = [
1951
+ lintPolicyCheck(evidence),
1952
+ constructTestsCheck(evidence),
1953
+ ciCheck(evidence),
1954
+ hookCheck(evidence),
1955
+ redGateCheck(evidence)
1956
+ ];
1957
+ return {
1958
+ ok: baseline.missingFiles.length === 0 && problems.length === 0,
1959
+ missingFiles: baseline.missingFiles,
1960
+ modifiedFiles: baseline.modifiedFiles,
1961
+ missingDiscovery: missingDiscovery(root, manifest),
1962
+ provenance: discoveryProvenance(root, manifest),
1963
+ harnessProblems: problems,
1964
+ warnings: typecheckWarnings(manifest.preset, evidence),
1965
+ checks,
1966
+ weakestLink: weakestLink(checks),
1967
+ versionGap: versionGap(root, manifest, version)
1968
+ };
1969
+ }
1970
+
1971
+ // src/commands/init.ts
1972
+ import { mkdirSync as mkdirSync2 } from "fs";
1973
+ import path21 from "path";
1974
+
1975
+ // src/detect/index.ts
1976
+ import { existsSync as existsSync17 } from "fs";
1977
+ import path19 from "path";
1978
+ import process3 from "process";
1979
+
1980
+ // src/detect/layout.ts
1981
+ import { existsSync as existsSync15, readdirSync as readdirSync7, readFileSync as readFileSync15, statSync as statSync4 } from "fs";
1982
+ import path17 from "path";
1983
+
1984
+ // src/detect/workspaces.ts
1985
+ import { existsSync as existsSync14, readFileSync as readFileSync14 } from "fs";
1986
+ import path16 from "path";
1987
+ var TOP_LEVEL_PACKAGES_KEY = /^packages:(.*)$/m;
1988
+ var EMPTY_FLOW_SEQUENCE = /^\[\s*\]$/;
1989
+ var BLOCK_SEQUENCE_ENTRY = /^[ \t]+-[ \t]*\S/;
1990
+ function readIfPresent(file) {
1991
+ if (!existsSync14(file))
1992
+ return null;
1993
+ try {
1994
+ return readFileSync14(file, "utf8");
1995
+ } catch {
1996
+ return null;
1997
+ }
1998
+ }
1999
+ function startsBlockSequence(rest) {
2000
+ for (const line of rest.split("\n")) {
2001
+ if (line.trim() === "" || line.trimStart().startsWith("#"))
2002
+ continue;
2003
+ return BLOCK_SEQUENCE_ENTRY.test(line);
2004
+ }
2005
+ return false;
2006
+ }
2007
+ function declaresPnpmPackages(dir) {
2008
+ const content = readIfPresent(path16.join(dir, "pnpm-workspace.yaml"));
2009
+ if (content == null)
2010
+ return false;
2011
+ const match = TOP_LEVEL_PACKAGES_KEY.exec(content);
2012
+ if (match == null)
2013
+ return false;
2014
+ const inline = match[1].trim();
2015
+ if (inline.startsWith("["))
2016
+ return !EMPTY_FLOW_SEQUENCE.test(inline);
2017
+ if (inline !== "")
2018
+ return false;
2019
+ return startsBlockSequence(content.slice(match.index + match[0].length));
2020
+ }
2021
+ function declaresNpmWorkspaces(dir) {
2022
+ const content = readIfPresent(path16.join(dir, "package.json"));
2023
+ if (content == null)
2024
+ return false;
2025
+ let workspaces;
2026
+ try {
2027
+ ({ workspaces } = JSON.parse(content));
2028
+ } catch {
2029
+ return false;
2030
+ }
2031
+ if (Array.isArray(workspaces))
2032
+ return workspaces.length > 0;
2033
+ const packages = workspaces?.packages;
2034
+ return Array.isArray(packages) && packages.length > 0;
2035
+ }
2036
+
2037
+ // src/detect/layout.ts
2038
+ var IGNORED_ENTRIES = /* @__PURE__ */ new Set([".git", ".DS_Store", ".gitignore", ".gitattributes", "LICENSE", "README.md", ".idea", ".vscode"]);
2039
+ function isEmptyDir(dir) {
2040
+ if (!existsSync15(dir))
2041
+ return true;
2042
+ return readdirSync7(dir).every((entry) => IGNORED_ENTRIES.has(entry));
2043
+ }
2044
+ function detectMonorepoTools(dir) {
2045
+ const tools = [];
2046
+ if (declaresPnpmPackages(dir))
2047
+ tools.push("pnpm-workspace");
2048
+ if (declaresNpmWorkspaces(dir))
2049
+ tools.push("npm-workspaces");
2050
+ if (existsSync15(path17.join(dir, "turbo.json")))
2051
+ tools.push("turbo");
2052
+ if (existsSync15(path17.join(dir, "nx.json")))
2053
+ tools.push("nx");
2054
+ return tools;
2055
+ }
2056
+ function detectWorkspaceDirs(dir) {
2057
+ return ["apps", "packages", "libs", "services"].filter((name) => existsSync15(path17.join(dir, name)) && statSync4(path17.join(dir, name)).isDirectory());
2058
+ }
2059
+ function packageName(dir) {
2060
+ try {
2061
+ const parsed = JSON.parse(readFileSync15(path17.join(dir, "package.json"), "utf8"));
2062
+ return typeof parsed.name === "string" && parsed.name !== "" ? parsed.name : null;
2063
+ } catch {
2064
+ return null;
2065
+ }
2066
+ }
2067
+ function detectWorkspacePackages(root, workspaceDirs) {
2068
+ return workspaceDirs.flatMap((parent) => readdirSync7(path17.join(root, parent)).sort().map((entry) => `${parent}/${entry}`).filter((dir) => existsSync15(path17.join(root, dir, "package.json"))).map((dir) => ({ dir, name: packageName(path17.join(root, dir)) ?? dir.split("/").at(-1) ?? dir })));
2069
+ }
2070
+ function detectLayout(dir, monorepoTools, workspaceDirs, hasSrc) {
2071
+ if (isEmptyDir(dir))
2072
+ return "empty";
2073
+ if (monorepoTools.length > 0 || workspaceDirs.length > 0)
2074
+ return "monorepo";
2075
+ if (hasSrc || existsSync15(path17.join(dir, "package.json")))
2076
+ return "single";
2077
+ return "unknown";
2078
+ }
2079
+
2080
+ // src/detect/package-manager.ts
2081
+ import { execFileSync } from "child_process";
2082
+ import { existsSync as existsSync16, readFileSync as readFileSync16 } from "fs";
2083
+ import path18 from "path";
2084
+ var LOCKFILES = [
2085
+ ["pnpm-lock.yaml", "pnpm"],
2086
+ ["bun.lockb", "bun"],
2087
+ ["bun.lock", "bun"],
2088
+ ["yarn.lock", "yarn"],
2089
+ ["package-lock.json", "npm"]
2090
+ ];
2091
+ function fromPackageManagerField(dir) {
2092
+ const manifest = path18.join(dir, "package.json");
2093
+ if (!existsSync16(manifest))
2094
+ return null;
2095
+ try {
2096
+ const parsed = JSON.parse(readFileSync16(manifest, "utf8"));
2097
+ const name = parsed.packageManager?.split("@")[0];
2098
+ return name === "pnpm" || name === "npm" || name === "yarn" || name === "bun" ? name : null;
2099
+ } catch {
2100
+ return null;
2101
+ }
2102
+ }
2103
+ function detectPackageManager(dir) {
2104
+ const declared = fromPackageManagerField(dir);
2105
+ if (declared != null)
2106
+ return declared;
2107
+ for (const [lockfile, manager] of LOCKFILES) {
2108
+ if (existsSync16(path18.join(dir, lockfile)))
2109
+ return manager;
2110
+ }
2111
+ return existsSync16(path18.join(dir, "package.json")) ? "npm" : "none";
2112
+ }
2113
+ var pnpmVersionCache;
2114
+ function detectPnpmVersion() {
2115
+ if (pnpmVersionCache !== void 0)
2116
+ return pnpmVersionCache;
2117
+ try {
2118
+ pnpmVersionCache = execFileSync("pnpm", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
2119
+ } catch {
2120
+ pnpmVersionCache = null;
2121
+ }
2122
+ return pnpmVersionCache;
1582
2123
  }
1583
2124
 
1584
- // src/presets/index.ts
1585
- var AI_TARGET_LABELS = {
1586
- claude: "Claude Code",
1587
- cursor: "Cursor",
1588
- both: "Claude Code, Cursor"
1589
- };
1590
- var EXPRESS_APP = "stacks/express-api/app";
1591
- var EXPRESS_REPO = "stacks/express-api/repo";
1592
- var HTTP_CONTRACT = "stacks/http-contract";
1593
- function sampleWorkspace(scope) {
1594
- return [
1595
- { dir: "packages/shared", name: `${scope}/shared` },
1596
- { dir: "apps/api", name: `${scope}/api` }
1597
- ];
1598
- }
1599
- function quote(value) {
1600
- return `'${value}'`;
1601
- }
1602
- function renderWorkspacePolicy(packages, sample) {
1603
- const names = packages.map((pkg) => pkg.name);
1604
- const allowedFor = (pkg) => {
1605
- if (sample)
1606
- return pkg.dir.startsWith("apps/") ? names.filter((name) => name !== pkg.name) : [];
1607
- return names.filter((name) => name !== pkg.name);
1608
- };
1609
- const lines = packages.map((pkg) => ` ${quote(pkg.dir)}: [${allowedFor(pkg).map(quote).join(", ")}],`);
2125
+ // src/detect/index.ts
2126
+ function detect(dir) {
2127
+ const root = path19.resolve(dir);
2128
+ const monorepoTools = detectMonorepoTools(root);
2129
+ const workspaceDirs = detectWorkspaceDirs(root);
2130
+ const hasSrc = existsSync17(path19.join(root, "src"));
1610
2131
  return {
1611
- workspacePackages: `[${names.map(quote).join(", ")}]`,
1612
- allowedWorkspaceImports: `{
1613
- ${lines.join("\n")}
1614
- }`
2132
+ dir: root,
2133
+ packageManager: detectPackageManager(root),
2134
+ pnpmVersion: detectPnpmVersion(),
2135
+ layout: detectLayout(root, monorepoTools, workspaceDirs, hasSrc),
2136
+ monorepoTools,
2137
+ workspaceDirs,
2138
+ workspacePackages: detectWorkspacePackages(root, workspaceDirs),
2139
+ hasSrc,
2140
+ nodeMajor: Number(process3.versions.node.split(".")[0]),
2141
+ existing: detectExisting(root)
1615
2142
  };
1616
2143
  }
1617
- var PRESETS = {
1618
- "node-backend": {
1619
- id: "node-backend",
1620
- label: "Node.js backend",
1621
- description: "Express + TypeScript, contract-first HTTP API, composition root, harness",
1622
- groups: [
1623
- "base",
1624
- "harness",
1625
- HTTP_CONTRACT,
1626
- { group: EXPRESS_APP, onlyWhenEmpty: true },
1627
- { group: EXPRESS_REPO, onlyWhenEmpty: true },
1628
- "presets/node-backend/baseline"
1629
- ],
1630
- contracts: true,
1631
- available: true,
1632
- vars: () => ({
1633
- contractPath: "contracts/api/openapi.yaml",
1634
- contractTypesOutput: "src/contracts/openapi.ts",
1635
- contractTypesImport: "./openapi.js",
1636
- contractPathFromConfig: "../contracts/api/openapi.yaml",
1637
- appRoot: ""
1638
- })
1639
- },
1640
- "node-frontend": {
1641
- id: "node-frontend",
1642
- label: "Node.js frontend",
1643
- description: "Vite + TypeScript, platform CSS rules, composition root, harness; no API contract",
1644
- groups: [
1645
- "base",
1646
- "harness",
1647
- { group: "presets/node-frontend/sample", onlyWhenEmpty: true },
1648
- "presets/node-frontend/baseline"
1649
- ],
1650
- contracts: false,
1651
- available: true,
1652
- vars: () => ({
1653
- contractPath: "",
1654
- contractTypesOutput: ""
1655
- })
1656
- },
1657
- "node-library": {
1658
- id: "node-library",
1659
- label: "Node.js library or CLI",
1660
- description: "TypeScript package with no HTTP contract: architecture policy, composition models, harness",
1661
- groups: ["base", "harness"],
1662
- contracts: false,
1663
- available: true,
1664
- vars: () => ({
1665
- contractPath: "",
1666
- contractTypesOutput: ""
1667
- })
1668
- },
1669
- "monorepo": {
1670
- id: "monorepo",
1671
- label: "pnpm monorepo",
1672
- description: "apps/* + packages/*, catalog:, contract types in packages/shared, dependency policy in lint",
1673
- groups: [
1674
- "base",
1675
- "harness",
1676
- HTTP_CONTRACT,
1677
- { group: EXPRESS_APP, into: "apps/api", onlyWhenEmpty: true },
1678
- { group: EXPRESS_REPO, onlyWhenEmpty: true },
1679
- { group: "presets/monorepo/sample", onlyWhenEmpty: true },
1680
- "presets/monorepo/baseline"
1681
- ],
1682
- contracts: true,
1683
- available: true,
1684
- vars: (report, projectName) => {
1685
- const detected = report.workspacePackages;
1686
- const packages = detected.length > 0 ? detected : sampleWorkspace(`@${projectName}`);
1687
- return {
1688
- contractPath: "contracts/api/openapi.yaml",
1689
- contractTypesOutput: "packages/shared/src/api/openapi.ts",
1690
- contractTypesImport: `@${projectName}/shared`,
1691
- contractPathFromConfig: "../../../contracts/api/openapi.yaml",
1692
- appRoot: "apps/api/",
1693
- ...renderWorkspacePolicy(packages, detected.length === 0)
1694
- };
1695
- }
2144
+
2145
+ // src/materialize/apply.ts
2146
+ import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
2147
+ import path20 from "path";
2148
+ function applyPlan(root, ops) {
2149
+ const written = [];
2150
+ for (const op of ops) {
2151
+ if (op.action === "skip")
2152
+ continue;
2153
+ const absolute = path20.join(root, op.target);
2154
+ mkdirSync(path20.dirname(absolute), { recursive: true });
2155
+ writeFileSync2(absolute, op.content);
2156
+ written.push(op);
1696
2157
  }
1697
- };
1698
- var PRESET_IDS = Object.keys(PRESETS);
1699
- var PRESET_LIST = Object.values(PRESETS);
1700
- function isPresetId(value) {
1701
- return value in PRESETS;
1702
- }
1703
- function getPreset(id) {
1704
- return PRESETS[id];
1705
- }
1706
- function aiGroups(target) {
1707
- return target === "both" ? ["ai/shared", "ai/claude", "ai/cursor"] : ["ai/shared", `ai/${target}`];
1708
- }
1709
- var DEFAULT_REVIEW_MODEL = "claude-sonnet-5";
1710
- function reviewGroups(provider) {
1711
- return provider === "claude" ? ["ai/review"] : [];
1712
- }
1713
- function defaultProjectName(dir) {
1714
- const base = dir.split(/[\\/]/).filter(Boolean).at(-1) ?? "project";
1715
- return base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2158
+ return written;
1716
2159
  }
1717
2160
 
1718
2161
  // src/ui/prompts.ts
@@ -1785,24 +2228,6 @@ function createClackPrompter(lore, streams = {}) {
1785
2228
  };
1786
2229
  }
1787
2230
 
1788
- // src/version.ts
1789
- import { readFileSync as readFileSync15 } from "fs";
1790
- import path19 from "path";
1791
- import { fileURLToPath as fileURLToPath2 } from "url";
1792
- var HERE2 = path19.dirname(fileURLToPath2(import.meta.url));
1793
- function readVersion() {
1794
- for (const candidate of ["../package.json", "../../package.json"]) {
1795
- try {
1796
- const parsed = JSON.parse(readFileSync15(path19.resolve(HERE2, candidate), "utf8"));
1797
- if (parsed.name === "mikoshi-construct" && parsed.version != null)
1798
- return parsed.version;
1799
- } catch {
1800
- }
1801
- }
1802
- return "0.0.0";
1803
- }
1804
- var VERSION = readVersion();
1805
-
1806
2231
  // src/commands/soulkill.ts
1807
2232
  function yesNo(value) {
1808
2233
  return value ? "yes" : "no";
@@ -1826,6 +2251,13 @@ function printDetectReport(ui2, report) {
1826
2251
 
1827
2252
  // src/commands/init.ts
1828
2253
  var CANCELLED = /* @__PURE__ */ Symbol("cancelled");
2254
+ var RESTATED_BY_THE_RUNNING_BINARY = ["constructVersion"];
2255
+ function varsThisRunChanged(previous, vars) {
2256
+ return Object.entries(vars).filter(([name]) => !RESTATED_BY_THE_RUNNING_BINARY.includes(name)).flatMap(([name, to]) => {
2257
+ const from = previous.vars[name];
2258
+ return from == null || from === to ? [] : [{ name, from, to }];
2259
+ });
2260
+ }
1829
2261
  function aborted(skipped = [], conflicts = []) {
1830
2262
  return { status: "aborted", written: [], skipped, conflicts };
1831
2263
  }
@@ -1893,7 +2325,7 @@ async function askChoices(ui2, options, report, root, prompter) {
1893
2325
  return { presetId, ai, projectName, review };
1894
2326
  }
1895
2327
  async function runInit(ui2, options, prompter) {
1896
- const root = path20.resolve(options.dir);
2328
+ const root = path21.resolve(options.dir);
1897
2329
  mkdirSync2(root, { recursive: true });
1898
2330
  if (!options.yes && prompter == null) {
1899
2331
  ui2.glitch(ui2.lore.needsTerminal);
@@ -1931,7 +2363,7 @@ async function runInit(ui2, options, prompter) {
1931
2363
  contracts: preset.contracts ? "true" : "false",
1932
2364
  contractPath: "contracts/api/openapi.yaml",
1933
2365
  contractTypesOutput: "src/contracts/openapi.ts",
1934
- compositionDir: report.existing.compositionDir ?? "architecture/composition",
2366
+ compositionDir: report.existing.compositionDir ?? DEFAULT_COMPOSITION_DIR,
1935
2367
  harnessCommand: "pnpm run quality",
1936
2368
  packageManager: "pnpm",
1937
2369
  pnpmVersion: report.pnpmVersion ?? "",
@@ -1957,7 +2389,7 @@ async function runInit(ui2, options, prompter) {
1957
2389
  ui2.line();
1958
2390
  if (plan.omittedGroups.length > 0)
1959
2391
  ui2.line(ui2.theme.dim(` ${ui2.lore.sampleOmitted}`));
1960
- if (report.existing.compositionDir != null && report.existing.compositionDir !== "architecture/composition")
2392
+ if (report.existing.compositionDir != null && report.existing.compositionDir !== DEFAULT_COMPOSITION_DIR)
1961
2393
  ui2.line(ui2.theme.dim(` Existing composition models found at ${report.existing.compositionDir}/ \u2014 kept there, not moved.`));
1962
2394
  if (plan.conflicts.length > 0)
1963
2395
  ui2.glitch("Existing values kept; review these keys by hand:", plan.conflicts);
@@ -1970,7 +2402,17 @@ async function runInit(ui2, options, prompter) {
1970
2402
  if (interactive != null && await interactive.confirm(ui2.lore.confirm) !== true)
1971
2403
  return aborted(skipped, plan.conflicts);
1972
2404
  const written = applyPlan(root, plan.ops);
1973
- writeManifest(root, buildManifest({ version: VERSION, preset: presetId, ai, review, vars, written, contracts: preset.contracts }));
2405
+ const previous = readManifest(root);
2406
+ const manifest = buildManifest({ version: VERSION, preset: presetId, ai, review, vars, written, contracts: preset.contracts, previous });
2407
+ writeManifest(root, manifest);
2408
+ if (previous != null) {
2409
+ const carriedOver = Object.keys(previous.files).filter((target) => !written.some((op) => op.target === target)).length;
2410
+ const added = written.filter((op) => previous.files[op.target] == null).length;
2411
+ ui2.line(ui2.theme.dim(` ${ui2.lore.recordCarriedOver(carriedOver, added)}`));
2412
+ const changed = varsThisRunChanged(previous, vars);
2413
+ if (changed.length > 0)
2414
+ ui2.line(ui2.theme.dim(` ${ui2.lore.recordVarsChanged(changed)}`));
2415
+ }
1974
2416
  ui2.phase(4, 4, "\u2705", ui2.lore.phaseOnline);
1975
2417
  ui2.tree([
1976
2418
  ["Written", `${written.length} files`],
@@ -1980,10 +2422,202 @@ async function runInit(ui2, options, prompter) {
1980
2422
  return { status: "done", written: written.map((op) => op.target), skipped, conflicts: plan.conflicts };
1981
2423
  }
1982
2424
 
2425
+ // src/commands/sync/report.ts
2426
+ var SYNC_EXIT = {
2427
+ upToDate: 0,
2428
+ noManifest: 1,
2429
+ pending: 2
2430
+ };
2431
+ var SYNC_APPLY_EXIT = {
2432
+ written: 0,
2433
+ noManifest: 1,
2434
+ refused: 2
2435
+ };
2436
+ var LISTED_CLASSES = ["add", "update", "conflict", "unknown", "removed", "orphaned"];
2437
+ var CLASS_COLUMN = Math.max(...PATH_CLASSES.map((value) => value.length)) + 2;
2438
+ function pendingCount(report) {
2439
+ return PENDING_CLASSES.reduce((total, value) => total + report.counts[value], 0);
2440
+ }
2441
+ function syncExit(report) {
2442
+ if (report == null)
2443
+ return SYNC_EXIT.noManifest;
2444
+ return pendingCount(report) === 0 ? SYNC_EXIT.upToDate : SYNC_EXIT.pending;
2445
+ }
2446
+ function actionableKeys(entry) {
2447
+ return entry.keys.filter((key) => key.class !== "keep").map((key) => `${key.key} (${key.class})`);
2448
+ }
2449
+ function syncJson(report) {
2450
+ return {
2451
+ fromVersion: report.fromVersion,
2452
+ toVersion: report.toVersion,
2453
+ counts: report.counts,
2454
+ paths: report.classifications.map((entry) => ({
2455
+ target: entry.target,
2456
+ class: entry.class,
2457
+ strategy: entry.strategy,
2458
+ ...entry.keys.length === 0 ? {} : { keys: entry.keys },
2459
+ ...entry.variant == null ? {} : { variant: entry.variant.variant, variantEvidence: entry.variant.evidence },
2460
+ ...entry.shape == null ? {} : { shape: entry.shape },
2461
+ ...entry.writeEffect == null ? {} : { writeEffect: entry.writeEffect }
2462
+ }))
2463
+ };
2464
+ }
2465
+ function note(ui2, entry) {
2466
+ if (entry.strategy === "merge-json") {
2467
+ const keys = actionableKeys(entry);
2468
+ return keys.length === 0 ? "" : ui2.lore.syncMergedKeys(keys);
2469
+ }
2470
+ if (entry.class === "unknown")
2471
+ return ui2.lore.syncVariantUnknown(entry.shape ?? "");
2472
+ return entry.writeEffect == null ? "" : ui2.lore.syncWriteEffect[entry.writeEffect] ?? "";
2473
+ }
2474
+ var COUNT_ORDER = ["add", "update", "conflict", "unknown", "removed", "orphaned", "keep", "foreign"];
2475
+ function printCounts(ui2, report) {
2476
+ ui2.line(ui2.theme.accent(ui2.lore.syncClasses));
2477
+ for (const value of COUNT_ORDER) {
2478
+ if (report.counts[value] === 0)
2479
+ continue;
2480
+ const meaning = ui2.lore.syncClassMeaning[value] ?? "";
2481
+ ui2.line(` ${value.padEnd(CLASS_COLUMN)}${String(report.counts[value]).padStart(3)} ${ui2.theme.dim(meaning)}`);
2482
+ }
2483
+ }
2484
+ function printPaths(ui2, report) {
2485
+ for (const value of LISTED_CLASSES) {
2486
+ const entries = report.classifications.filter((entry) => entry.class === value);
2487
+ if (entries.length === 0)
2488
+ continue;
2489
+ ui2.line();
2490
+ ui2.line(`${ui2.theme.accent(value)} ${ui2.theme.dim(`\u2014 ${ui2.lore.syncClassMeaning[value] ?? ""}`)}`);
2491
+ for (const entry of entries) {
2492
+ const detail = note(ui2, entry);
2493
+ ui2.line(` ${entry.target}${detail === "" ? "" : ui2.theme.dim(` \u2014 ${detail}`)}`);
2494
+ }
2495
+ }
2496
+ }
2497
+ function printMergedNote(ui2, report) {
2498
+ const listed = report.classifications.filter((entry) => LISTED_CLASSES.includes(entry.class));
2499
+ if (!listed.some((entry) => entry.strategy === "merge-json"))
2500
+ return;
2501
+ ui2.line();
2502
+ ui2.line(ui2.theme.dim(ui2.lore.syncMergedNotWritten));
2503
+ }
2504
+ function printSync(ui2, report) {
2505
+ if (report == null) {
2506
+ ui2.flatline(ui2.lore.syncNoManifest);
2507
+ return SYNC_EXIT.noManifest;
2508
+ }
2509
+ ui2.line(ui2.theme.accent(ui2.theme.bold(ui2.lore.syncTitle)));
2510
+ ui2.line(ui2.theme.bold(ui2.lore.syncVersionGap(report.fromVersion, report.toVersion)));
2511
+ ui2.line();
2512
+ printCounts(ui2, report);
2513
+ printPaths(ui2, report);
2514
+ printMergedNote(ui2, report);
2515
+ const pending = pendingCount(report);
2516
+ ui2.line();
2517
+ ui2.line(pending === 0 ? ui2.lore.syncNothingToWrite : ui2.lore.syncPending(pending));
2518
+ return syncExit(report);
2519
+ }
2520
+ function syncApplyExit(result) {
2521
+ if (result == null)
2522
+ return SYNC_APPLY_EXIT.noManifest;
2523
+ return result.refused.length === 0 ? SYNC_APPLY_EXIT.written : SYNC_APPLY_EXIT.refused;
2524
+ }
2525
+ function syncApplyJson(result) {
2526
+ return {
2527
+ ...syncJson(result.report),
2528
+ written: result.written,
2529
+ pending: result.refused.map((entry) => entry.target),
2530
+ ranAt: result.ranAt
2531
+ };
2532
+ }
2533
+ function printWritten(ui2, result) {
2534
+ if (result.written.length === 0)
2535
+ return;
2536
+ const byTarget = new Map(result.report.classifications.map((entry) => [entry.target, entry]));
2537
+ ui2.line();
2538
+ ui2.line(ui2.theme.accent(ui2.lore.syncApplyWritten));
2539
+ for (const target of result.written) {
2540
+ const entry = byTarget.get(target);
2541
+ const detail = entry == null ? "" : note(ui2, entry);
2542
+ ui2.line(` ${target}${detail === "" ? "" : ui2.theme.dim(` \u2014 ${detail}`)}`);
2543
+ }
2544
+ }
2545
+ function printUnknownVariants(ui2, result) {
2546
+ const unknown = result.report.classifications.filter((entry) => entry.class === "unknown");
2547
+ if (unknown.length === 0)
2548
+ return;
2549
+ ui2.line();
2550
+ ui2.line(ui2.theme.accent(ui2.lore.syncApplyUnknown));
2551
+ for (const entry of unknown)
2552
+ ui2.line(` ${entry.target}${ui2.theme.dim(` \u2014 ${note(ui2, entry)}`)}`);
2553
+ }
2554
+ function printRefused(ui2, result) {
2555
+ if (result.refused.length === 0)
2556
+ return;
2557
+ ui2.line();
2558
+ ui2.line(ui2.theme.accent(ui2.lore.syncApplyRefused));
2559
+ for (const entry of result.refused) {
2560
+ const detail = note(ui2, entry);
2561
+ ui2.line(` ${entry.target}${detail === "" ? "" : ui2.theme.dim(` \u2014 ${detail}`)}`);
2562
+ }
2563
+ ui2.line(ui2.theme.dim(ui2.lore.syncMergedNotWritten));
2564
+ }
2565
+ function printSyncApply(ui2, result) {
2566
+ if (result == null) {
2567
+ ui2.flatline(ui2.lore.syncNoManifest);
2568
+ return SYNC_APPLY_EXIT.noManifest;
2569
+ }
2570
+ ui2.line(ui2.theme.accent(ui2.theme.bold(ui2.lore.syncApplyTitle)));
2571
+ ui2.line(ui2.theme.bold(ui2.lore.syncVersionGap(result.report.fromVersion, result.report.toVersion)));
2572
+ printWritten(ui2, result);
2573
+ printUnknownVariants(ui2, result);
2574
+ printRefused(ui2, result);
2575
+ ui2.line();
2576
+ ui2.line(result.written.length === 0 ? ui2.lore.syncApplyNothingWritten : ui2.lore.syncApplyWrote(result.written.length));
2577
+ if (result.refused.length > 0)
2578
+ ui2.line(ui2.lore.syncApplyLeftToYou(result.refused.length));
2579
+ return syncApplyExit(result);
2580
+ }
2581
+
2582
+ // src/commands/sync/index.ts
2583
+ function countByClass(classifications) {
2584
+ const counts = Object.fromEntries(PATH_CLASSES.map((value) => [value, 0]));
2585
+ for (const entry of classifications)
2586
+ counts[entry.class] += 1;
2587
+ return counts;
2588
+ }
2589
+ function runSync(root, version) {
2590
+ const manifest = readManifest(root);
2591
+ if (manifest == null)
2592
+ return null;
2593
+ const { fromVersion, toVersion, classifications } = replay({ root, manifest, version, facts: factsTheRepositoryEstablishes(root) });
2594
+ return { fromVersion, toVersion, counts: countByClass(classifications), classifications };
2595
+ }
2596
+ function applySync(root, version) {
2597
+ const manifest = readManifest(root);
2598
+ if (manifest == null)
2599
+ return null;
2600
+ const { fromVersion, toVersion, present, produced, classifications } = replay({ root, manifest, version, facts: factsTheRepositoryEstablishes(root) });
2601
+ const report = { fromVersion, toVersion, counts: countByClass(classifications), classifications };
2602
+ const { writes, refused } = planWrites({ classifications, present, produced });
2603
+ const written = applyPlan(root, writes.map((write) => ({ target: write.target, strategy: write.strategy, action: "create", content: write.content })));
2604
+ const ranAt = (/* @__PURE__ */ new Date()).toISOString();
2605
+ if (writes.length > 0) {
2606
+ writeManifest(root, recordSync(manifest, {
2607
+ ranAt,
2608
+ toVersion: version,
2609
+ files: Object.fromEntries(writes.map((write) => [write.target, write.ownedSha])),
2610
+ variants: Object.fromEntries(writes.flatMap((write) => write.variant == null ? [] : [[write.target, write.variant]]))
2611
+ }));
2612
+ }
2613
+ return { report, written: written.map((op) => op.target), refused, ranAt };
2614
+ }
2615
+
1983
2616
  // src/ui/console.ts
1984
2617
  import process4 from "process";
1985
2618
 
1986
2619
  // src/ui/lore.ts
2620
+ var BLOCK_REPLACED_WHOLE = "block-replaced-whole-discovery-bodies-carried-over";
1987
2621
  var BANNER = String.raw`
1988
2622
  ███╗ ███╗██╗██╗ ██╗ ██████╗ ███████╗██╗ ██╗██╗
1989
2623
  ████╗ ████║██║██║ ██╔╝██╔═══██╗██╔════╝██║ ██║██║
@@ -2021,6 +2655,9 @@ var LORE = {
2021
2655
  discoveryIncomplete: "Discovery incomplete.",
2022
2656
  provenance: "AUTHORSHIP TRACE",
2023
2657
  stillConstructAuthored: (count) => `Still the construct's own words: ${count} marker${count === 1 ? "" : "s"} nobody has stood behind yet.`,
2658
+ baselineCurrent: "The baseline reads back what today's templates produce.",
2659
+ baselineMoved: (count) => `THE BASELINE MOVED ON: ${count} recorded path${count === 1 ? "" : "s"} a sync would add or update \u2014 run \`construct sync\`.`,
2660
+ baselineGapUnknown: "What a sync would add or update cannot be established from this manifest: run `construct sync`.",
2024
2661
  enforcement: "ENFORCEMENT TRACE",
2025
2662
  typecheckCaveat: "Typecheck cannot carry this stack alone.",
2026
2663
  weakestLink: (id, level) => `WEAKEST LINK: ${id} at ${level}`,
@@ -2046,7 +2683,38 @@ var LORE = {
2046
2683
  ledgerMalformed: (count) => `${count} ledger line${count === 1 ? "" : "s"} could not be read as a run record.`,
2047
2684
  ledgerDrift: (entriesWithoutSession, sessionsWithoutEntry, unjoinable) => `Ledger against the traces it claims: ${entriesWithoutSession} entries with no session, ${sessionsWithoutEntry} sessions with no entry, ${unjoinable} entries with no run id.`,
2048
2685
  ledgerEntryWithoutSession: "logged as a run, no session behind it",
2049
- ledgerSessionWithoutEntry: "ran, never logged"
2686
+ ledgerSessionWithoutEntry: "ran, never logged",
2687
+ syncTitle: "BRAINDANCE \u2014 ENGRAM REPLAY",
2688
+ syncClasses: "PATH CLASSES",
2689
+ syncClassMeaning: {
2690
+ add: "not in the tree; the templates produce it",
2691
+ update: "the construct owns this and the template moved on",
2692
+ conflict: "yours \u2014 you wrote or changed it; sync never touches these",
2693
+ unknown: "which template variant wrote this block cannot be established; you changed nothing, and sync writes nothing here",
2694
+ removed: "you deleted it; sync never puts it back",
2695
+ orphaned: "the construct wrote it once and no longer produces it; it is yours now",
2696
+ keep: "already what the templates produce",
2697
+ foreign: "never ours"
2698
+ },
2699
+ syncMergedKeys: (keys) => `keys: ${keys.join(", ")}`,
2700
+ syncMergedNotWritten: "A merged target is reported by its keys and never rewritten: no merge-json file is written in this version.",
2701
+ syncWriteEffect: {
2702
+ [BLOCK_REPLACED_WHOLE]: "the construct block is replaced whole \u2014 edits between the delimiters do not survive; the discovery marker bodies are carried over"
2703
+ },
2704
+ syncVariantUnknown: (shape) => `no record of the variant that wrote it and no rendering matches the recorded hash; the shape reads like the ${shape} variant, which is a guess and never enough to write on`,
2705
+ syncApplyUnknown: "BEYOND THE BLACKWALL",
2706
+ syncNothingToWrite: "NOTHING TO WRITE \u2014 the replay reads back what the tree already carries.",
2707
+ syncPending: (count) => `${count} path${count === 1 ? "" : "s"} can be written: run \`construct sync --apply\`.`,
2708
+ syncApplyTitle: "RELIC WRITE",
2709
+ syncApplyWritten: "WRITTEN",
2710
+ syncApplyRefused: "LEFT TO YOU",
2711
+ syncApplyWrote: (count) => `${count} path${count === 1 ? "" : "s"} written. The manifest records the owned view of each of them.`,
2712
+ syncApplyNothingWritten: "NOTHING WRITTEN \u2014 the tree already carries what the construct owns.",
2713
+ syncApplyLeftToYou: (count) => `${count} path${count === 1 ? "" : "s"} the record cannot prove the construct owns. Yours to carry across.`,
2714
+ syncVersionGap: (from, to) => `ENGRAM CUT BY v${from} // REPLAYED BY v${to}`,
2715
+ syncNoManifest: "No construct.json here. Run `construct init` first.",
2716
+ recordCarriedOver: (carried, added) => `ENGRAM EXTENDED: ${carried} record${carried === 1 ? "" : "s"} carried over from the construct.json already here, ${added} added.`,
2717
+ recordVarsChanged: (changed) => `ENGRAM REWRITTEN: this run changed ${changed.map((entry) => `${entry.name} (${entry.from} \u2192 ${entry.to})`).join(", ")} in the record; the recorded hashes were taken with the old value${changed.length === 1 ? "" : "s"}.`
2050
2718
  };
2051
2719
  var PLAIN_LORE = {
2052
2720
  subtitle: (version) => `mikoshi-construct v${version}`,
@@ -2078,6 +2746,9 @@ var PLAIN_LORE = {
2078
2746
  discoveryIncomplete: "Discovery incomplete.",
2079
2747
  provenance: "Discovery provenance",
2080
2748
  stillConstructAuthored: (count) => `Unchanged since discovery wrote them: ${count} marker${count === 1 ? "" : "s"} nobody has stood behind yet.`,
2749
+ baselineCurrent: "The baseline reads back what today's templates produce.",
2750
+ baselineMoved: (count) => `The baseline moved on: ${count} recorded path${count === 1 ? "" : "s"} a sync would add or update \u2014 run \`construct sync\`.`,
2751
+ baselineGapUnknown: "What a sync would add or update cannot be established from this manifest: run `construct sync`.",
2081
2752
  enforcement: "Enforcement",
2082
2753
  typecheckCaveat: "Typecheck cannot carry this stack alone.",
2083
2754
  weakestLink: (id, level) => `Weakest link: ${id} at ${level}`,
@@ -2103,7 +2774,38 @@ var PLAIN_LORE = {
2103
2774
  ledgerMalformed: (count) => `${count} ledger line${count === 1 ? "" : "s"} could not be read as a run record.`,
2104
2775
  ledgerDrift: (entriesWithoutSession, sessionsWithoutEntry, unjoinable) => `Ledger against the runtime: ${entriesWithoutSession} entries with no session, ${sessionsWithoutEntry} sessions with no entry, ${unjoinable} entries with no run id.`,
2105
2776
  ledgerEntryWithoutSession: "logged as a run, no session behind it",
2106
- ledgerSessionWithoutEntry: "ran, never logged"
2777
+ ledgerSessionWithoutEntry: "ran, never logged",
2778
+ syncTitle: "Sync report",
2779
+ syncClasses: "Classes",
2780
+ syncClassMeaning: {
2781
+ add: "not in the tree; the templates produce it",
2782
+ update: "the construct owns this and the template moved on",
2783
+ conflict: "yours \u2014 you wrote or changed it; sync never touches these",
2784
+ unknown: "which template variant wrote this block cannot be established; you changed nothing, and sync writes nothing here",
2785
+ removed: "you deleted it; sync never puts it back",
2786
+ orphaned: "the construct wrote it once and no longer produces it; it is yours now",
2787
+ keep: "already what the templates produce",
2788
+ foreign: "never ours"
2789
+ },
2790
+ syncMergedKeys: (keys) => `keys: ${keys.join(", ")}`,
2791
+ syncMergedNotWritten: "A merged target is reported by its keys and never rewritten: no merge-json file is written in this version.",
2792
+ syncWriteEffect: {
2793
+ [BLOCK_REPLACED_WHOLE]: "the construct block is replaced whole \u2014 edits between the delimiters do not survive; the discovery marker bodies are carried over"
2794
+ },
2795
+ syncVariantUnknown: (shape) => `no record of the variant that wrote it and no rendering matches the recorded hash; the shape reads like the ${shape} variant, which is a guess and never enough to write on`,
2796
+ syncApplyUnknown: "Variant unknown",
2797
+ syncNothingToWrite: "Nothing to write: the replay reads back what the tree already carries.",
2798
+ syncPending: (count) => `${count} path${count === 1 ? "" : "s"} can be written: run \`construct sync --apply\`.`,
2799
+ syncApplyTitle: "Sync apply",
2800
+ syncApplyWritten: "Written",
2801
+ syncApplyRefused: "Left to you",
2802
+ syncApplyWrote: (count) => `${count} path${count === 1 ? "" : "s"} written. The manifest records the owned view of each of them.`,
2803
+ syncApplyNothingWritten: "Nothing written: the tree already carries what the construct owns.",
2804
+ syncApplyLeftToYou: (count) => `${count} path${count === 1 ? "" : "s"} the record cannot prove the construct owns. Yours to carry across.`,
2805
+ syncVersionGap: (from, to) => `Materialized by construct ${from}, read by ${to}.`,
2806
+ syncNoManifest: "No construct.json here. Run `construct init` first.",
2807
+ recordCarriedOver: (carried, added) => `Carried over ${carried} record${carried === 1 ? "" : "s"} from the construct.json already here; added ${added}.`,
2808
+ recordVarsChanged: (changed) => `This run changed ${changed.map((entry) => `${entry.name} (${entry.from} -> ${entry.to})`).join(", ")} in the record; the recorded hashes were taken with the old value${changed.length === 1 ? "" : "s"}.`
2107
2809
  };
2108
2810
 
2109
2811
  // src/ui/console.ts
@@ -2305,7 +3007,7 @@ var cost = defineCommand({
2305
3007
  json: { type: "boolean", description: "Machine-readable report", default: false }
2306
3008
  },
2307
3009
  run({ args }) {
2308
- const report = costReport(path21.resolve(args.dir));
3010
+ const report = costReport(path22.resolve(args.dir));
2309
3011
  if (args.json) {
2310
3012
  process6.stdout.write(`${JSON.stringify(costJson(report, args.last), null, 2)}
2311
3013
  `);
@@ -2315,6 +3017,41 @@ var cost = defineCommand({
2315
3017
  process6.exitCode = printCost(ui(args), report, args.last);
2316
3018
  }
2317
3019
  });
3020
+ var sync = defineCommand({
3021
+ meta: { name: "sync", description: "Classify what today's construct would change in this repository; --apply writes what it owns" },
3022
+ args: {
3023
+ ...commonArgs,
3024
+ json: { type: "boolean", description: "Machine-readable report", default: false },
3025
+ apply: { type: "boolean", description: "Write the paths the construct owns \u2014 the only way sync writes; never a conflict, a removal or a merged file", default: false }
3026
+ },
3027
+ run({ args }) {
3028
+ const console = ui(args);
3029
+ if (args.apply) {
3030
+ try {
3031
+ const result = applySync(args.dir, VERSION);
3032
+ if (args.json) {
3033
+ process6.stdout.write(`${JSON.stringify(result == null ? null : syncApplyJson(result), null, 2)}
3034
+ `);
3035
+ process6.exitCode = syncApplyExit(result);
3036
+ return;
3037
+ }
3038
+ process6.exitCode = printSyncApply(console, result);
3039
+ } catch (error) {
3040
+ console.flatline(error instanceof Error ? error.message : String(error));
3041
+ process6.exitCode = 1;
3042
+ }
3043
+ return;
3044
+ }
3045
+ const report = runSync(args.dir, VERSION);
3046
+ if (args.json) {
3047
+ process6.stdout.write(`${JSON.stringify(report == null ? null : syncJson(report), null, 2)}
3048
+ `);
3049
+ process6.exitCode = syncExit(report);
3050
+ return;
3051
+ }
3052
+ process6.exitCode = printSync(console, report);
3053
+ }
3054
+ });
2318
3055
  var main = defineCommand({
2319
3056
  meta: {
2320
3057
  name: "construct",
@@ -2327,6 +3064,7 @@ var main = defineCommand({
2327
3064
  inspect: soulkill,
2328
3065
  capture: soulkill,
2329
3066
  doctor,
3067
+ sync,
2330
3068
  cost
2331
3069
  }
2332
3070
  });