mikoshi-construct 0.1.3 → 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,22 +1,27 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import path14 from "path";
5
- import process4 from "process";
4
+ import path22 from "path";
5
+ import process6 from "process";
6
6
  import { isTTY } from "@clack/prompts";
7
7
  import { defineCommand, runMain } from "citty";
8
8
 
9
- // src/commands/cost.ts
10
- import { existsSync, readdirSync, readFileSync, statSync } from "fs";
9
+ // src/commands/cost/index.ts
10
+ import process2 from "process";
11
+
12
+ // src/commands/cost/claude-code.ts
13
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "fs";
11
14
  import { homedir } from "os";
12
15
  import path from "path";
16
+
17
+ // src/commands/cost/usage.ts
18
+ var PRICE_RELATIVE_TO_INPUT = { cacheWrite: 1.25, cacheRead: 0.1, output: 5 };
13
19
  function emptyUsage() {
14
20
  return { calls: 0, input: 0, cacheWrite: 0, cacheRead: 0, output: 0, models: [] };
15
21
  }
16
22
  function billable(usage) {
17
23
  return usage.input + usage.cacheWrite + usage.cacheRead + usage.output;
18
24
  }
19
- var PRICE_RELATIVE_TO_INPUT = { cacheWrite: 1.25, cacheRead: 0.1, output: 5 };
20
25
  function weighted(usage) {
21
26
  return Math.round(usage.input + usage.cacheWrite * PRICE_RELATIVE_TO_INPUT.cacheWrite + usage.cacheRead * PRICE_RELATIVE_TO_INPUT.cacheRead + usage.output * PRICE_RELATIVE_TO_INPUT.output);
22
27
  }
@@ -31,6 +36,8 @@ function add(total, part) {
31
36
  total.models.push(model);
32
37
  }
33
38
  }
39
+
40
+ // src/commands/cost/claude-code.ts
34
41
  function projectKey(cwd) {
35
42
  return cwd.replace(/[/.]/g, "-");
36
43
  }
@@ -80,48 +87,221 @@ function collectRuns(sessionDir) {
80
87
  });
81
88
  }
82
89
  function collectWorkflowRuns(cwd, projectsDir = claudeProjectsDir()) {
83
- const projectDir = path.join(projectsDir, projectKey(cwd));
84
- if (!existsSync(projectDir))
90
+ return directories(path.join(projectsDir, projectKey(cwd))).flatMap(collectRuns).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
91
+ }
92
+ function resolvedPath(cwd) {
93
+ try {
94
+ return realpathSync(cwd);
95
+ } catch {
85
96
  return null;
86
- return directories(projectDir).flatMap(collectRuns).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
97
+ }
87
98
  }
88
- function fmt(value) {
89
- return value.toLocaleString("en-US");
99
+ function mainWorktreePath(cwd) {
100
+ const dotGit = path.join(cwd, ".git");
101
+ if (!existsSync(dotGit) || !statSync(dotGit).isFile())
102
+ return null;
103
+ const gitdir = readFileSync(dotGit, "utf8").match(/^gitdir: *(\S.*)$/m)?.[1]?.trim();
104
+ return gitdir?.match(/^(.+)\/\.git\/worktrees\/[^/]+\/?$/)?.[1] ?? null;
90
105
  }
91
- function printCost(ui2, runs, last) {
92
- if (runs == null) {
93
- ui2.flatline("No Claude Code session data for this directory.");
94
- return 1;
106
+ function recordedElsewhere(cwd, key, projectsDir) {
107
+ return [resolvedPath(cwd), mainWorktreePath(cwd)].filter((candidate) => candidate != null).map(projectKey).filter((candidate) => candidate !== key && existsSync(path.join(projectsDir, candidate)));
108
+ }
109
+ function lookalikeKeys(cwd, key, projectsDir) {
110
+ const suffix = `-${path.basename(cwd)}`;
111
+ return directories(projectsDir).map((dir) => path.basename(dir)).filter((name) => name !== key && name.endsWith(suffix));
112
+ }
113
+ var ClaudeCodeCostSource = class {
114
+ constructor(projectsDir = claudeProjectsDir()) {
115
+ this.projectsDir = projectsDir;
95
116
  }
96
- const selected = last ? runs.slice(-1) : runs;
97
- if (selected.length === 0) {
98
- ui2.glitch("No /implement runs recorded here yet.");
99
- return 0;
117
+ projectsDir;
118
+ runtime = "claude-code";
119
+ readable() {
120
+ return existsSync(this.projectsDir);
100
121
  }
101
- const grand = emptyUsage();
102
- for (const run of selected) {
103
- ui2.line(`${ui2.theme.accent(run.run)} ${ui2.theme.dim(`${run.startedAt} \xB7 ${run.total.models.join(", ")}`)}`);
104
- for (const agent of run.agents) {
105
- ui2.line(` ${agent.type.padEnd(12)} ${agent.label.padEnd(28)} calls ${String(agent.usage.calls).padStart(3)} in ${fmt(agent.usage.input).padStart(8)} cache-w ${fmt(agent.usage.cacheWrite).padStart(9)} cache-r ${fmt(agent.usage.cacheRead).padStart(10)} out ${fmt(agent.usage.output).padStart(7)}`);
122
+ read(cwd) {
123
+ const key = projectKey(cwd);
124
+ if (existsSync(path.join(this.projectsDir, key))) {
125
+ const runs = collectWorkflowRuns(cwd, this.projectsDir);
126
+ return { status: runs.length > 0 ? "ok" : "empty", runs, key, candidates: [] };
106
127
  }
107
- ui2.line(` ${ui2.theme.bold(`total ${fmt(billable(run.total))} billable tokens in ${run.total.calls} calls`)} ${ui2.theme.dim(`\u2248 ${fmt(weighted(run.total))} input-equivalent`)}`);
108
- ui2.line();
109
- add(grand, run.total);
128
+ const recorded = recordedElsewhere(cwd, key, this.projectsDir);
129
+ if (recorded.length > 0)
130
+ return { status: "mismatch", runs: [], key, candidates: recorded };
131
+ const lookalikes = lookalikeKeys(cwd, key, this.projectsDir);
132
+ if (lookalikes.length > 0)
133
+ return { status: "unknown", runs: [], key, candidates: lookalikes };
134
+ return { status: "empty", runs: [], key, candidates: [] };
110
135
  }
111
- if (selected.length > 1)
112
- ui2.line(`${ui2.theme.bold(`${selected.length} runs: ${fmt(billable(grand))} billable tokens in ${grand.calls} calls`)} ${ui2.theme.dim(`\u2248 ${fmt(weighted(grand))} input-equivalent (cache-write \xD7${PRICE_RELATIVE_TO_INPUT.cacheWrite}, cache-read \xD7${PRICE_RELATIVE_TO_INPUT.cacheRead}, output \xD7${PRICE_RELATIVE_TO_INPUT.output})`)}`);
113
- return 0;
136
+ };
137
+
138
+ // src/commands/cost/ledger.ts
139
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
140
+ import path2 from "path";
141
+ var LEDGER_FILE = ".construct/runs.jsonl";
142
+ var TEXT_FIELDS = ["at", "task", "effort", "status", "rung"];
143
+ var COUNT_FIELDS = ["agents", "toolUses", "seconds"];
144
+ function isText(value) {
145
+ return typeof value === "string" && value !== "";
146
+ }
147
+ function isCount(value) {
148
+ return typeof value === "number" && Number.isFinite(value);
149
+ }
150
+ function isTokenCount(value) {
151
+ return value === "unknown" || isCount(value);
152
+ }
153
+ function attemptFaults(value, index) {
154
+ if (value == null || typeof value !== "object")
155
+ return [`attempts[${index}] is not a record`];
156
+ const attempt = value;
157
+ return [
158
+ isCount(attempt.rung) ? null : `attempts[${index}].rung`,
159
+ isText(attempt.effort) ? null : `attempts[${index}].effort`,
160
+ isText(attempt.outcome) ? null : `attempts[${index}].outcome`,
161
+ typeof attempt.reason === "string" ? null : `attempts[${index}].reason`
162
+ ].filter((fault) => fault != null);
163
+ }
164
+ function attemptListFaults(value) {
165
+ if (!Array.isArray(value))
166
+ return ["attempts"];
167
+ return value.flatMap(attemptFaults);
168
+ }
169
+ function undeclaredFields(record) {
170
+ const missing = [
171
+ ...TEXT_FIELDS.filter((field) => !isText(record[field])),
172
+ ...COUNT_FIELDS.filter((field) => !isCount(record[field]))
173
+ ];
174
+ if (!isTokenCount(record.tokens))
175
+ missing.push("tokens");
176
+ missing.push(...attemptListFaults(record.attempts));
177
+ return missing;
178
+ }
179
+ function toAttempt(value) {
180
+ const attempt = value;
181
+ return { rung: attempt.rung, effort: attempt.effort, outcome: attempt.outcome, reason: attempt.reason };
182
+ }
183
+ function toEntry(raw) {
184
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw))
185
+ return "not a run record";
186
+ const record = raw;
187
+ const missing = undeclaredFields(record);
188
+ if (missing.length > 0)
189
+ return `missing or invalid: ${missing.join(", ")}`;
190
+ return {
191
+ run: isText(record.run) ? record.run : null,
192
+ at: record.at,
193
+ task: record.task,
194
+ effort: record.effort,
195
+ status: record.status,
196
+ rung: record.rung,
197
+ attempts: record.attempts.map(toAttempt),
198
+ agents: record.agents,
199
+ tokens: record.tokens,
200
+ toolUses: record.toolUses,
201
+ seconds: record.seconds
202
+ };
203
+ }
204
+ function readLedger(root) {
205
+ const file = path2.join(root, LEDGER_FILE);
206
+ const reading = { entries: [], malformed: [] };
207
+ if (!existsSync2(file))
208
+ return reading;
209
+ readFileSync2(file, "utf8").split("\n").forEach((text2, index) => {
210
+ const line = index + 1;
211
+ if (text2.trim() === "")
212
+ return;
213
+ let raw;
214
+ try {
215
+ raw = JSON.parse(text2);
216
+ } catch {
217
+ reading.malformed.push({ line, reason: "not JSON" });
218
+ return;
219
+ }
220
+ const entry = toEntry(raw);
221
+ if (typeof entry === "string")
222
+ reading.malformed.push({ line, reason: entry });
223
+ else
224
+ reading.entries.push(entry);
225
+ });
226
+ return reading;
227
+ }
228
+ function summarizeLedger(reading) {
229
+ const anyTokenUnknown = reading.entries.some((entry) => entry.tokens === "unknown");
230
+ const counted = reading.entries.reduce((total, entry) => total + (entry.tokens === "unknown" ? 0 : entry.tokens), 0);
231
+ return {
232
+ runs: reading.entries.length,
233
+ agents: reading.entries.reduce((total, entry) => total + entry.agents, 0),
234
+ failures: reading.entries.filter((entry) => entry.status !== "done").length,
235
+ tokens: anyTokenUnknown ? "unknown" : counted,
236
+ malformed: reading.malformed
237
+ };
238
+ }
239
+ function withoutTokenTotals(summary) {
240
+ return { ...summary, tokens: "unknown" };
241
+ }
242
+ function hasLedgerFindings(summary) {
243
+ return summary.runs > 0 || summary.malformed.length > 0;
244
+ }
245
+ function reconcile(entries, runs) {
246
+ const sessionRuns = new Set(runs.map((run) => run.run));
247
+ const ledgerRuns = entries.map((entry) => entry.run).filter((run) => run != null);
248
+ const joinable = new Set(ledgerRuns);
249
+ return {
250
+ entriesWithoutSession: [...joinable].filter((run) => !sessionRuns.has(run)),
251
+ sessionsWithoutEntry: runs.map((run) => run.run).filter((run) => !joinable.has(run)),
252
+ unjoinable: entries.length - ledgerRuns.length
253
+ };
114
254
  }
115
255
 
116
- // src/commands/doctor.ts
117
- import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
118
- import path3 from "path";
256
+ // src/commands/cost/runtime.ts
257
+ import process from "process";
119
258
 
120
259
  // src/manifest.ts
121
260
  import { createHash } from "crypto";
122
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
123
- import path2 from "path";
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";
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
124
303
  var MANIFEST_FILE = "construct.json";
304
+ var MANIFEST_VERSION = 4;
125
305
  var DISCOVERY_MARKERS = [
126
306
  "product",
127
307
  "module-map",
@@ -137,7 +317,7 @@ var DISCOVERY_MARKERS = [
137
317
  function sha256(content) {
138
318
  return createHash("sha256").update(content).digest("hex");
139
319
  }
140
- function markerFile(marker, compositionDir2 = "architecture/composition") {
320
+ function markerFile(marker, compositionDir2) {
141
321
  switch (marker) {
142
322
  case "composition":
143
323
  return compositionDir2;
@@ -148,13 +328,19 @@ function markerFile(marker, compositionDir2 = "architecture/composition") {
148
328
  }
149
329
  }
150
330
  function buildManifest(input) {
151
- const files = {};
331
+ const written = {};
152
332
  for (const op of input.written)
153
- files[op.target] = sha256(op.content);
154
- const discovery = Object.fromEntries(DISCOVERY_MARKERS.map((marker) => [marker, markerFile(marker, input.vars.compositionDir)]));
333
+ written[op.target] = sha256(op.content);
334
+ const files = { ...input.previous?.files, ...written };
335
+ const markers = Object.fromEntries(DISCOVERY_MARKERS.map((marker) => [marker, {
336
+ file: markerFile(marker, input.vars.compositionDir),
337
+ authoredBy: "unknown",
338
+ sha: null
339
+ }]));
155
340
  return {
156
- construct: input.version,
157
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
341
+ manifestVersion: MANIFEST_VERSION,
342
+ construct: input.previous?.construct ?? input.version,
343
+ createdAt: input.previous?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString(),
158
344
  preset: input.preset,
159
345
  ai: input.ai,
160
346
  review: input.review === "none" ? null : { provider: input.review, model: input.vars.reviewModel },
@@ -163,21 +349,448 @@ function buildManifest(input) {
163
349
  contracts: input.contracts ? { path: input.vars.contractPath, types: input.vars.contractTypesOutput } : null,
164
350
  vars: input.vars,
165
351
  files,
166
- discovery
352
+ variants: { ...input.previous?.variants, ...variantsOf(input.written) },
353
+ discovery: input.previous?.discovery ?? { baseSha: null, filledAt: null, markers },
354
+ sync: input.previous?.sync ?? null
355
+ };
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
+ }
367
+ function upgradeMarker(recorded, file) {
368
+ if (typeof recorded === "string")
369
+ return { file: recorded, authoredBy: "unknown", sha: null };
370
+ const value = recorded ?? {};
371
+ return {
372
+ file: typeof value.file === "string" ? value.file : file,
373
+ authoredBy: value.authoredBy === "construct" ? "construct" : "unknown",
374
+ sha: typeof value.sha === "string" ? value.sha : null
375
+ };
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
+ }
389
+ function upgradeManifest(raw) {
390
+ const manifest = raw;
391
+ const discovery = manifest.discovery ?? {};
392
+ const recorded = discovery.markers ?? discovery;
393
+ const markers = Object.fromEntries(DISCOVERY_MARKERS.map((marker) => [
394
+ marker,
395
+ upgradeMarker(recorded[marker], markerFile(marker, manifest.vars?.compositionDir ?? DEFAULT_COMPOSITION_DIR))
396
+ ]));
397
+ return {
398
+ ...manifest,
399
+ manifestVersion: MANIFEST_VERSION,
400
+ variants: upgradeVariants(manifest.variants),
401
+ discovery: {
402
+ baseSha: typeof discovery.baseSha === "string" ? discovery.baseSha : null,
403
+ filledAt: typeof discovery.filledAt === "string" ? discovery.filledAt : null,
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 }
418
+ }
167
419
  };
168
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
+ }
169
427
  function writeManifest(root, manifest) {
170
- writeFileSync(path2.join(root, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
428
+ writeFileSync(path4.join(root, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}
171
429
  `);
172
430
  }
173
431
  function readManifest(root) {
174
- const file = path2.join(root, MANIFEST_FILE);
175
- if (!existsSync2(file))
432
+ const file = path4.join(root, MANIFEST_FILE);
433
+ if (!existsSync4(file))
176
434
  return null;
177
- return JSON.parse(readFileSync2(file, "utf8"));
435
+ return upgradeManifest(JSON.parse(readFileSync3(file, "utf8")));
436
+ }
437
+
438
+ // src/commands/cost/runtime.ts
439
+ var CLAUDE_CODE_ENV = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"];
440
+ var CURSOR_ENV = ["CURSOR_AGENT", "CURSOR_TRACE_ID"];
441
+ function anySet(env, names) {
442
+ return names.some((name) => (env[name] ?? "") !== "");
443
+ }
444
+ function resolveRuntime(root, env = process.env) {
445
+ if (anySet(env, CLAUDE_CODE_ENV))
446
+ return "claude-code";
447
+ if (anySet(env, CURSOR_ENV))
448
+ return "cursor";
449
+ return readManifest(root)?.ai === "cursor" ? "cursor" : "claude-code";
450
+ }
451
+
452
+ // src/commands/cost/report.ts
453
+ var COST_EXIT = {
454
+ ok: 0,
455
+ empty: 0,
456
+ mismatch: 1,
457
+ unknown: 1,
458
+ unsupported: 3
459
+ };
460
+ function selectRuns(report, last) {
461
+ const runs = report.runs ?? [];
462
+ return last ? runs.slice(-1) : runs;
463
+ }
464
+ function costJson(report, last) {
465
+ const runs = selectRuns(report, last);
466
+ return {
467
+ status: report.status,
468
+ runtime: report.runtime,
469
+ ...report.key == null ? {} : { key: report.key },
470
+ ...report.candidates == null || report.candidates.length === 0 ? {} : { candidates: report.candidates },
471
+ ...runs.length === 0 ? {} : { runs },
472
+ ...report.ledger == null ? {} : { ledger: report.ledger },
473
+ ...report.reconciliation == null ? {} : { reconciliation: report.reconciliation }
474
+ };
475
+ }
476
+ function fmt(value) {
477
+ return value.toLocaleString("en-US");
478
+ }
479
+ function tokens(value) {
480
+ return value === "unknown" ? "unknown" : fmt(value);
481
+ }
482
+ function printLedger(ui2, ledger, reconciliation) {
483
+ if (ledger == null && reconciliation == null)
484
+ return;
485
+ if (ledger != null) {
486
+ ui2.line(ui2.theme.dim(ui2.lore.ledgerCounts(ledger.runs, ledger.agents, ledger.failures, tokens(ledger.tokens))));
487
+ if (ledger.malformed.length > 0)
488
+ ui2.glitch(ui2.lore.ledgerMalformed(ledger.malformed.length), ledger.malformed.map((entry) => `line ${entry.line}: ${entry.reason}`));
489
+ }
490
+ if (reconciliation == null)
491
+ return;
492
+ ui2.line(ui2.theme.dim(ui2.lore.ledgerDrift(reconciliation.entriesWithoutSession.length, reconciliation.sessionsWithoutEntry.length, reconciliation.unjoinable)));
493
+ for (const run of reconciliation.entriesWithoutSession)
494
+ ui2.line(ui2.theme.dim(` ${run}: ${ui2.lore.ledgerEntryWithoutSession}`));
495
+ for (const run of reconciliation.sessionsWithoutEntry)
496
+ ui2.line(ui2.theme.dim(` ${run}: ${ui2.lore.ledgerSessionWithoutEntry}`));
497
+ }
498
+ function printRuns(ui2, runs) {
499
+ const grand = emptyUsage();
500
+ for (const run of runs) {
501
+ ui2.line(`${ui2.theme.accent(run.run)} ${ui2.theme.dim(`${run.startedAt} \xB7 ${run.total.models.join(", ")}`)}`);
502
+ for (const agent of run.agents)
503
+ ui2.line(` ${agent.type.padEnd(12)} ${agent.label.padEnd(28)} calls ${String(agent.usage.calls).padStart(3)} in ${fmt(agent.usage.input).padStart(8)} cache-w ${fmt(agent.usage.cacheWrite).padStart(9)} cache-r ${fmt(agent.usage.cacheRead).padStart(10)} out ${fmt(agent.usage.output).padStart(7)}`);
504
+ ui2.line(` ${ui2.theme.bold(`total ${fmt(billable(run.total))} billable tokens in ${run.total.calls} calls`)} ${ui2.theme.dim(`\u2248 ${fmt(weighted(run.total))} input-equivalent`)}`);
505
+ ui2.line();
506
+ add(grand, run.total);
507
+ }
508
+ if (runs.length > 1)
509
+ ui2.line(`${ui2.theme.bold(`${runs.length} runs: ${fmt(billable(grand))} billable tokens in ${grand.calls} calls`)} ${ui2.theme.dim(`\u2248 ${fmt(weighted(grand))} input-equivalent (cache-write \xD7${PRICE_RELATIVE_TO_INPUT.cacheWrite}, cache-read \xD7${PRICE_RELATIVE_TO_INPUT.cacheRead}, output \xD7${PRICE_RELATIVE_TO_INPUT.output})`)}`);
510
+ }
511
+ function printCost(ui2, report, last) {
512
+ const key = report.key ?? "";
513
+ const candidates = report.candidates ?? [];
514
+ switch (report.status) {
515
+ case "unsupported":
516
+ ui2.glitch(ui2.lore.costUnsupported(report.runtime));
517
+ break;
518
+ case "mismatch":
519
+ ui2.glitch(ui2.lore.costKeyMismatch(key), candidates);
520
+ break;
521
+ case "unknown":
522
+ ui2.glitch(ui2.lore.costKeyUnknown(key), candidates);
523
+ break;
524
+ case "empty":
525
+ ui2.glitch(ui2.lore.costEmpty);
526
+ break;
527
+ case "ok":
528
+ printRuns(ui2, selectRuns(report, last));
529
+ break;
530
+ }
531
+ printLedger(ui2, report.ledger, report.reconciliation);
532
+ return COST_EXIT[report.status];
533
+ }
534
+
535
+ // src/commands/cost/index.ts
536
+ function costReport(cwd, options = {}) {
537
+ const runtime = resolveRuntime(cwd, options.env ?? process2.env);
538
+ const reading = readLedger(cwd);
539
+ const ledger = summarizeLedger(reading);
540
+ const reported = hasLedgerFindings(ledger);
541
+ const source = runtime === "claude-code" ? new ClaudeCodeCostSource(options.projectsDir) : null;
542
+ if (source == null || !source.readable())
543
+ return { status: "unsupported", runtime, ...reported ? { ledger: withoutTokenTotals(ledger) } : {} };
544
+ const result = source.read(cwd);
545
+ const joinable = result.status === "ok" || result.status === "empty";
546
+ return {
547
+ runtime,
548
+ ...result,
549
+ ...reported ? { ledger } : {},
550
+ ...joinable && (reported || result.runs.length > 0) ? { reconciliation: reconcile(reading.entries, result.runs) } : {}
551
+ };
552
+ }
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
+
572
+ // src/commands/doctor/baseline.ts
573
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "fs";
574
+ import path6 from "path";
575
+ function baselineVerdict(root, manifest) {
576
+ const missingFiles = [];
577
+ const modifiedFiles = [];
578
+ for (const [file, hash] of Object.entries(manifest.files)) {
579
+ const absolute = path6.join(root, file);
580
+ if (!existsSync5(absolute))
581
+ missingFiles.push(file);
582
+ else if (sha256(readFileSync5(absolute, "utf8")) !== hash)
583
+ modifiedFiles.push(file);
584
+ }
585
+ return { missingFiles, modifiedFiles };
586
+ }
587
+
588
+ // src/commands/doctor/enforcement.ts
589
+ function harnessReach(evidence) {
590
+ const command = `"${evidence.harness.command}"`;
591
+ if (evidence.workflows.harnessWorkflow != null)
592
+ return { level: "L3", evidence: `${evidence.workflows.harnessWorkflow} runs ${command}` };
593
+ if (evidence.hooks.runsHarness && evidence.hooks.manager != null)
594
+ return { level: "L2", evidence: `${evidence.hooks.manager} runs ${command}, and a local hook is bypassable with --no-verify` };
595
+ return { level: "L0", evidence: `no workflow and no hook configuration runs ${command}` };
596
+ }
597
+ function reached(id, state, evidence, detail) {
598
+ const reach = harnessReach(evidence);
599
+ return { id, level: reach.level, state, evidence: `${detail}; ${reach.evidence}` };
600
+ }
601
+ function absent(id, detail) {
602
+ return { id, level: "L0", state: "absent", evidence: detail };
603
+ }
604
+
605
+ // src/commands/doctor/checks/ci.ts
606
+ var ID = "ci";
607
+ var SCOPE = "branch protection and organisation rulesets live in the GitHub API, not in the repository, so doctor cannot see whether this blocks a merge";
608
+ function ciCheck(evidence) {
609
+ const workflows = evidence.workflows;
610
+ const reach = harnessReach(evidence);
611
+ if (workflows.harnessWorkflow != null)
612
+ return { id: ID, level: reach.level, state: "present", evidence: `${reach.evidence}; ${SCOPE}` };
613
+ const seen = workflows.files.length === 0 ? `${workflows.directory} holds no workflow file` : `none of ${workflows.files.map((file) => `${workflows.directory}/${file}`).join(", ")} runs "${evidence.harness.command}"`;
614
+ return { id: ID, level: reach.level, state: "unknown", evidence: `${seen}; ${SCOPE}` };
615
+ }
616
+
617
+ // src/commands/doctor/runner.ts
618
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
619
+ import path7 from "path";
620
+ var RUNNER_CONFIG_FILES = [
621
+ "vitest.config.ts",
622
+ "vitest.config.mts",
623
+ "vitest.config.js",
624
+ "vitest.config.mjs",
625
+ "vite.config.ts",
626
+ "vite.config.mts",
627
+ "vite.config.js",
628
+ "vite.config.mjs"
629
+ ];
630
+ var STRING_LITERAL = /^(['"])(.*)\1$/;
631
+ function literalEntries(body) {
632
+ const trimmed = body.trim();
633
+ if (trimmed === "")
634
+ return [];
635
+ const entries = trimmed.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
636
+ const values = entries.map((entry) => STRING_LITERAL.exec(entry)?.[2]);
637
+ return values.every((value) => value != null) ? values : null;
638
+ }
639
+ function includeGlobs(source) {
640
+ const found = [];
641
+ let literal = false;
642
+ for (const match of source.matchAll(/include\s*:\s*/g)) {
643
+ const rest = source.slice(match.index + match[0].length);
644
+ if (!rest.startsWith("["))
645
+ return null;
646
+ const close = rest.indexOf("]");
647
+ if (close === -1)
648
+ return null;
649
+ const entries = literalEntries(rest.slice(1, close));
650
+ if (entries == null)
651
+ return null;
652
+ literal = true;
653
+ found.push(...entries);
654
+ }
655
+ return literal ? found : null;
656
+ }
657
+ function escapeLiteral(character) {
658
+ return /[.+^${}()|[\]\\]/.test(character) ? `\\${character}` : character;
659
+ }
660
+ function globToRegExp(glob) {
661
+ let pattern = "";
662
+ let braces = 0;
663
+ for (let index = 0; index < glob.length; index += 1) {
664
+ const character = glob[index];
665
+ if (character === "*" && glob[index + 1] === "*" && glob[index + 2] === "/") {
666
+ pattern += "(?:[^/]+/)*";
667
+ index += 2;
668
+ continue;
669
+ }
670
+ if (character === "*" && glob[index + 1] === "*") {
671
+ pattern += ".*";
672
+ index += 1;
673
+ continue;
674
+ }
675
+ if (character === "*") {
676
+ pattern += "[^/]*";
677
+ continue;
678
+ }
679
+ if (character === "?") {
680
+ pattern += "[^/]";
681
+ continue;
682
+ }
683
+ if (character === "{") {
684
+ braces += 1;
685
+ pattern += "(?:";
686
+ continue;
687
+ }
688
+ if (character === "}" && braces > 0) {
689
+ braces -= 1;
690
+ pattern += ")";
691
+ continue;
692
+ }
693
+ if (character === "," && braces > 0) {
694
+ pattern += "|";
695
+ continue;
696
+ }
697
+ pattern += escapeLiteral(character);
698
+ }
699
+ return new RegExp(`^${pattern}$`);
700
+ }
701
+ function matchesAnyGlob(file, globs) {
702
+ const normalized = file.replace(/^\.\//, "");
703
+ return globs.some((glob) => globToRegExp(glob.replace(/^\.\//, "")).test(normalized));
704
+ }
705
+ function readRunnerFacts(root, harnessText) {
706
+ const invokedByHarness = harnessText.includes("vitest");
707
+ const file = RUNNER_CONFIG_FILES.find((candidate) => existsSync6(path7.join(root, candidate))) ?? null;
708
+ if (file == null) {
709
+ return {
710
+ file: null,
711
+ globs: null,
712
+ note: `no runner config file (${RUNNER_CONFIG_FILES[0]} or a sibling) exists, so the include list cannot be read`,
713
+ invokedByHarness
714
+ };
715
+ }
716
+ let source;
717
+ try {
718
+ source = readFileSync6(path7.join(root, file), "utf8");
719
+ } catch {
720
+ return { file, globs: null, note: `${file} cannot be read, so the include list is unknown`, invokedByHarness };
721
+ }
722
+ const globs = includeGlobs(source);
723
+ if (globs == null)
724
+ return { file, globs: null, note: `the include in ${file} is not a literal list of strings, so doctor cannot say what the runner collects`, invokedByHarness };
725
+ return { file, globs, note: `${file} includes ${globs.map((glob) => `"${glob}"`).join(", ")}`, invokedByHarness };
178
726
  }
179
727
 
180
- // src/commands/doctor.ts
728
+ // src/commands/doctor/checks/construct-tests.ts
729
+ var ID2 = "construct-tests";
730
+ function constructTestsCheck(evidence) {
731
+ const recorded = evidence.recordedTests;
732
+ if (recorded.length === 0)
733
+ return absent(ID2, "construct.json records no test file; weakest link: there are no construct tests to run");
734
+ const runner = evidence.runner;
735
+ if (runner.globs == null)
736
+ return reached(ID2, "unknown", evidence, `construct.json records ${recorded.length} test files, but ${runner.note}`);
737
+ const orphan = recorded.find((file) => !matchesAnyGlob(file, runner.globs ?? []));
738
+ if (orphan != null)
739
+ return absent(ID2, `${orphan} is recorded in construct.json, but ${runner.note}, so the runner never collects it; weakest link: a construct test that nothing runs`);
740
+ if (!runner.invokedByHarness)
741
+ return absent(ID2, `construct.json records ${recorded.length} test files and ${runner.note}, but "${evidence.harness.script}" does not run the test runner; weakest link: the harness command never reaches them`);
742
+ return reached(ID2, "present", evidence, `all ${recorded.length} test files recorded in construct.json match the include in ${runner.file}, and "${evidence.harness.script}" runs the test runner`);
743
+ }
744
+
745
+ // src/commands/doctor/checks/hook.ts
746
+ var ID3 = "hook";
747
+ function hookCheck(evidence) {
748
+ const hooks = evidence.hooks;
749
+ if (hooks.manager != null) {
750
+ const runs = hooks.runsHarness ? `runs "${evidence.harness.command}"` : `does not run "${evidence.harness.command}"`;
751
+ return { id: ID3, level: "L2", state: "present", evidence: `${hooks.manager} installs a git hook and ${runs}; a local hook is bypassable with --no-verify` };
752
+ }
753
+ if (hooks.script != null)
754
+ return { id: ID3, level: "L0", state: "present", evidence: `package.json script "${hooks.script}" is claimed as a guard, but no .husky, lefthook, simple-git-hooks or core.hooksPath configuration installs it; weakest link: nobody is obliged to run it` };
755
+ return { id: ID3, level: "L0", state: "absent", evidence: "no .husky, lefthook, simple-git-hooks or core.hooksPath configuration and no pre-commit script in package.json" };
756
+ }
757
+
758
+ // src/commands/doctor/checks/lint-policy.ts
759
+ var ID4 = "lint-policy";
760
+ function lintPolicyCheck(evidence) {
761
+ const policy = evidence.policyTests[0];
762
+ if (policy == null) {
763
+ if (evidence.unreadable.length > 0)
764
+ return reached(ID4, "unknown", evidence, `${evidence.unreadable[0]} cannot be read, so doctor cannot say whether a lint policy check exists`);
765
+ return absent(ID4, "no file recorded in construct.json runs ESLint over the lint policy this repository declares; weakest link: the repository has no policy check to run");
766
+ }
767
+ const runner = evidence.runner;
768
+ if (runner.globs == null)
769
+ return reached(ID4, "unknown", evidence, `${policy} runs ESLint over the lint policy this repository declares, but ${runner.note}`);
770
+ if (!matchesAnyGlob(policy, runner.globs))
771
+ return absent(ID4, `${policy} runs ESLint over the lint policy this repository declares, but ${runner.note}; weakest link: the runner never collects the policy check`);
772
+ if (!runner.invokedByHarness)
773
+ return absent(ID4, `${policy} runs ESLint over the lint policy this repository declares and ${runner.note}, but "${evidence.harness.script}" does not run the test runner; weakest link: the harness command never reaches the policy check`);
774
+ return reached(ID4, "present", evidence, `${policy} runs ESLint over the lint policy this repository declares, ${runner.note}, and "${evidence.harness.script}" runs the test runner`);
775
+ }
776
+
777
+ // src/commands/doctor/checks/red-gate.ts
778
+ var ID5 = "red-gate";
779
+ function redGateCheck(evidence) {
780
+ return reached(ID5, "unknown", evidence, `doctor executes nothing from the repository it inspects, so whether "${evidence.harness.command}" passes on a clean checkout is unproven here; CI is where that is proven`);
781
+ }
782
+
783
+ // src/commands/doctor/discovery.ts
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
181
794
  var DISCOVERY_PLACEHOLDER = "_Not discovered yet \u2014 run `/construct-discover`._";
182
795
  function markerOpen(marker) {
183
796
  return `<!-- construct:discover:${marker} -->`;
@@ -185,318 +798,279 @@ function markerOpen(marker) {
185
798
  function markerClose(marker) {
186
799
  return `<!-- /construct:discover:${marker} -->`;
187
800
  }
188
- function isMarkerFilled(document, marker) {
801
+ function blockBody(document, marker) {
189
802
  const start = document.indexOf(markerOpen(marker));
190
803
  const stop = document.indexOf(markerClose(marker));
191
804
  if (start === -1 || stop === -1 || stop < start)
192
- return false;
805
+ return null;
193
806
  const body = document.slice(start + markerOpen(marker).length, stop).trim();
194
- return body !== "" && body !== DISCOVERY_PLACEHOLDER;
807
+ return body === "" || body === DISCOVERY_PLACEHOLDER ? null : body;
195
808
  }
809
+ function compositionBody(directory) {
810
+ if (!existsSync7(directory))
811
+ return null;
812
+ const models = readdirSync3(directory).filter((file) => file.endsWith(".yaml")).sort();
813
+ if (models.length === 0)
814
+ return null;
815
+ return models.map((model) => `${model}
816
+ ${readFileSync7(path8.join(directory, model), "utf8")}`).join("\n");
817
+ }
818
+ function markerBody(root, marker, file) {
819
+ const location = path8.join(root, file);
820
+ if (marker === "composition")
821
+ return compositionBody(location);
822
+ if (!existsSync7(location))
823
+ return null;
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;
834
+ }
835
+ function missingDiscovery(root, manifest) {
836
+ return DISCOVERY_MARKERS.filter((marker) => markerBody(root, marker, markerFileFor(root, manifest, marker)) == null);
837
+ }
838
+
839
+ // src/commands/doctor/evidence.ts
840
+ import { readFileSync as readFileSync11 } from "fs";
841
+ import path12 from "path";
842
+
843
+ // src/commands/doctor/harness.ts
844
+ import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
845
+ import path9 from "path";
196
846
  var REQUIRED_QUALITY_STEPS = ["lint", "typecheck", "test"];
197
- function contractProblems(root, contracts, scriptName, quality) {
847
+ var SCRIPT_REFERENCE = /(?:^|&&|\|\||;)\s*(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)/g;
848
+ function harnessScriptName(command) {
849
+ return command.replace(/^(pnpm|npm|yarn|bun)\s+(run\s+)?/, "");
850
+ }
851
+ function expandScript(scripts, name, seen) {
852
+ if (seen.has(name))
853
+ return "";
854
+ seen.add(name);
855
+ const body = scripts[name];
856
+ if (body == null)
857
+ return "";
858
+ const referenced = [...body.matchAll(SCRIPT_REFERENCE)].map((match) => match[1]);
859
+ return [body, ...referenced.map((reference) => expandScript(scripts, reference, seen))].join(" && ");
860
+ }
861
+ function readHarnessFacts(root, command) {
862
+ const script = harnessScriptName(command);
863
+ const manifestPath = path9.join(root, "package.json");
864
+ const packageJson = existsSync8(manifestPath) ? JSON.parse(readFileSync8(manifestPath, "utf8")) : null;
865
+ const scripts = packageJson?.scripts ?? {};
866
+ const body = scripts[script] ?? null;
867
+ return {
868
+ command,
869
+ script,
870
+ scripts,
871
+ body,
872
+ resolved: expandScript(scripts, script, /* @__PURE__ */ new Set()),
873
+ commandForms: [command, `pnpm run ${script}`, `pnpm ${script}`, `npm run ${script}`, `yarn ${script}`],
874
+ packageJson
875
+ };
876
+ }
877
+ function runsHarnessCommand(text2, forms) {
878
+ return forms.some((form) => text2.includes(form));
879
+ }
880
+ function contractProblems(root, contracts, script, body) {
198
881
  if (contracts == null)
199
882
  return [];
200
- const problems = [contracts.path, contracts.types].filter((file) => !existsSync3(path3.join(root, file))).map((file) => `${file} is missing (construct.json \u2192 contracts)`);
201
- if (!quality.includes("contracts:check"))
202
- problems.push(`"${scriptName}" does not run contracts:check`);
883
+ const problems = [contracts.path, contracts.types].filter((file) => !existsSync8(path9.join(root, file))).map((file) => `${file} is missing (construct.json \u2192 contracts)`);
884
+ if (!body.includes("contracts:check"))
885
+ problems.push(`"${script}" does not run contracts:check`);
203
886
  return problems;
204
887
  }
205
- function harnessProblems(root, manifest) {
206
- const command = manifest.harness.command;
207
- const manifestPath = path3.join(root, "package.json");
208
- if (!existsSync3(manifestPath))
888
+ function harnessProblems(root, manifest, facts) {
889
+ if (facts.packageJson == null)
209
890
  return ["package.json is missing"];
210
- const pkg = JSON.parse(readFileSync3(manifestPath, "utf8"));
211
- const scripts = pkg.scripts ?? {};
212
- const scriptName = command.replace(/^(pnpm|npm|yarn|bun)\s+(run\s+)?/, "");
213
- const quality = scripts[scriptName];
214
- if (quality == null)
215
- return [`package.json has no "${scriptName}" script (harness command is "${command}")`];
891
+ const body = facts.body;
892
+ if (body == null)
893
+ return [`package.json has no "${facts.script}" script (harness command is "${facts.command}")`];
216
894
  return [
217
- ...REQUIRED_QUALITY_STEPS.filter((step) => !quality.includes(step)).map((step) => `"${scriptName}" does not run ${step}`),
218
- ...contractProblems(root, manifest.contracts, scriptName, quality)
895
+ ...REQUIRED_QUALITY_STEPS.filter((step) => !body.includes(step)).map((step) => `"${facts.script}" does not run ${step}`),
896
+ ...contractProblems(root, manifest.contracts, facts.script, body)
219
897
  ];
220
898
  }
221
- function runDoctor(root) {
222
- const manifest = readManifest(root);
223
- if (manifest == null)
899
+
900
+ // src/commands/doctor/hooks.ts
901
+ import { existsSync as existsSync9, readdirSync as readdirSync4, readFileSync as readFileSync9, statSync as statSync2 } from "fs";
902
+ import path10 from "path";
903
+ var LEFTHOOK_FILES = ["lefthook.yml", "lefthook.yaml", "lefthook.toml", "lefthook.json", ".lefthook.yml", ".lefthook.yaml"];
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"];
905
+ var HUSKY_DIR = ".husky";
906
+ var GIT_CONFIG = ".git/config";
907
+ var HOOK_SCRIPTS = ["precommit", "pre-commit", "prepush", "pre-push"];
908
+ function read(root, file) {
909
+ try {
910
+ return readFileSync9(path10.join(root, file), "utf8");
911
+ } catch {
224
912
  return null;
225
- const missingFiles = [];
226
- const modifiedFiles = [];
227
- for (const [file, hash] of Object.entries(manifest.files)) {
228
- const absolute = path3.join(root, file);
229
- if (!existsSync3(absolute))
230
- missingFiles.push(file);
231
- else if (sha256(readFileSync3(absolute, "utf8")) !== hash)
232
- modifiedFiles.push(file);
233
913
  }
234
- const missingDiscovery = DISCOVERY_MARKERS.filter((marker) => {
235
- const location = path3.join(root, manifest.discovery[marker]);
236
- if (marker === "composition")
237
- return !existsSync3(location) || !readdirSync2(location).some((file) => file.endsWith(".yaml"));
238
- return !existsSync3(location) || !isMarkerFilled(readFileSync3(location, "utf8"), marker);
914
+ }
915
+ function huskyHooks(root) {
916
+ const directory = path10.join(root, HUSKY_DIR);
917
+ if (!existsSync9(directory) || !statSync2(directory).isDirectory())
918
+ return [];
919
+ return readdirSync4(directory).filter((entry) => !entry.startsWith("_") && !entry.startsWith(".")).sort().map((entry) => `${HUSKY_DIR}/${entry}`);
920
+ }
921
+ function managerFiles(root, packageJson) {
922
+ const files = [...huskyHooks(root)];
923
+ files.push(...[...LEFTHOOK_FILES, ...SIMPLE_GIT_HOOKS_FILES].filter((file) => existsSync9(path10.join(root, file))));
924
+ if (packageJson != null && "simple-git-hooks" in packageJson)
925
+ files.push("package.json (simple-git-hooks)");
926
+ const gitConfig = read(root, GIT_CONFIG);
927
+ if (gitConfig != null && gitConfig.includes("hooksPath"))
928
+ files.push(`${GIT_CONFIG} (core.hooksPath)`);
929
+ return files;
930
+ }
931
+ function readHookFacts(root, packageJson, scripts, commandForms) {
932
+ const files = managerFiles(root, packageJson);
933
+ const script = HOOK_SCRIPTS.find((name) => scripts[name] != null) ?? null;
934
+ const sources = files.map((file) => {
935
+ if (file.startsWith("package.json"))
936
+ return JSON.stringify(packageJson?.["simple-git-hooks"] ?? "");
937
+ return read(root, file.split(" ")[0]) ?? "";
239
938
  });
240
- const problems = harnessProblems(root, manifest);
241
- return {
242
- ok: missingFiles.length === 0 && problems.length === 0,
243
- missingFiles,
244
- modifiedFiles,
245
- missingDiscovery,
246
- harnessProblems: problems
247
- };
939
+ const manager = files[0] ?? null;
940
+ const scriptRunsHarness = script != null && runsHarnessCommand(scripts[script] ?? "", commandForms);
941
+ const installed = sources.some((source) => runsHarnessCommand(source, commandForms) || scriptRunsHarness && script != null && source.includes(script));
942
+ return { manager, script, runsHarness: manager != null && installed };
248
943
  }
249
- function printDoctor(ui2, result) {
250
- if (result == null) {
251
- ui2.flatline("No construct.json here. Run `construct init` first.");
252
- return 1;
944
+
945
+ // src/commands/doctor/workflows.ts
946
+ import { existsSync as existsSync10, readdirSync as readdirSync5, readFileSync as readFileSync10 } from "fs";
947
+ import path11 from "path";
948
+ var WORKFLOWS_DIR = ".github/workflows";
949
+ var RUN_STEP = /(?:^|\s)run:[ \t]*(\S.*)$/;
950
+ function runStepTexts(source) {
951
+ const lines = source.split("\n");
952
+ const steps = [];
953
+ for (let index = 0; index < lines.length; index += 1) {
954
+ const inline = RUN_STEP.exec(lines[index])?.[1];
955
+ if (inline == null)
956
+ continue;
957
+ if (!inline.startsWith("|") && !inline.startsWith(">")) {
958
+ steps.push(inline.trim());
959
+ continue;
960
+ }
961
+ const indent = lines[index].length - lines[index].trimStart().length;
962
+ const block = [];
963
+ for (let next = index + 1; next < lines.length; next += 1) {
964
+ const line = lines[next];
965
+ if (line.trim() !== "" && line.length - line.trimStart().length <= indent)
966
+ break;
967
+ block.push(line.trim());
968
+ }
969
+ steps.push(block.join("\n"));
253
970
  }
254
- if (result.harnessProblems.length > 0)
255
- ui2.glitch("Harness is broken.", result.harnessProblems);
256
- if (result.missingFiles.length > 0)
257
- ui2.glitch("Baseline files are missing.", result.missingFiles);
258
- if (result.missingDiscovery.length > 0) {
259
- ui2.glitch(ui2.lore.discoveryIncomplete, ["", "Missing:", ...result.missingDiscovery.map((marker) => ` ${marker}`), "", "Run: claude \u2192 /construct-discover"]);
971
+ return steps;
972
+ }
973
+ function readWorkflowFacts(root, commandForms) {
974
+ const directory = path11.join(root, WORKFLOWS_DIR);
975
+ if (!existsSync10(directory))
976
+ return { directory: WORKFLOWS_DIR, files: [], harnessWorkflow: null, unreadable: [] };
977
+ const files = readdirSync5(directory).filter((file) => file.endsWith(".yml") || file.endsWith(".yaml")).sort();
978
+ const unreadable = [];
979
+ let harnessWorkflow = null;
980
+ for (const file of files) {
981
+ let source;
982
+ try {
983
+ source = readFileSync10(path11.join(directory, file), "utf8");
984
+ } catch {
985
+ unreadable.push(`${WORKFLOWS_DIR}/${file}`);
986
+ continue;
987
+ }
988
+ if (harnessWorkflow == null && runStepTexts(source).some((step) => runsHarnessCommand(step, commandForms)))
989
+ harnessWorkflow = `${WORKFLOWS_DIR}/${file}`;
260
990
  }
261
- if (result.modifiedFiles.length > 0)
262
- ui2.line(ui2.theme.dim(` ${result.modifiedFiles.length} baseline files modified since init (expected once the project evolves).`));
263
- if (result.ok) {
264
- ui2.ok(ui2.lore.stable);
265
- return 0;
991
+ return { directory: WORKFLOWS_DIR, files, harnessWorkflow, unreadable };
992
+ }
993
+
994
+ // src/commands/doctor/evidence.ts
995
+ var TEST_FILE = /\.test\.[cm]?[jt]s$/;
996
+ var LINT_POLICY_MARKERS = ["calculateConfigForFile", "lintText", "lintFiles"];
997
+ function recordedTestFiles(manifest) {
998
+ return Object.keys(manifest.files).filter((file) => TEST_FILE.test(file)).sort();
999
+ }
1000
+ function gatherEvidence(root, manifest) {
1001
+ const harness = readHarnessFacts(root, manifest.harness.command);
1002
+ const runner = readRunnerFacts(root, harness.resolved);
1003
+ const workflows = readWorkflowFacts(root, harness.commandForms);
1004
+ const hooks = readHookFacts(root, harness.packageJson, harness.scripts, harness.commandForms);
1005
+ const recordedTests = recordedTestFiles(manifest);
1006
+ const policyTests = [];
1007
+ const unreadable = [...workflows.unreadable];
1008
+ for (const file of recordedTests) {
1009
+ try {
1010
+ const source = readFileSync11(path12.join(root, file), "utf8");
1011
+ if (LINT_POLICY_MARKERS.some((marker) => source.includes(marker)))
1012
+ policyTests.push(file);
1013
+ } catch {
1014
+ unreadable.push(file);
1015
+ }
266
1016
  }
267
- return 1;
1017
+ return { harness, runner, workflows, hooks, recordedTests, policyTests, unreadable };
268
1018
  }
269
1019
 
270
- // src/commands/init.ts
271
- import { mkdirSync as mkdirSync2 } from "fs";
272
- import path13 from "path";
1020
+ // src/commands/doctor/provenance.ts
1021
+ function markerAuthorship(recorded, body) {
1022
+ if (recorded.authoredBy !== "construct" || recorded.sha == null || body == null)
1023
+ return "unknown";
1024
+ return sha256(body) === recorded.sha ? "construct" : "owner";
1025
+ }
1026
+ function discoveryProvenance(root, manifest) {
1027
+ return DISCOVERY_MARKERS.map((marker) => {
1028
+ const recorded = manifest.discovery.markers[marker];
1029
+ return {
1030
+ marker,
1031
+ file: recorded.file,
1032
+ authorship: markerAuthorship(recorded, markerBody(root, marker, markerFileFor(root, manifest, marker)))
1033
+ };
1034
+ });
1035
+ }
1036
+ function constructAuthored(readings) {
1037
+ return readings.filter((reading) => reading.authorship === "construct");
1038
+ }
273
1039
 
274
- // src/detect/index.ts
275
- import { existsSync as existsSync8 } from "fs";
276
- import path8 from "path";
277
- import process from "process";
278
-
279
- // src/detect/existing.ts
280
- import { existsSync as existsSync4, readdirSync as readdirSync3 } from "fs";
281
- import path4 from "path";
282
- var ESLINT_CONFIGS = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", ".eslintrc", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yml"];
283
- var COMPOSITION_CANDIDATES = ["architecture/composition", "docs/architecture/composition", "docs/composition", "composition"];
284
- 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"];
285
- function anyExists(dir, candidates) {
286
- return candidates.some((candidate) => existsSync4(path4.join(dir, candidate)));
287
- }
288
- function firstExisting(dir, candidates) {
289
- return candidates.find((candidate) => existsSync4(path4.join(dir, candidate))) ?? null;
290
- }
291
- function compositionDir(dir) {
292
- return COMPOSITION_CANDIDATES.find((candidate) => {
293
- const absolute = path4.join(dir, candidate);
294
- return existsSync4(absolute) && readdirSync3(absolute).some((file) => file.endsWith(".yaml") || file.endsWith(".yml"));
295
- }) ?? null;
296
- }
297
- function hasWorkflows(dir) {
298
- const workflows = path4.join(dir, ".github", "workflows");
299
- return existsSync4(workflows) && readdirSync3(workflows).some((file) => file.endsWith(".yml") || file.endsWith(".yaml"));
300
- }
301
- function detectExisting(dir) {
302
- return {
303
- packageJson: existsSync4(path4.join(dir, "package.json")),
304
- tsconfig: existsSync4(path4.join(dir, "tsconfig.json")),
305
- eslintConfig: anyExists(dir, ESLINT_CONFIGS),
306
- githubWorkflows: hasWorkflows(dir),
307
- claudeMd: existsSync4(path4.join(dir, "CLAUDE.md")),
308
- agentsMd: existsSync4(path4.join(dir, "AGENTS.md")),
309
- cursorRules: existsSync4(path4.join(dir, ".cursor", "rules")),
310
- openapi: firstExisting(dir, OPENAPI_CANDIDATES),
311
- compositionDir: compositionDir(dir),
312
- constructJson: existsSync4(path4.join(dir, "construct.json"))
313
- };
314
- }
315
-
316
- // src/detect/layout.ts
317
- import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync5, statSync as statSync2 } from "fs";
318
- import path6 from "path";
319
-
320
- // src/detect/workspaces.ts
321
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
322
- import path5 from "path";
323
- var TOP_LEVEL_PACKAGES_KEY = /^packages:(.*)$/m;
324
- var EMPTY_FLOW_SEQUENCE = /^\[\s*\]$/;
325
- var BLOCK_SEQUENCE_ENTRY = /^[ \t]+-[ \t]*\S/;
326
- function readIfPresent(file) {
327
- if (!existsSync5(file))
328
- return null;
329
- try {
330
- return readFileSync4(file, "utf8");
331
- } catch {
332
- return null;
333
- }
334
- }
335
- function startsBlockSequence(rest) {
336
- for (const line of rest.split("\n")) {
337
- if (line.trim() === "" || line.trimStart().startsWith("#"))
338
- continue;
339
- return BLOCK_SEQUENCE_ENTRY.test(line);
340
- }
341
- return false;
342
- }
343
- function declaresPnpmPackages(dir) {
344
- const content = readIfPresent(path5.join(dir, "pnpm-workspace.yaml"));
345
- if (content == null)
346
- return false;
347
- const match = TOP_LEVEL_PACKAGES_KEY.exec(content);
348
- if (match == null)
349
- return false;
350
- const inline = match[1].trim();
351
- if (inline.startsWith("["))
352
- return !EMPTY_FLOW_SEQUENCE.test(inline);
353
- if (inline !== "")
354
- return false;
355
- return startsBlockSequence(content.slice(match.index + match[0].length));
356
- }
357
- function declaresNpmWorkspaces(dir) {
358
- const content = readIfPresent(path5.join(dir, "package.json"));
359
- if (content == null)
360
- return false;
361
- let workspaces;
362
- try {
363
- ({ workspaces } = JSON.parse(content));
364
- } catch {
365
- return false;
366
- }
367
- if (Array.isArray(workspaces))
368
- return workspaces.length > 0;
369
- const packages = workspaces?.packages;
370
- return Array.isArray(packages) && packages.length > 0;
371
- }
372
-
373
- // src/detect/layout.ts
374
- var IGNORED_ENTRIES = /* @__PURE__ */ new Set([".git", ".DS_Store", ".gitignore", ".gitattributes", "LICENSE", "README.md", ".idea", ".vscode"]);
375
- function isEmptyDir(dir) {
376
- if (!existsSync6(dir))
377
- return true;
378
- return readdirSync4(dir).every((entry) => IGNORED_ENTRIES.has(entry));
379
- }
380
- function detectMonorepoTools(dir) {
381
- const tools = [];
382
- if (declaresPnpmPackages(dir))
383
- tools.push("pnpm-workspace");
384
- if (declaresNpmWorkspaces(dir))
385
- tools.push("npm-workspaces");
386
- if (existsSync6(path6.join(dir, "turbo.json")))
387
- tools.push("turbo");
388
- if (existsSync6(path6.join(dir, "nx.json")))
389
- tools.push("nx");
390
- return tools;
391
- }
392
- function detectWorkspaceDirs(dir) {
393
- return ["apps", "packages", "libs", "services"].filter((name) => existsSync6(path6.join(dir, name)) && statSync2(path6.join(dir, name)).isDirectory());
394
- }
395
- function packageName(dir) {
396
- try {
397
- const parsed = JSON.parse(readFileSync5(path6.join(dir, "package.json"), "utf8"));
398
- return typeof parsed.name === "string" && parsed.name !== "" ? parsed.name : null;
399
- } catch {
400
- return null;
1040
+ // src/commands/doctor/typecheck.ts
1041
+ var CAVEATS = {
1042
+ "node-frontend": {
1043
+ checkers: ["vue-tsc", "svelte-check"],
1044
+ text: "node-frontend: `tsc --noEmit` does not see `.vue` or `.svelte` single-file components, so a Vite app that adds them needs the framework's own checker (vue-tsc, svelte-check) in the harness"
401
1045
  }
402
- }
403
- function detectWorkspacePackages(root, workspaceDirs) {
404
- return workspaceDirs.flatMap((parent) => readdirSync4(path6.join(root, parent)).sort().map((entry) => `${parent}/${entry}`).filter((dir) => existsSync6(path6.join(root, dir, "package.json"))).map((dir) => ({ dir, name: packageName(path6.join(root, dir)) ?? dir.split("/").at(-1) ?? dir })));
405
- }
406
- function detectLayout(dir, monorepoTools, workspaceDirs, hasSrc) {
407
- if (isEmptyDir(dir))
408
- return "empty";
409
- if (monorepoTools.length > 0 || workspaceDirs.length > 0)
410
- return "monorepo";
411
- if (hasSrc || existsSync6(path6.join(dir, "package.json")))
412
- return "single";
413
- return "unknown";
1046
+ };
1047
+ function typecheckWarnings(preset, evidence) {
1048
+ const caveat = CAVEATS[preset];
1049
+ if (caveat == null || caveat.checkers.some((checker) => evidence.harness.resolved.includes(checker)))
1050
+ return [];
1051
+ return [caveat.text];
414
1052
  }
415
1053
 
416
- // src/detect/package-manager.ts
417
- import { execFileSync } from "child_process";
418
- import { existsSync as existsSync7, readFileSync as readFileSync6 } from "fs";
419
- import path7 from "path";
420
- var LOCKFILES = [
421
- ["pnpm-lock.yaml", "pnpm"],
422
- ["bun.lockb", "bun"],
423
- ["bun.lock", "bun"],
424
- ["yarn.lock", "yarn"],
425
- ["package-lock.json", "npm"]
426
- ];
427
- function fromPackageManagerField(dir) {
428
- const manifest = path7.join(dir, "package.json");
429
- if (!existsSync7(manifest))
430
- return null;
431
- try {
432
- const parsed = JSON.parse(readFileSync6(manifest, "utf8"));
433
- const name = parsed.packageManager?.split("@")[0];
434
- return name === "pnpm" || name === "npm" || name === "yarn" || name === "bun" ? name : null;
435
- } catch {
1054
+ // src/commands/doctor/verdict.ts
1055
+ var CHECK_IDS = ["lint-policy", "construct-tests", "ci", "hook", "red-gate"];
1056
+ var LEVELS = ["L0", "L1", "L2", "L3", "L4"];
1057
+ function weakestLink(checks) {
1058
+ const claimed = checks.filter((check) => check.state === "present");
1059
+ if (claimed.length === 0)
436
1060
  return null;
437
- }
438
- }
439
- function detectPackageManager(dir) {
440
- const declared = fromPackageManagerField(dir);
441
- if (declared != null)
442
- return declared;
443
- for (const [lockfile, manager] of LOCKFILES) {
444
- if (existsSync7(path7.join(dir, lockfile)))
445
- return manager;
446
- }
447
- return existsSync7(path7.join(dir, "package.json")) ? "npm" : "none";
448
- }
449
- var pnpmVersionCache;
450
- function detectPnpmVersion() {
451
- if (pnpmVersionCache !== void 0)
452
- return pnpmVersionCache;
453
- try {
454
- pnpmVersionCache = execFileSync("pnpm", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || null;
455
- } catch {
456
- pnpmVersionCache = null;
457
- }
458
- return pnpmVersionCache;
459
- }
460
-
461
- // src/detect/index.ts
462
- function detect(dir) {
463
- const root = path8.resolve(dir);
464
- const monorepoTools = detectMonorepoTools(root);
465
- const workspaceDirs = detectWorkspaceDirs(root);
466
- const hasSrc = existsSync8(path8.join(root, "src"));
467
- return {
468
- dir: root,
469
- packageManager: detectPackageManager(root),
470
- pnpmVersion: detectPnpmVersion(),
471
- layout: detectLayout(root, monorepoTools, workspaceDirs, hasSrc),
472
- monorepoTools,
473
- workspaceDirs,
474
- workspacePackages: detectWorkspacePackages(root, workspaceDirs),
475
- hasSrc,
476
- nodeMajor: Number(process.versions.node.split(".")[0]),
477
- existing: detectExisting(root)
478
- };
1061
+ const ordered = [...claimed].sort((left, right) => CHECK_IDS.indexOf(left.id) - CHECK_IDS.indexOf(right.id));
1062
+ const weakest = ordered.reduce((current, check) => LEVELS.indexOf(check.level) < LEVELS.indexOf(current.level) ? check : current);
1063
+ return { id: weakest.id, level: weakest.level };
479
1064
  }
480
1065
 
481
- // src/materialize/apply.ts
482
- import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
483
- import path9 from "path";
484
- function applyPlan(root, ops) {
485
- const written = [];
486
- for (const op of ops) {
487
- if (op.action === "skip")
488
- continue;
489
- const absolute = path9.join(root, op.target);
490
- mkdirSync(path9.dirname(absolute), { recursive: true });
491
- writeFileSync2(absolute, op.content);
492
- written.push(op);
493
- }
494
- return written;
495
- }
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";
496
1070
 
497
1071
  // src/materialize/plan.ts
498
- import { existsSync as existsSync10, readFileSync as readFileSync7 } from "fs";
499
- import path11 from "path";
1072
+ import { existsSync as existsSync12, readFileSync as readFileSync12 } from "fs";
1073
+ import path14 from "path";
500
1074
 
501
1075
  // src/materialize/rules.ts
502
1076
  var CLAUDE_RULES_DIR = ".claude/rules/";
@@ -593,7 +1167,7 @@ function strategyFor(target) {
593
1167
  return "append-block";
594
1168
  return "create";
595
1169
  }
596
- function isObject(value) {
1170
+ function isJsonObject(value) {
597
1171
  return typeof value === "object" && value != null && !Array.isArray(value);
598
1172
  }
599
1173
  function mergeJson(existing, incoming, conflicts, prefix = "") {
@@ -605,7 +1179,7 @@ function mergeJson(existing, incoming, conflicts, prefix = "") {
605
1179
  continue;
606
1180
  }
607
1181
  const current = existing[key];
608
- if (isObject(current) && isObject(value)) {
1182
+ if (isJsonObject(current) && isJsonObject(value)) {
609
1183
  result[key] = mergeJson(current, value, conflicts, at);
610
1184
  continue;
611
1185
  }
@@ -614,7 +1188,7 @@ function mergeJson(existing, incoming, conflicts, prefix = "") {
614
1188
  }
615
1189
  return result;
616
1190
  }
617
- function markersFor(target) {
1191
+ function blockMarkers(target) {
618
1192
  return target.endsWith(".gitignore") ? [GITIGNORE_BEGIN, GITIGNORE_END] : [BLOCK_BEGIN, BLOCK_END];
619
1193
  }
620
1194
  var DISCOVERY_OPEN = /<!-- construct:discover:([\w-]+) -->/g;
@@ -627,6 +1201,16 @@ function discoveryBlock(document, marker) {
627
1201
  return null;
628
1202
  return { start: start + open.length, end, body: document.slice(start + open.length, end) };
629
1203
  }
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)
1209
+ continue;
1210
+ result = `${result.slice(0, block.start)}${result.slice(block.end)}`;
1211
+ }
1212
+ return result;
1213
+ }
630
1214
  function preserveDiscovery(existing, incoming) {
631
1215
  let result = incoming;
632
1216
  for (const [, marker] of existing.matchAll(DISCOVERY_OPEN)) {
@@ -638,13 +1222,20 @@ function preserveDiscovery(existing, incoming) {
638
1222
  }
639
1223
  return result;
640
1224
  }
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)}`;
1231
+ }
641
1232
  function withoutSecondH1(existing, block) {
642
1233
  if (existing.trim() === "" || !/^# /m.test(existing))
643
1234
  return block;
644
1235
  return block.replace(/^# (.*)$/m, "## $1");
645
1236
  }
646
1237
  function appendBlock(existing, block, target) {
647
- const [begin, end] = markersFor(target);
1238
+ const [begin, end] = blockMarkers(target);
648
1239
  const wrapped = `${begin}
649
1240
  ${preserveDiscovery(existing, withoutSecondH1(existing, block)).trimEnd()}
650
1241
  ${end}
@@ -658,43 +1249,43 @@ ${end}
658
1249
  }
659
1250
 
660
1251
  // src/materialize/templates.ts
661
- import { existsSync as existsSync9, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
662
- import path10 from "path";
663
- import { fileURLToPath } from "url";
664
- var HERE = path10.dirname(fileURLToPath(import.meta.url));
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));
665
1256
  function templatesRoot() {
666
- const candidates = [path10.resolve(HERE, "../templates"), path10.resolve(HERE, "../../templates")];
667
- const found = candidates.find((candidate) => existsSync9(candidate));
1257
+ const candidates = [path13.resolve(HERE2, "../templates"), path13.resolve(HERE2, "../../templates")];
1258
+ const found = candidates.find((candidate) => existsSync11(candidate));
668
1259
  if (found == null)
669
- throw new Error(`templates directory not found next to ${HERE}`);
1260
+ throw new Error(`templates directory not found next to ${HERE2}`);
670
1261
  return found;
671
1262
  }
672
1263
  var EXISTING_SUFFIX = ".existing.eta";
673
1264
  function toTargetPath(relative) {
674
- const segments = relative.split(path10.sep).map((segment) => segment.startsWith("_") ? `.${segment.slice(1)}` : segment);
1265
+ const segments = relative.split(path13.sep).map((segment) => segment.startsWith("_") ? `.${segment.slice(1)}` : segment);
675
1266
  const joined = segments.join("/");
676
1267
  if (joined.endsWith(EXISTING_SUFFIX))
677
1268
  return { target: joined.slice(0, -EXISTING_SUFFIX.length), rendered: true, variant: "existing" };
678
1269
  return joined.endsWith(".eta") ? { target: joined.slice(0, -".eta".length), rendered: true, variant: "default" } : { target: joined, rendered: false, variant: "default" };
679
1270
  }
680
1271
  function walk(root, current, files) {
681
- for (const entry of readdirSync5(current).sort()) {
682
- const absolute = path10.join(current, entry);
1272
+ for (const entry of readdirSync6(current).sort()) {
1273
+ const absolute = path13.join(current, entry);
683
1274
  if (statSync3(absolute).isDirectory())
684
1275
  walk(root, absolute, files);
685
1276
  else if (entry !== ".DS_Store")
686
- files.push(path10.relative(root, absolute));
1277
+ files.push(path13.relative(root, absolute));
687
1278
  }
688
1279
  }
689
1280
  function listTemplateFiles(group) {
690
- const root = path10.join(templatesRoot(), group);
691
- if (!existsSync9(root))
1281
+ const root = path13.join(templatesRoot(), group);
1282
+ if (!existsSync11(root))
692
1283
  throw new Error(`template group "${group}" does not exist`);
693
1284
  const files = [];
694
1285
  walk(root, root, files);
695
1286
  return files.map((relative) => {
696
1287
  const { target, rendered, variant } = toTargetPath(relative);
697
- return { group, source: path10.join(root, relative), target, rendered, variant };
1288
+ return { group, source: path13.join(root, relative), target, rendered, variant };
698
1289
  });
699
1290
  }
700
1291
  var BLOCK = /^[ \t]*\{\{#(if|unless) (\w+)\}\}[ \t]*\n([\s\S]*?)^[ \t]*\{\{\/\1\}\}[ \t]*\n/gm;
@@ -721,7 +1312,7 @@ function mountTarget(mount, target) {
721
1312
  return mount.into == null || mount.into === "." ? target : `${mount.into.replace(/\/$/, "")}/${target}`;
722
1313
  }
723
1314
  function readTemplate(source, rendered, vars) {
724
- const raw = readFileSync7(source, "utf8");
1315
+ const raw = readFileSync12(source, "utf8");
725
1316
  return rendered ? render(raw, vars) : raw;
726
1317
  }
727
1318
  var SORTED_SECTIONS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
@@ -779,12 +1370,13 @@ function layerJson(earlier, later) {
779
1370
  var NOT_ADDED_TO_EXISTING_MANIFEST = ["version"];
780
1371
  function planOne(root, target, content, conflicts, existingVariant) {
781
1372
  const strategy = strategyFor(target);
782
- const absolute = path11.join(root, target);
783
- const exists = existsSync10(absolute);
784
- if (!exists)
785
- 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
+ }
786
1378
  if (strategy === "merge-json") {
787
- const existing = JSON.parse(readFileSync7(absolute, "utf8"));
1379
+ const existing = JSON.parse(readFileSync12(absolute, "utf8"));
788
1380
  const incoming = JSON.parse(content);
789
1381
  for (const key of NOT_ADDED_TO_EXISTING_MANIFEST)
790
1382
  delete incoming[key];
@@ -795,8 +1387,14 @@ function planOne(root, target, content, conflicts, existingVariant) {
795
1387
  ` };
796
1388
  }
797
1389
  if (strategy === "append-block") {
798
- const existing = readFileSync7(absolute, "utf8");
799
- 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
+ };
800
1398
  }
801
1399
  return { target, strategy, action: "skip", content, note: "exists, review manually" };
802
1400
  }
@@ -821,143 +1419,743 @@ function planMaterialize(root, groups, vars, options) {
821
1419
  layered.set(target, previous == null || strategyFor(target) !== "merge-json" ? content : layerJson(previous, content));
822
1420
  }
823
1421
  }
824
- 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));
825
- return { ops, conflicts, omittedGroups };
826
- }
827
-
828
- // src/presets/index.ts
829
- var AI_TARGET_LABELS = {
830
- claude: "Claude Code",
831
- cursor: "Cursor",
832
- both: "Claude Code, Cursor"
833
- };
834
- var EXPRESS_APP = "stacks/express-api/app";
835
- var EXPRESS_REPO = "stacks/express-api/repo";
836
- var HTTP_CONTRACT = "stacks/http-contract";
837
- function sampleWorkspace(scope) {
838
- return [
839
- { dir: "packages/shared", name: `${scope}/shared` },
840
- { dir: "apps/api", name: `${scope}/api` }
841
- ];
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";
842
2112
  }
843
- function quote(value) {
844
- return `'${value}'`;
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;
845
2123
  }
846
- function renderWorkspacePolicy(packages, sample) {
847
- const names = packages.map((pkg) => pkg.name);
848
- const allowedFor = (pkg) => {
849
- if (sample)
850
- return pkg.dir.startsWith("apps/") ? names.filter((name) => name !== pkg.name) : [];
851
- return names.filter((name) => name !== pkg.name);
852
- };
853
- const lines = packages.map((pkg) => ` ${quote(pkg.dir)}: [${allowedFor(pkg).map(quote).join(", ")}],`);
2124
+
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"));
854
2131
  return {
855
- workspacePackages: `[${names.map(quote).join(", ")}]`,
856
- allowedWorkspaceImports: `{
857
- ${lines.join("\n")}
858
- }`
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)
859
2142
  };
860
2143
  }
861
- var PRESETS = {
862
- "node-backend": {
863
- id: "node-backend",
864
- label: "Node.js backend",
865
- description: "Express + TypeScript, contract-first HTTP API, composition root, harness",
866
- groups: [
867
- "base",
868
- "harness",
869
- HTTP_CONTRACT,
870
- { group: EXPRESS_APP, onlyWhenEmpty: true },
871
- { group: EXPRESS_REPO, onlyWhenEmpty: true },
872
- { group: "presets/node-backend/sample", onlyWhenEmpty: true },
873
- "presets/node-backend/baseline"
874
- ],
875
- contracts: true,
876
- available: true,
877
- vars: () => ({
878
- contractPath: "contracts/api/openapi.yaml",
879
- contractTypesOutput: "src/contracts/openapi.ts",
880
- contractTypesImport: "./openapi.js",
881
- contractPathFromConfig: "../contracts/api/openapi.yaml",
882
- appRoot: ""
883
- })
884
- },
885
- "node-frontend": {
886
- id: "node-frontend",
887
- label: "Node.js frontend",
888
- description: "Vite + TypeScript, platform CSS rules, composition root, harness; no API contract",
889
- groups: [
890
- "base",
891
- "harness",
892
- { group: "presets/node-frontend/sample", onlyWhenEmpty: true },
893
- "presets/node-frontend/baseline"
894
- ],
895
- contracts: false,
896
- available: true,
897
- vars: () => ({
898
- contractPath: "",
899
- contractTypesOutput: ""
900
- })
901
- },
902
- "node-library": {
903
- id: "node-library",
904
- label: "Node.js library or CLI",
905
- description: "TypeScript package with no HTTP contract: architecture policy, composition models, harness",
906
- groups: ["base", "harness"],
907
- contracts: false,
908
- available: true,
909
- vars: () => ({
910
- contractPath: "",
911
- contractTypesOutput: ""
912
- })
913
- },
914
- "monorepo": {
915
- id: "monorepo",
916
- label: "pnpm monorepo",
917
- description: "apps/* + packages/*, catalog:, contract types in packages/shared, dependency policy in lint",
918
- groups: [
919
- "base",
920
- "harness",
921
- HTTP_CONTRACT,
922
- { group: EXPRESS_APP, into: "apps/api", onlyWhenEmpty: true },
923
- { group: EXPRESS_REPO, onlyWhenEmpty: true },
924
- { group: "presets/monorepo/sample", onlyWhenEmpty: true },
925
- "presets/monorepo/baseline"
926
- ],
927
- contracts: true,
928
- available: true,
929
- vars: (report, projectName) => {
930
- const detected = report.workspacePackages;
931
- const packages = detected.length > 0 ? detected : sampleWorkspace(`@${projectName}`);
932
- return {
933
- contractPath: "contracts/api/openapi.yaml",
934
- contractTypesOutput: "packages/shared/src/api/openapi.ts",
935
- contractTypesImport: `@${projectName}/shared`,
936
- contractPathFromConfig: "../../../contracts/api/openapi.yaml",
937
- appRoot: "apps/api/",
938
- ...renderWorkspacePolicy(packages, detected.length === 0)
939
- };
940
- }
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);
941
2157
  }
942
- };
943
- var PRESET_IDS = Object.keys(PRESETS);
944
- var PRESET_LIST = Object.values(PRESETS);
945
- function isPresetId(value) {
946
- return value in PRESETS;
947
- }
948
- function getPreset(id) {
949
- return PRESETS[id];
950
- }
951
- function aiGroups(target) {
952
- return target === "both" ? ["ai/shared", "ai/claude", "ai/cursor"] : ["ai/shared", `ai/${target}`];
953
- }
954
- var DEFAULT_REVIEW_MODEL = "claude-sonnet-5";
955
- function reviewGroups(provider) {
956
- return provider === "claude" ? ["ai/review"] : [];
957
- }
958
- function defaultProjectName(dir) {
959
- const base = dir.split(/[\\/]/).filter(Boolean).at(-1) ?? "project";
960
- return base.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
2158
+ return written;
961
2159
  }
962
2160
 
963
2161
  // src/ui/prompts.ts
@@ -1030,24 +2228,6 @@ function createClackPrompter(lore, streams = {}) {
1030
2228
  };
1031
2229
  }
1032
2230
 
1033
- // src/version.ts
1034
- import { readFileSync as readFileSync8 } from "fs";
1035
- import path12 from "path";
1036
- import { fileURLToPath as fileURLToPath2 } from "url";
1037
- var HERE2 = path12.dirname(fileURLToPath2(import.meta.url));
1038
- function readVersion() {
1039
- for (const candidate of ["../package.json", "../../package.json"]) {
1040
- try {
1041
- const parsed = JSON.parse(readFileSync8(path12.resolve(HERE2, candidate), "utf8"));
1042
- if (parsed.name === "mikoshi-construct" && parsed.version != null)
1043
- return parsed.version;
1044
- } catch {
1045
- }
1046
- }
1047
- return "0.0.0";
1048
- }
1049
- var VERSION = readVersion();
1050
-
1051
2231
  // src/commands/soulkill.ts
1052
2232
  function yesNo(value) {
1053
2233
  return value ? "yes" : "no";
@@ -1071,6 +2251,13 @@ function printDetectReport(ui2, report) {
1071
2251
 
1072
2252
  // src/commands/init.ts
1073
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
+ }
1074
2261
  function aborted(skipped = [], conflicts = []) {
1075
2262
  return { status: "aborted", written: [], skipped, conflicts };
1076
2263
  }
@@ -1138,7 +2325,7 @@ async function askChoices(ui2, options, report, root, prompter) {
1138
2325
  return { presetId, ai, projectName, review };
1139
2326
  }
1140
2327
  async function runInit(ui2, options, prompter) {
1141
- const root = path13.resolve(options.dir);
2328
+ const root = path21.resolve(options.dir);
1142
2329
  mkdirSync2(root, { recursive: true });
1143
2330
  if (!options.yes && prompter == null) {
1144
2331
  ui2.glitch(ui2.lore.needsTerminal);
@@ -1176,7 +2363,7 @@ async function runInit(ui2, options, prompter) {
1176
2363
  contracts: preset.contracts ? "true" : "false",
1177
2364
  contractPath: "contracts/api/openapi.yaml",
1178
2365
  contractTypesOutput: "src/contracts/openapi.ts",
1179
- compositionDir: report.existing.compositionDir ?? "architecture/composition",
2366
+ compositionDir: report.existing.compositionDir ?? DEFAULT_COMPOSITION_DIR,
1180
2367
  harnessCommand: "pnpm run quality",
1181
2368
  packageManager: "pnpm",
1182
2369
  pnpmVersion: report.pnpmVersion ?? "",
@@ -1202,7 +2389,7 @@ async function runInit(ui2, options, prompter) {
1202
2389
  ui2.line();
1203
2390
  if (plan.omittedGroups.length > 0)
1204
2391
  ui2.line(ui2.theme.dim(` ${ui2.lore.sampleOmitted}`));
1205
- if (report.existing.compositionDir != null && report.existing.compositionDir !== "architecture/composition")
2392
+ if (report.existing.compositionDir != null && report.existing.compositionDir !== DEFAULT_COMPOSITION_DIR)
1206
2393
  ui2.line(ui2.theme.dim(` Existing composition models found at ${report.existing.compositionDir}/ \u2014 kept there, not moved.`));
1207
2394
  if (plan.conflicts.length > 0)
1208
2395
  ui2.glitch("Existing values kept; review these keys by hand:", plan.conflicts);
@@ -1215,7 +2402,17 @@ async function runInit(ui2, options, prompter) {
1215
2402
  if (interactive != null && await interactive.confirm(ui2.lore.confirm) !== true)
1216
2403
  return aborted(skipped, plan.conflicts);
1217
2404
  const written = applyPlan(root, plan.ops);
1218
- 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
+ }
1219
2416
  ui2.phase(4, 4, "\u2705", ui2.lore.phaseOnline);
1220
2417
  ui2.tree([
1221
2418
  ["Written", `${written.length} files`],
@@ -1225,10 +2422,202 @@ async function runInit(ui2, options, prompter) {
1225
2422
  return { status: "done", written: written.map((op) => op.target), skipped, conflicts: plan.conflicts };
1226
2423
  }
1227
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
+
1228
2616
  // src/ui/console.ts
1229
- import process2 from "process";
2617
+ import process4 from "process";
1230
2618
 
1231
2619
  // src/ui/lore.ts
2620
+ var BLOCK_REPLACED_WHOLE = "block-replaced-whole-discovery-bodies-carried-over";
1232
2621
  var BANNER = String.raw`
1233
2622
  ███╗ ███╗██╗██╗ ██╗ ██████╗ ███████╗██╗ ██╗██╗
1234
2623
  ████╗ ████║██║██║ ██╔╝██╔═══██╗██╔════╝██║ ██║██║
@@ -1264,12 +2653,68 @@ var LORE = {
1264
2653
  flatlined: "FLATLINED",
1265
2654
  stable: "CONSTRUCT STABLE",
1266
2655
  discoveryIncomplete: "Discovery incomplete.",
2656
+ provenance: "AUTHORSHIP TRACE",
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`.",
2661
+ enforcement: "ENFORCEMENT TRACE",
2662
+ typecheckCaveat: "Typecheck cannot carry this stack alone.",
2663
+ weakestLink: (id, level) => `WEAKEST LINK: ${id} at ${level}`,
2664
+ levelLift: {
2665
+ L0: "wire it to a hook or a workflow step and it climbs",
2666
+ L1: "only a reviewer stands behind it; a hook or a workflow step raises it",
2667
+ L2: "a hook is bypassable with --no-verify; running it in CI too raises it",
2668
+ L3: "L3 is the ceiling doctor can read: branch protection is what makes it blocking, and that lives in the API",
2669
+ L4: "nothing above this"
2670
+ },
2671
+ weakestLinkNone: "WEAKEST LINK: nothing is claimed",
1267
2672
  wireHarness: "Existing configs were kept, so the harness is not wired in yet. /construct-discover does this first; by hand:",
1268
2673
  wireHarnessSteps: [
1269
2674
  "eslint: ignore scripts/construct/*.workflow.mjs (the ladder script uses top-level return)",
1270
2675
  "tsconfig: include scripts/**/*.ts; vitest: include scripts/tests/**/*.test.ts",
1271
2676
  "package.json: make the quality script run composition:check (and contracts:check when there is a contract)"
1272
- ]
2677
+ ],
2678
+ costUnsupported: (runtime) => `No usage feed: the ${runtime} runtime does not expose per-run token usage.`,
2679
+ costEmpty: "No /implement runs recorded here yet.",
2680
+ costKeyMismatch: (key) => `Runs for this repository were recorded under another path. Looked up: ${key}`,
2681
+ costKeyUnknown: (key) => `Runs may be recorded under another path \u2014 the evidence is not conclusive. Looked up: ${key}`,
2682
+ ledgerCounts: (runs, agents, failures, tokens2) => `Ledger, kept by hand and trusted by nobody: ${runs} runs, ${agents} agents, ${failures} unfinished, ${tokens2} tokens.`,
2683
+ ledgerMalformed: (count) => `${count} ledger line${count === 1 ? "" : "s"} could not be read as a run record.`,
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.`,
2685
+ ledgerEntryWithoutSession: "logged as a run, no session behind it",
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"}.`
1273
2718
  };
1274
2719
  var PLAIN_LORE = {
1275
2720
  subtitle: (version) => `mikoshi-construct v${version}`,
@@ -1299,17 +2744,73 @@ var PLAIN_LORE = {
1299
2744
  flatlined: "ERROR",
1300
2745
  stable: "OK",
1301
2746
  discoveryIncomplete: "Discovery incomplete.",
2747
+ provenance: "Discovery provenance",
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`.",
2752
+ enforcement: "Enforcement",
2753
+ typecheckCaveat: "Typecheck cannot carry this stack alone.",
2754
+ weakestLink: (id, level) => `Weakest link: ${id} at ${level}`,
2755
+ levelLift: {
2756
+ L0: "wire it to a hook or a workflow step and it climbs",
2757
+ L1: "only a reviewer stands behind it; a hook or a workflow step raises it",
2758
+ L2: "a hook is bypassable with --no-verify; running it in CI too raises it",
2759
+ L3: "L3 is the ceiling doctor can read: branch protection is what makes it blocking, and that lives in the API",
2760
+ L4: "nothing above this"
2761
+ },
2762
+ weakestLinkNone: "Weakest link: nothing is claimed",
1302
2763
  wireHarness: "Existing configs were kept, so the harness is not wired in yet. /construct-discover does this first; by hand:",
1303
2764
  wireHarnessSteps: [
1304
2765
  "eslint: ignore scripts/construct/*.workflow.mjs (the ladder script uses top-level return)",
1305
2766
  "tsconfig: include scripts/**/*.ts; vitest: include scripts/tests/**/*.test.ts",
1306
2767
  "package.json: make the quality script run composition:check (and contracts:check when there is a contract)"
1307
- ]
2768
+ ],
2769
+ costUnsupported: (runtime) => `The ${runtime} runtime does not expose per-run token usage.`,
2770
+ costEmpty: "No /implement runs recorded here yet.",
2771
+ costKeyMismatch: (key) => `Runs for this repository were recorded under another path. Looked up: ${key}`,
2772
+ costKeyUnknown: (key) => `Runs may be recorded under another path \u2014 the evidence is not conclusive. Looked up: ${key}`,
2773
+ ledgerCounts: (runs, agents, failures, tokens2) => `Ledger (a skill step writes it, nothing enforces it): ${runs} runs, ${agents} agents, ${failures} unfinished, ${tokens2} tokens.`,
2774
+ ledgerMalformed: (count) => `${count} ledger line${count === 1 ? "" : "s"} could not be read as a run record.`,
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.`,
2776
+ ledgerEntryWithoutSession: "logged as a run, no session behind it",
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"}.`
1308
2809
  };
1309
2810
 
1310
2811
  // src/ui/console.ts
1311
2812
  var stdoutWriter = (text2) => {
1312
- process2.stdout.write(text2);
2813
+ process4.stdout.write(text2);
1313
2814
  };
1314
2815
  function createUi(theme, write = stdoutWriter) {
1315
2816
  const plain = theme.name === "plain";
@@ -1368,21 +2869,21 @@ function createUi(theme, write = stdoutWriter) {
1368
2869
  }
1369
2870
 
1370
2871
  // src/ui/theme.ts
1371
- import process3 from "process";
2872
+ import process5 from "process";
1372
2873
  import pc from "picocolors";
1373
2874
  function rgb(r, g, b) {
1374
2875
  return (text2) => `\x1B[38;2;${r};${g};${b}m${text2}\x1B[39m`;
1375
2876
  }
1376
2877
  var identity = (text2) => text2;
1377
2878
  function supportsColor() {
1378
- if (process3.env.NO_COLOR != null && process3.env.NO_COLOR !== "")
2879
+ if (process5.env.NO_COLOR != null && process5.env.NO_COLOR !== "")
1379
2880
  return false;
1380
- if (process3.env.FORCE_COLOR != null && process3.env.FORCE_COLOR !== "0")
2881
+ if (process5.env.FORCE_COLOR != null && process5.env.FORCE_COLOR !== "0")
1381
2882
  return true;
1382
2883
  return pc.isColorSupported;
1383
2884
  }
1384
2885
  function truecolor() {
1385
- const term = process3.env.COLORTERM ?? "";
2886
+ const term = process5.env.COLORTERM ?? "";
1386
2887
  return term === "truecolor" || term === "24bit";
1387
2888
  }
1388
2889
  var PLAIN = {
@@ -1451,12 +2952,12 @@ var init = defineCommand({
1451
2952
  const console = ui(args);
1452
2953
  console.banner(VERSION, args.johnny);
1453
2954
  try {
1454
- const prompter = isTTY(process4.stdout) && process4.stdin.isTTY === true ? createClackPrompter(console.lore) : void 0;
2955
+ const prompter = isTTY(process6.stdout) && process6.stdin.isTTY === true ? createClackPrompter(console.lore) : void 0;
1455
2956
  const result = await runInit(console, { dir: args.dir, preset: args.preset, ai: args.ai, name: args.name, review: args.review, reviewModel: args.reviewModel, yes: args.yes, dryRun: args.dryRun }, prompter);
1456
- process4.exitCode = result.status === "aborted" ? 1 : 0;
2957
+ process6.exitCode = result.status === "aborted" ? 1 : 0;
1457
2958
  } catch (error) {
1458
2959
  console.flatline(error instanceof Error ? error.message : String(error));
1459
- process4.exitCode = 1;
2960
+ process6.exitCode = 1;
1460
2961
  }
1461
2962
  }
1462
2963
  });
@@ -1469,7 +2970,7 @@ var soulkill = defineCommand({
1469
2970
  run({ args }) {
1470
2971
  const report = detect(args.dir);
1471
2972
  if (args.json) {
1472
- process4.stdout.write(`${JSON.stringify(report, null, 2)}
2973
+ process6.stdout.write(`${JSON.stringify(report, null, 2)}
1473
2974
  `);
1474
2975
  return;
1475
2976
  }
@@ -1489,13 +2990,13 @@ var doctor = defineCommand({
1489
2990
  run({ args }) {
1490
2991
  const result = runDoctor(args.dir);
1491
2992
  if (args.json) {
1492
- process4.stdout.write(`${JSON.stringify(result, null, 2)}
2993
+ process6.stdout.write(`${JSON.stringify(result, null, 2)}
1493
2994
  `);
1494
- process4.exitCode = result?.ok === true ? 0 : 1;
2995
+ process6.exitCode = result?.ok === true ? 0 : 1;
1495
2996
  return;
1496
2997
  }
1497
2998
  const console = ui(args);
1498
- process4.exitCode = printDoctor(console, result);
2999
+ process6.exitCode = printDoctor(console, result);
1499
3000
  }
1500
3001
  });
1501
3002
  var cost = defineCommand({
@@ -1506,16 +3007,49 @@ var cost = defineCommand({
1506
3007
  json: { type: "boolean", description: "Machine-readable report", default: false }
1507
3008
  },
1508
3009
  run({ args }) {
1509
- const runs = collectWorkflowRuns(path14.resolve(args.dir));
3010
+ const report = costReport(path22.resolve(args.dir));
1510
3011
  if (args.json) {
1511
- const selected = runs == null ? null : args.last ? runs.slice(-1) : runs;
1512
- process4.stdout.write(`${JSON.stringify(selected, null, 2)}
3012
+ process6.stdout.write(`${JSON.stringify(costJson(report, args.last), null, 2)}
1513
3013
  `);
1514
- process4.exitCode = runs == null ? 1 : 0;
3014
+ process6.exitCode = COST_EXIT[report.status];
1515
3015
  return;
1516
3016
  }
3017
+ process6.exitCode = printCost(ui(args), report, args.last);
3018
+ }
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 }) {
1517
3028
  const console = ui(args);
1518
- process4.exitCode = printCost(console, runs, args.last);
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);
1519
3053
  }
1520
3054
  });
1521
3055
  var main = defineCommand({
@@ -1530,6 +3064,7 @@ var main = defineCommand({
1530
3064
  inspect: soulkill,
1531
3065
  capture: soulkill,
1532
3066
  doctor,
3067
+ sync,
1533
3068
  cost
1534
3069
  }
1535
3070
  });