@purix/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +202 -0
  2. package/dist/cli/commands/auth.d.ts +3 -0
  3. package/dist/cli/commands/auth.d.ts.map +1 -0
  4. package/dist/cli/commands/auth.js +130 -0
  5. package/dist/cli/commands/auth.js.map +1 -0
  6. package/dist/cli/commands/backup.d.ts +3 -0
  7. package/dist/cli/commands/backup.d.ts.map +1 -0
  8. package/dist/cli/commands/backup.js +54 -0
  9. package/dist/cli/commands/backup.js.map +1 -0
  10. package/dist/cli/commands/config.d.ts +3 -0
  11. package/dist/cli/commands/config.d.ts.map +1 -0
  12. package/dist/cli/commands/config.js +35 -0
  13. package/dist/cli/commands/config.js.map +1 -0
  14. package/dist/cli/commands/connect.d.ts +16 -0
  15. package/dist/cli/commands/connect.d.ts.map +1 -0
  16. package/dist/cli/commands/connect.js +45 -0
  17. package/dist/cli/commands/connect.js.map +1 -0
  18. package/dist/cli/commands/dev.d.ts +3 -0
  19. package/dist/cli/commands/dev.d.ts.map +1 -0
  20. package/dist/cli/commands/dev.js +58 -0
  21. package/dist/cli/commands/dev.js.map +1 -0
  22. package/dist/cli/commands/index.d.ts +3 -0
  23. package/dist/cli/commands/index.d.ts.map +1 -0
  24. package/dist/cli/commands/index.js +35 -0
  25. package/dist/cli/commands/index.js.map +1 -0
  26. package/dist/cli/commands/lang.d.ts +3 -0
  27. package/dist/cli/commands/lang.d.ts.map +1 -0
  28. package/dist/cli/commands/lang.js +187 -0
  29. package/dist/cli/commands/lang.js.map +1 -0
  30. package/dist/cli/commands/lifecycle.d.ts +3 -0
  31. package/dist/cli/commands/lifecycle.d.ts.map +1 -0
  32. package/dist/cli/commands/lifecycle.js +782 -0
  33. package/dist/cli/commands/lifecycle.js.map +1 -0
  34. package/dist/cli/commands/mcp.d.ts +3 -0
  35. package/dist/cli/commands/mcp.d.ts.map +1 -0
  36. package/dist/cli/commands/mcp.js +75 -0
  37. package/dist/cli/commands/mcp.js.map +1 -0
  38. package/dist/cli/commands/mcp_serve.d.ts +3 -0
  39. package/dist/cli/commands/mcp_serve.d.ts.map +1 -0
  40. package/dist/cli/commands/mcp_serve.js +16 -0
  41. package/dist/cli/commands/mcp_serve.js.map +1 -0
  42. package/dist/cli/commands/memory.d.ts +3 -0
  43. package/dist/cli/commands/memory.d.ts.map +1 -0
  44. package/dist/cli/commands/memory.js +12 -0
  45. package/dist/cli/commands/memory.js.map +1 -0
  46. package/dist/cli/commands/migrations.d.ts +3 -0
  47. package/dist/cli/commands/migrations.d.ts.map +1 -0
  48. package/dist/cli/commands/migrations.js +98 -0
  49. package/dist/cli/commands/migrations.js.map +1 -0
  50. package/dist/cli/commands/observability.d.ts +3 -0
  51. package/dist/cli/commands/observability.d.ts.map +1 -0
  52. package/dist/cli/commands/observability.js +141 -0
  53. package/dist/cli/commands/observability.js.map +1 -0
  54. package/dist/cli/commands/provider.d.ts +3 -0
  55. package/dist/cli/commands/provider.d.ts.map +1 -0
  56. package/dist/cli/commands/provider.js +82 -0
  57. package/dist/cli/commands/provider.js.map +1 -0
  58. package/dist/cli/commands/security.d.ts +3 -0
  59. package/dist/cli/commands/security.d.ts.map +1 -0
  60. package/dist/cli/commands/security.js +27 -0
  61. package/dist/cli/commands/security.js.map +1 -0
  62. package/dist/cli/commands/tier.d.ts +3 -0
  63. package/dist/cli/commands/tier.d.ts.map +1 -0
  64. package/dist/cli/commands/tier.js +27 -0
  65. package/dist/cli/commands/tier.js.map +1 -0
  66. package/dist/cli/commands/tools.d.ts +3 -0
  67. package/dist/cli/commands/tools.d.ts.map +1 -0
  68. package/dist/cli/commands/tools.js +18 -0
  69. package/dist/cli/commands/tools.js.map +1 -0
  70. package/dist/cli/output.d.ts +5 -0
  71. package/dist/cli/output.d.ts.map +1 -0
  72. package/dist/cli/output.js +22 -0
  73. package/dist/cli/output.js.map +1 -0
  74. package/dist/cli/savings_output.d.ts +9 -0
  75. package/dist/cli/savings_output.d.ts.map +1 -0
  76. package/dist/cli/savings_output.js +62 -0
  77. package/dist/cli/savings_output.js.map +1 -0
  78. package/dist/cli.d.ts +12 -0
  79. package/dist/cli.d.ts.map +1 -0
  80. package/dist/cli.js +155 -0
  81. package/dist/cli.js.map +1 -0
  82. package/dist/telemetry/log.d.ts +4 -0
  83. package/dist/telemetry/log.d.ts.map +1 -0
  84. package/dist/telemetry/log.js +92 -0
  85. package/dist/telemetry/log.js.map +1 -0
  86. package/package.json +47 -0
@@ -0,0 +1,782 @@
1
+ import { confirmGated } from "@purix/core/cli-io/gated-confirm";
2
+ import { readManifest, writeManifestWithLimitCheck, linkComponents, createPendingOperation, completeModification, commitManifestWithRetry, deletePendingOperation, deleteManifestEntry, removeDependent, removeDependencyReference, } from "@purix/core/manifest/store";
3
+ import { existsSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { buildManifestEntry, writeScaffold } from "@purix/core/entrypoints/scaffold";
6
+ import { snapshotSavings, printRunSummary } from "../savings_output.js";
7
+ import { classifyGreenfield, refineIntent, classifyModification, classifyDiff, scanDiffForInjectionAttempts, scanFilesForInjectionAttempts } from "@purix/core/llm/classify";
8
+ import { scanForInjectionAttempts } from "@purix/core/llm/injection";
9
+ import { checkDrift } from "@purix/core/state/drift";
10
+ import { applyModificationFiles, rollbackModification } from "@purix/core/entrypoints/modify";
11
+ import { computeSyncHash } from "@purix/core/state/hash";
12
+ import { compilePatch } from "@purix/core/verify/compile";
13
+ import { verifyInSandbox } from "@purix/core/sandbox/sandbox";
14
+ import { getDependents, reVerifyDependents } from "@purix/core/verify/impact";
15
+ import { runSelfHealingLoop } from "@purix/core/recovery/heal";
16
+ import { runEscalation } from "@purix/core/recovery/escalate";
17
+ import { auditSecurityPatterns } from "@purix/core/security/security_audit";
18
+ import { suggestTools, formatSuggestions } from "@purix/core/tools/matchmaker";
19
+ import { buildMigrationPlan, stageMigration } from "@purix/core/state/migration";
20
+ import { assertAuthorizedToApprove } from "@purix/core/security/auth";
21
+ import { recordEvent } from "@purix/core/manifest/events";
22
+ import { readRelevantMemory, readGlobalMemory, formatMemoryLines } from "@purix/core/manifest/memory";
23
+ import { computeRequestKey, findPriorCommit, recordRequestCommit } from "@purix/core/state/idempotency";
24
+ import { ingestDiffFromFile } from "@purix/core/entrypoints/ingest";
25
+ import { resolveLanguage, getLanguageProvider } from "@purix/core/language/registry";
26
+ import { evaluateTrustGate, hasTestCoverage, checkDeterministicOverrideFloor, loadDofPatterns } from "@purix/core/gates/trustgate";
27
+ import { setBudgetOverride } from "@purix/core/llm/budget";
28
+ import { setSecurityOverride } from "@purix/core/gates/security_gate";
29
+ import { recordOverrideAudit } from "@purix/core/security/override_audit";
30
+ export function registerLifecycleCommands(program) {
31
+ // ---------------------------------------------------------------------------
32
+ // create (§3.3 Greenfield)
33
+ // ---------------------------------------------------------------------------
34
+ program
35
+ .command("create <n>")
36
+ .description("Scaffold a new component (Greenfield, Instruction Path)")
37
+ .action(async (name) => {
38
+ const savingsBefore = snapshotSavings();
39
+ if (readManifest(name)) {
40
+ console.error(`Component "${name}" already exists in the manifest. Use "purix modify" instead.`);
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ console.log(`Planning "${name}"...`);
45
+ const plan = await classifyGreenfield(name);
46
+ console.log(`\nProposed component: ${plan.component_id} (${plan.component_type})`);
47
+ for (const f of plan.files)
48
+ console.log(` ${f.path} — ${f.purpose}`);
49
+ if (plan.depends_on.length > 0)
50
+ console.log(` depends_on: ${plan.depends_on.join(", ")}`);
51
+ // §11.1: Tool Matchmaker reads Repository Memory too. A brand new
52
+ // component has no history of its own yet, so this checks the
53
+ // repo-wide conventions log instead ("we decided against X" notes).
54
+ const globalMemory = readGlobalMemory();
55
+ if (globalMemory.length > 0) {
56
+ console.log(`\n Repository Memory (§11.1) — keep these in mind before picking a package:`);
57
+ for (const line of formatMemoryLines(globalMemory))
58
+ console.log(` ${line}`);
59
+ }
60
+ // §12.1 Tool Matchmaker: add_component is exactly the trigger case
61
+ // this describes — check for a vetted, maintained npm package
62
+ // before committing to hand-rolled scaffolding. Advisory only: this
63
+ // never blocks and never touches package.json itself.
64
+ const matchQuery = `${name} ${plan.files.map((f) => f.purpose).join(" ")}`.trim();
65
+ const toolSuggestions = await suggestTools(matchQuery);
66
+ if (toolSuggestions.length > 0) {
67
+ console.log(`\n Vetted package suggestion(s) (§12.1, advisory only — not applied):`);
68
+ console.log(formatSuggestions(toolSuggestions));
69
+ }
70
+ // BUG FIX: assertAuthorizedToApprove is async (it may prompt to
71
+ // bootstrap the sole-approver file) — this was previously called
72
+ // without await, meaning a declined bootstrap couldn't reliably
73
+ // block the write it's supposed to gate. Same fix applied below in
74
+ // "modify" and in escalate.ts.
75
+ try {
76
+ await assertAuthorizedToApprove();
77
+ }
78
+ catch (err) {
79
+ console.error(`\nšŸ›‘ ${err instanceof Error ? err.message : err}`);
80
+ process.exitCode = 1;
81
+ return;
82
+ }
83
+ const proceed = await confirmGated(`\nWrite these files and register the component?`, "create_write", name);
84
+ if (!proceed) {
85
+ console.log("Cancelled — nothing written.");
86
+ return;
87
+ }
88
+ await writeScaffold(plan, process.cwd());
89
+ const entry = buildManifestEntry(plan);
90
+ entry.last_synced_hash = computeSyncHash(plan.files.map((f) => ({ path: f.path, content: f.starter_content })));
91
+ writeManifestWithLimitCheck(entry);
92
+ if (plan.depends_on.length > 0) {
93
+ const link = linkComponents(entry.component_id, plan.depends_on);
94
+ if (link.skippedNotFound.length > 0) {
95
+ console.log(` ⚠ depends_on referenced unknown component(s), not linked: ${link.skippedNotFound.join(", ")}`);
96
+ }
97
+ }
98
+ console.log(`\nāœ… "${entry.component_id}" created (v${entry.current_version}).`);
99
+ printRunSummary(savingsBefore);
100
+ });
101
+ // ---------------------------------------------------------------------------
102
+ // modify (§3.1 Instruction Path — the full Brownfield node loop)
103
+ // ---------------------------------------------------------------------------
104
+ program
105
+ .command("modify <componentId> <instruction>")
106
+ .description("Modify an existing component via the Instruction Path ( Brownfield )")
107
+ .option("--override <reason>", "Override gate stops with a reason")
108
+ .action(async (componentId, instruction, options) => {
109
+ if (options.override !== undefined) {
110
+ setBudgetOverride(options.override);
111
+ setSecurityOverride(options.override);
112
+ }
113
+ try {
114
+ const savingsBefore = snapshotSavings();
115
+ // Node 2: State Resolver
116
+ const entry = readManifest(componentId);
117
+ if (!entry) {
118
+ console.error(`No manifest entry for "${componentId}". Run "purix create" first, or check the id.`);
119
+ process.exitCode = 1;
120
+ return;
121
+ }
122
+ // Node 2a: Drift Detector — shared convergence point for both paths (§3.2)
123
+ const drift = await checkDrift(entry, process.cwd());
124
+ if (drift.drifted) {
125
+ console.error(`\n⚠ "${componentId}" has drifted from its last known state (§7.2).\n` +
126
+ `Someone edited the files outside Purix. Run "purix accept-drift ${componentId}" first, ` +
127
+ `then retry this modify.`);
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ const originalFiles = drift.liveFiles;
132
+ if (originalFiles.length === 0) {
133
+ console.error(`"${componentId}" has no readable files on disk — nothing to modify.`);
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+ // Idempotency key, checked before Node 1 spends anything. Same
138
+ // component + same raw instruction + same file state that already
139
+ // committed once is a no-op repeat, not a second edit.
140
+ const requestKey = computeRequestKey(componentId, instruction, drift.liveHash);
141
+ const priorCommit = findPriorCommit(requestKey);
142
+ if (priorCommit) {
143
+ console.log(`\n This exact request already committed as v${priorCommit.resultingVersion} — nothing to do (idempotency key match).`);
144
+ return;
145
+ }
146
+ recordEvent("request", { component_id: componentId, detail: { instruction } });
147
+ // §E-DOF: Deterministic Override Floor. Checked here, ahead of both
148
+ // Node 1's and Node 3b's LLM calls, purely deterministic — no
149
+ // confidence, no LLM, path matching only. Honest note on scope: this
150
+ // does NOT skip calling the classifier (compilePatch below still
151
+ // needs a real verdict to produce edits from — there's no way to
152
+ // patch a file without deciding what the patch is). What it DOES
153
+ // guarantee is that a hit here forces human_confirm at the TrustGate
154
+ // decision further down, unconditionally, regardless of whatever
155
+ // confidence the classifier reports. Logged now so you know before
156
+ // spending any LLM cost that this request will need your sign-off
157
+ // no matter what comes back.
158
+ const dofPatterns = loadDofPatterns(process.cwd());
159
+ const dof = checkDeterministicOverrideFloor(entry.files, dofPatterns);
160
+ if (dof.hit) {
161
+ console.log(`\n šŸ›‘ Deterministic Override Floor hit: "${dof.matchedPath}" matches protected pattern "${dof.matchedPattern}" (§E-DOF).\n` +
162
+ ` This will require your explicit confirmation regardless of classifier confidence.`);
163
+ }
164
+ // §9.3 pre-LLM pass on the raw developer instruction — same
165
+ // discipline the diff-ingest path already applies to diff content,
166
+ // now applied before the instruction ever reaches Node 1
167
+ // (refineIntent) or Node 3b (classifyModification), neither of
168
+ // which previously had any local scan on this string at all.
169
+ const instructionInjectionHits = scanForInjectionAttempts(instruction);
170
+ if (instructionInjectionHits.length > 0) {
171
+ console.log(`\n⚠ Instruction-like text found inside the developer instruction itself (§9.3):`);
172
+ for (const h of instructionInjectionHits)
173
+ console.log(` "${h}"`);
174
+ const proceedWithInstruction = await confirmGated(`This could be an attempt to redirect the classifier via the instruction text. Send it to the classifier anyway?`, "injection_risk_proceed", componentId);
175
+ if (!proceedWithInstruction) {
176
+ console.error(`\nšŸ›‘ Aborted after suspicious content was flagged in the instruction.`);
177
+ process.exitCode = 1;
178
+ return;
179
+ }
180
+ }
181
+ // §9.3 pass over the component's EXISTING file content. The
182
+ // instruction scan above only covers what the developer typed —
183
+ // originalFiles gets pasted into the refineIntent and
184
+ // classifyModification prompts too (filesBlock), and until now
185
+ // nothing scanned that content before it reached the model.
186
+ const fileInjectionFindings = scanFilesForInjectionAttempts(originalFiles);
187
+ if (fileInjectionFindings.length > 0) {
188
+ console.log(`\n⚠ Instruction-like text found inside "${componentId}"'s existing file content (§9.3):`);
189
+ for (const f of fileInjectionFindings) {
190
+ for (const h of f.hits)
191
+ console.log(` ${f.path}: "${h}"`);
192
+ }
193
+ const proceedWithFiles = await confirmGated(`This could be an attempt to redirect the classifier via content already in the component's files. Send it to the classifier anyway?`, "injection_risk_proceed", componentId);
194
+ if (!proceedWithFiles) {
195
+ console.error(`\nšŸ›‘ Aborted after suspicious content was flagged in existing file content.`);
196
+ process.exitCode = 1;
197
+ return;
198
+ }
199
+ }
200
+ // Node 1: Intake / Intent Refinement
201
+ console.log(`Refining intent...`);
202
+ const memory = readRelevantMemory(componentId);
203
+ const refined = await refineIntent(componentId, instruction, originalFiles, formatMemoryLines(memory));
204
+ console.log(`\nInterpreted as: "${refined.explicit_instruction}"`);
205
+ if (refined.assumptions.length > 0) {
206
+ console.log(`Assumptions made:`);
207
+ for (const a of refined.assumptions)
208
+ console.log(` - ${a}`);
209
+ }
210
+ const intentOk = await confirmGated(`\nProceed with this interpretation?`, "intent_refinement", componentId);
211
+ if (!intentOk) {
212
+ console.log("Cancelled — nothing touched.");
213
+ return;
214
+ }
215
+ // Node 3b: Change Classifier (intent-classify mode)
216
+ console.log(`\nClassifying...`);
217
+ const verdict = await classifyModification(componentId, refined.explicit_instruction, originalFiles);
218
+ console.log(` operation: ${verdict.operation}`);
219
+ console.log(` contract_changing: ${verdict.contract_changing}`);
220
+ console.log(` confidence: ${verdict.confidence.toFixed(2)}`);
221
+ console.log(` reasoning: ${verdict.reasoning}`);
222
+ if (verdict.suspicious_injected_instruction) {
223
+ console.log(` ⚠ Classifier self-reported suspicious instruction-like content.`);
224
+ }
225
+ recordEvent("classification", {
226
+ component_id: componentId,
227
+ operation: verdict.operation,
228
+ detail: {
229
+ confidence: verdict.confidence,
230
+ contract_changing: verdict.contract_changing,
231
+ suspicious_injected_instruction: verdict.suspicious_injected_instruction,
232
+ },
233
+ });
234
+ // Node 3a: Impact Analysis / Advisory Audit (never blocks)
235
+ const secFindings = auditSecurityPatterns(originalFiles);
236
+ if (secFindings.length > 0) {
237
+ console.log(`\n Advisory, not applied — ${secFindings.length} pattern finding(s):`);
238
+ for (const f of secFindings)
239
+ console.log(` ${f.path}:${f.line} [${f.category}] ${f.message}`);
240
+ }
241
+ let dependents = getDependents(entry);
242
+ if (verdict.contract_changing && dependents.length > 0) {
243
+ console.log(`\n This is contract-changing — ${dependents.length} dependent(s) will be re-verified after commit.`);
244
+ }
245
+ // Node 4: Patch Compiler
246
+ const compiled = compilePatch(verdict, originalFiles);
247
+ let finalFiles;
248
+ let fromEscalation = false;
249
+ if (!compiled.ok) {
250
+ // No deterministic transform for this operation at all — genuine
251
+ // capability gap, straight to §6.3 rather than pretending a local
252
+ // retry could ever succeed.
253
+ const compileReason = compiled.reason;
254
+ console.log(`\n No deterministic transform available: ${compileReason}`);
255
+ const esc = await runEscalation(componentId, verdict.operation, refined.explicit_instruction, originalFiles, compileReason, process.cwd());
256
+ if (!esc.ok) {
257
+ console.error(`\nšŸ›‘ Escalation failed: ${esc.reason}`);
258
+ process.exitCode = 1;
259
+ return;
260
+ }
261
+ finalFiles = esc.files.map((f) => ({ path: f.path, new_content: f.new_content }));
262
+ fromEscalation = true;
263
+ }
264
+ else {
265
+ const candidateFiles = originalFiles.map((f) => {
266
+ const changed = compiled.files.find((c) => c.path === f.path);
267
+ return { path: f.path, new_content: changed ? changed.new_content : f.content };
268
+ });
269
+ const verification = verifyInSandbox(componentId, candidateFiles, process.cwd());
270
+ if (verification.status === "pass") {
271
+ finalFiles = candidateFiles;
272
+ recordEvent("verification_pass", { component_id: componentId, operation: verdict.operation, detail: { stage: "direct_patch" } });
273
+ recordEvent("idiom_findings", { component_id: componentId, operation: verdict.operation, detail: { count: verification.idiomFindings.length } });
274
+ if (verification.idiomFindings.length > 0) {
275
+ console.log(`\n Idiom findings (soft, non-blocking):`);
276
+ for (const f of verification.idiomFindings)
277
+ console.log(` ${f.path}:${f.line} [${f.rule}] ${f.message}`);
278
+ }
279
+ }
280
+ else {
281
+ // Node 6: first failure -> capped self-healing (§6.1).
282
+ console.log(`\n āŒ Verification failed: ${verification.reason}`);
283
+ recordEvent("verification_failure", {
284
+ component_id: componentId,
285
+ operation: verdict.operation,
286
+ detail: { stage: "direct_patch", reason: verification.reason },
287
+ });
288
+ const healed = await runSelfHealingLoop(componentId, verdict.operation, originalFiles, compiled.files, verification.reason, process.cwd());
289
+ if (healed.ok) {
290
+ finalFiles = healed.files.map((f) => ({ path: f.path, new_content: f.new_content }));
291
+ }
292
+ else {
293
+ // Second consecutive failure (self-healing exhausted) -> §6.3.
294
+ const lastReason = healed.attempts[healed.attempts.length - 1]?.reason ?? "self-healing exhausted";
295
+ const esc = await runEscalation(componentId, verdict.operation, refined.explicit_instruction, originalFiles, lastReason, process.cwd());
296
+ if (!esc.ok) {
297
+ console.error(`\nšŸ›‘ Escalation failed: ${esc.reason}`);
298
+ process.exitCode = 1;
299
+ return;
300
+ }
301
+ finalFiles = esc.files.map((f) => ({ path: f.path, new_content: f.new_content }));
302
+ fromEscalation = true;
303
+ }
304
+ }
305
+ }
306
+ // Node 3c: Test-Integrity Check (§6.4) — deterministic, runs before TrustGate.
307
+ const changedFiles = finalFiles.filter((f) => {
308
+ const orig = originalFiles.find((o) => o.path === f.path);
309
+ return orig ? orig.content !== f.new_content : true;
310
+ });
311
+ const lang = resolveLanguage(componentId, process.cwd());
312
+ const provider = getLanguageProvider(lang);
313
+ const testFilesBefore = originalFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test."));
314
+ const testFilesAfter = finalFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test.")).map((f) => ({ path: f.path, content: f.new_content }));
315
+ const testIntegrity = provider?.testIntegrityChecker
316
+ ? provider.testIntegrityChecker.check(testFilesBefore, testFilesAfter)
317
+ : { flagged: true, findings: [{ path: "unknown", reason: `no test integrity checker registered for language ${lang} — failing closed` }] };
318
+ if (testIntegrity.flagged) {
319
+ recordEvent("test_integrity_flag", { component_id: componentId, operation: verdict.operation, detail: { findings: testIntegrity.findings } });
320
+ console.log(`\n ⚠ Test-integrity check (§6.4): ${testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ")}`);
321
+ }
322
+ const hasCoverage = hasTestCoverage(changedFiles, process.cwd());
323
+ if (!hasCoverage) {
324
+ recordEvent("coverage_gate_flag", { component_id: componentId, operation: verdict.operation, detail: { changed_files: changedFiles.map((f) => f.path) } });
325
+ }
326
+ recordEvent("gate_evaluation", { component_id: componentId, operation: verdict.operation, detail: { path: "instruction" } });
327
+ // TrustGate (§6.2): three independent gates feeding one decision.
328
+ let decision = evaluateTrustGate({
329
+ confidence: verdict.confidence,
330
+ contractChanging: verdict.contract_changing,
331
+ hasCoverage,
332
+ testIntegrity: {
333
+ flagged: testIntegrity.flagged,
334
+ reason: testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ") || undefined,
335
+ },
336
+ });
337
+ // An escalation-authored fix never auto-commits, regardless of the
338
+ // ORIGINAL classifier confidence — that confidence described the
339
+ // classifier's own edits, not the content escalation actually
340
+ // produced after those edits failed. §7.5's checkpoint policy
341
+ // treats this the same as any first-time externally-authored change.
342
+ if (fromEscalation && decision.action === "auto_commit") {
343
+ decision = {
344
+ action: "human_confirm",
345
+ reason: `escalation-authored fix always requires confirmation, regardless of the original classifier confidence`,
346
+ };
347
+ }
348
+ // §E-DOF enforcement point: a deterministic floor overrides an
349
+ // auto_commit outcome the same way the escalation override above
350
+ // does. It does NOT downgrade an "abort" — if the classifier itself
351
+ // rejected this, a protected path doesn't make that safer to proceed
352
+ // past.
353
+ if (dof.hit && decision.action === "auto_commit") {
354
+ decision = {
355
+ action: "human_confirm",
356
+ reason: `touches a Deterministic Override Floor path ("${dof.matchedPath}" matches "${dof.matchedPattern}") — always requires confirmation regardless of confidence (§E-DOF)`,
357
+ };
358
+ }
359
+ console.log(`\n TrustGate: ${decision.action} — ${decision.reason}`);
360
+ if (decision.action === "abort") {
361
+ if (options.override !== undefined) {
362
+ recordOverrideAudit("TrustGate", options.override, decision.reason);
363
+ console.log(` [override] TrustGate overridden: ${options.override.trim()}`);
364
+ decision = { action: "human_confirm", reason: `Overridden via --override: ${options.override}` };
365
+ }
366
+ else {
367
+ console.error(`\nšŸ›‘ ${decision.reason}`);
368
+ console.error(` Rephrase the instruction and re-run "purix modify" — no real files were touched.`);
369
+ process.exitCode = 1;
370
+ return;
371
+ }
372
+ }
373
+ if (decision.action === "human_confirm") {
374
+ try {
375
+ await assertAuthorizedToApprove();
376
+ }
377
+ catch (err) {
378
+ console.error(`\nšŸ›‘ ${err instanceof Error ? err.message : err}`);
379
+ process.exitCode = 1;
380
+ return;
381
+ }
382
+ const checkpointKind = fromEscalation
383
+ ? "escalation_fix"
384
+ : dof.hit
385
+ ? "deterministic_override_floor"
386
+ : verdict.contract_changing
387
+ ? "contract_changing"
388
+ : "trust_gate";
389
+ const proceed = await confirmGated(`\nApply this change to real files now? (${decision.reason})`, checkpointKind, componentId);
390
+ if (!proceed) {
391
+ console.log("Cancelled — no real files touched.");
392
+ return;
393
+ }
394
+ }
395
+ // Node 5: Executor + atomic manifest commit (§7.3/§7.4)
396
+ const beforeSnapshot = originalFiles.map((f) => ({ path: f.path, content: f.content }));
397
+ const newVersion = entry.current_version + 1;
398
+ const pendingId = createPendingOperation({
399
+ component_id: componentId,
400
+ before_snapshot: beforeSnapshot,
401
+ after_snapshot: finalFiles.map((f) => ({ path: f.path, content: f.new_content })),
402
+ new_version: newVersion,
403
+ operation: verdict.operation,
404
+ contract_changed: verdict.contract_changing,
405
+ provenance: { source_type: "instruction", source_agent: null },
406
+ });
407
+ let backups;
408
+ try {
409
+ backups = await applyModificationFiles(finalFiles, process.cwd());
410
+ }
411
+ catch (err) {
412
+ console.error(`\nšŸ›‘ File write failed, rolled back: ${err instanceof Error ? err.message : err}`);
413
+ process.exitCode = 1;
414
+ return;
415
+ }
416
+ entry.current_version = newVersion;
417
+ entry.verification_status = "pass";
418
+ entry.last_synced_hash = computeSyncHash(finalFiles.map((f) => ({ path: f.path, content: f.new_content })));
419
+ entry.version_history.push({
420
+ version: newVersion,
421
+ operation: verdict.operation,
422
+ patch_ref: `v${newVersion}-${verdict.operation}`,
423
+ contract_changed: verdict.contract_changing,
424
+ timestamp: new Date().toISOString(),
425
+ provenance: { source_type: "instruction", source_agent: null },
426
+ });
427
+ const committed = await commitManifestWithRetry(entry, newVersion - 1);
428
+ if (!committed) {
429
+ // Real conflict: another process committed a newer version between
430
+ // our State Resolver read and now. Roll back the files we just wrote
431
+ // and hand back to the human rather than clobbering the other write.
432
+ await rollbackModification(backups);
433
+ deletePendingOperation(pendingId);
434
+ console.error(`\nšŸ›‘ Manifest write conflict — "${componentId}" changed underneath this run. Rolled back file writes. Re-run "purix modify" against current state.`);
435
+ process.exitCode = 1;
436
+ return;
437
+ }
438
+ completeModification(entry, pendingId, newVersion);
439
+ recordRequestCommit(requestKey, componentId, newVersion);
440
+ console.log(`\nāœ… "${componentId}" now at v${newVersion}.`);
441
+ // §6.5: cascade re-verification, only on contract-changing.
442
+ if (verdict.contract_changing) {
443
+ const plan = buildMigrationPlan(componentId, verdict.operation, beforeSnapshot, finalFiles.map((f) => ({ path: f.path, content: f.new_content })));
444
+ const migrationId = stageMigration(componentId, verdict.operation, newVersion - 1, newVersion, beforeSnapshot, finalFiles.map((f) => ({ path: f.path, content: f.new_content })));
445
+ console.log(` Migration record staged: ${migrationId} (${plan.summary})`);
446
+ dependents = getDependents(entry);
447
+ if (dependents.length > 0) {
448
+ console.log(` Re-verifying ${dependents.length} dependent(s)...`);
449
+ const cascade = reVerifyDependents(dependents, process.cwd());
450
+ for (const c of cascade) {
451
+ console.log(c.status === "pass" ? ` āœ… ${c.component_id}` : ` āŒ ${c.component_id}: ${c.reason}`);
452
+ }
453
+ }
454
+ }
455
+ printRunSummary(savingsBefore);
456
+ }
457
+ finally {
458
+ setBudgetOverride(undefined);
459
+ setSecurityOverride(undefined);
460
+ }
461
+ });
462
+ // ---------------------------------------------------------------------------
463
+ // ingest (§3.1 Diff Ingestion Path — Node 0)
464
+ // ---------------------------------------------------------------------------
465
+ program
466
+ .command("ingest <componentId> <diffFile>")
467
+ .description("Ingest an external diff (PR patch, pre-commit hook output, agent-authored diff) against a tracked component")
468
+ .option("-a, --agent <name>", "the upstream agent that produced this diff (cursor, claude-code, devin, human, ...)")
469
+ .action(async (componentId, diffFile, opts) => {
470
+ const sourceAgent = opts.agent ?? null;
471
+ // Node 2: State Resolver — identical requirement as the Instruction Path.
472
+ const entry = readManifest(componentId);
473
+ if (!entry) {
474
+ console.error(`No manifest entry for "${componentId}". This diff needs a component to land against — run "purix create" first, or link it under the right one.`);
475
+ process.exitCode = 1;
476
+ return;
477
+ }
478
+ // Node 2a: Drift Detector — shared convergence point (§3.2).
479
+ const drift = await checkDrift(entry, process.cwd());
480
+ if (drift.drifted) {
481
+ console.error(`\n⚠ "${componentId}" has drifted from its last known state. Run "purix accept-drift ${componentId}" first, then retry this ingest.`);
482
+ process.exitCode = 1;
483
+ return;
484
+ }
485
+ const originalFiles = drift.liveFiles;
486
+ // Node 0: Diff Ingestion
487
+ const ingested = await ingestDiffFromFile(diffFile, sourceAgent, process.cwd());
488
+ if (!ingested.ok) {
489
+ const ingestReason = ingested.reason;
490
+ console.error(`\nšŸ›‘ Diff didn't apply: ${ingestReason}`);
491
+ process.exitCode = 1;
492
+ return;
493
+ }
494
+ const deletions = ingested.files.filter((f) => f.status === "deleted");
495
+ if (deletions.length > 0) {
496
+ console.error(`\nšŸ›‘ This diff deletes file(s) (${deletions.map((f) => f.path).join(", ")}) — file deletion isn't wired into ingest yet. Handle deletions manually for now.`);
497
+ process.exitCode = 1;
498
+ return;
499
+ }
500
+ const trackedPaths = new Set(entry.files);
501
+ const untrackedModified = ingested.files.filter((f) => f.status === "modified" && !trackedPaths.has(f.path));
502
+ if (untrackedModified.length > 0) {
503
+ console.error(`\nšŸ›‘ This diff modifies file(s) not tracked by "${componentId}": ${untrackedModified.map((f) => f.path).join(", ")}. ` +
504
+ `Refusing to guess which component owns them — link the right component first.`);
505
+ process.exitCode = 1;
506
+ return;
507
+ }
508
+ const newPaths = ingested.files.filter((f) => f.status === "added").map((f) => f.path);
509
+ // §9.3 pre-LLM pass, same discipline runEscalation applies to
510
+ // neighbor context — cheap, local, runs before the diff content
511
+ // ever reaches the classifier.
512
+ const injectionFindings = scanDiffForInjectionAttempts(ingested.files.map((f) => ({ path: f.path, old_content: null, new_content: f.new_content, status: f.status })));
513
+ if (injectionFindings.length > 0) {
514
+ console.log(`\n⚠ Instruction-like text found in the ingested diff content (§9.3):`);
515
+ for (const finding of injectionFindings) {
516
+ for (const h of finding.hits)
517
+ console.log(` ${finding.path}: "${h}"`);
518
+ }
519
+ const proceed = await confirmGated(`This could be an attempt to redirect the classifier via untrusted diff content. Send it to the classifier anyway?`, "injection_risk_proceed", componentId);
520
+ if (!proceed) {
521
+ console.error(`\nšŸ›‘ Aborted after suspicious content was flagged in the ingested diff.`);
522
+ process.exitCode = 1;
523
+ return;
524
+ }
525
+ }
526
+ console.log(`\nIngested diff from ${sourceAgent ?? "(unknown source)"}: ${ingested.files.length} file(s) touched.`);
527
+ for (const f of ingested.files)
528
+ console.log(` ${f.status}: ${f.path}`);
529
+ // Node 3b: Change Classifier, diff-classify mode (§3.2). Same
530
+ // structured verdict shape the Instruction Path's intent-classify
531
+ // produces, fed by the diff itself instead of a developer
532
+ // instruction — this is what finally lets TrustGate below run the
533
+ // real evaluateTrustGate instead of the always-confirm placeholder.
534
+ console.log(`\nClassifying diff...`);
535
+ const diffVerdict = await classifyDiff(componentId, sourceAgent, ingested.files.map((f) => ({
536
+ path: f.path,
537
+ old_content: f.status === "added" ? null : originalFiles.find((o) => o.path === f.path)?.content ?? null,
538
+ new_content: f.new_content,
539
+ status: f.status,
540
+ })));
541
+ console.log(` operation: ${diffVerdict.operation}`);
542
+ console.log(` contract_changing: ${diffVerdict.contract_changing}`);
543
+ console.log(` confidence: ${diffVerdict.confidence.toFixed(2)}`);
544
+ console.log(` reasoning: ${diffVerdict.reasoning}`);
545
+ if (diffVerdict.suspicious_injected_instruction) {
546
+ console.log(` ⚠ Classifier self-reported suspicious instruction-like content inside the diff.`);
547
+ }
548
+ recordEvent("classification", {
549
+ component_id: componentId,
550
+ operation: diffVerdict.operation,
551
+ detail: { confidence: diffVerdict.confidence, contract_changing: diffVerdict.contract_changing, path: "diff" },
552
+ });
553
+ const finalFiles = [
554
+ ...originalFiles.map((f) => {
555
+ const changed = ingested.files.find((c) => c.path === f.path);
556
+ return { path: f.path, new_content: changed ? changed.new_content : f.content };
557
+ }),
558
+ ...ingested.files.filter((f) => f.status === "added").map((f) => ({ path: f.path, new_content: f.new_content })),
559
+ ];
560
+ // §3.2 Node 6: identical Verifier every path converges on.
561
+ let verification = verifyInSandbox(componentId, finalFiles, process.cwd());
562
+ let workingFiles = finalFiles;
563
+ let fromEscalation = false;
564
+ if (verification.status !== "pass") {
565
+ console.log(`\n āŒ Verification failed: ${verification.reason}`);
566
+ recordEvent("verification_failure", { component_id: componentId, operation: "diff_ingest", detail: { stage: "direct_ingest", reason: verification.reason } });
567
+ const healed = await runSelfHealingLoop(componentId, "diff_ingest", originalFiles, workingFiles, verification.reason, process.cwd());
568
+ if (healed.ok) {
569
+ workingFiles = healed.files.map((f) => ({ path: f.path, new_content: f.new_content }));
570
+ }
571
+ else {
572
+ const lastReason = healed.attempts[healed.attempts.length - 1]?.reason ?? "self-healing exhausted";
573
+ const esc = await runEscalation(componentId, "diff_ingest", `apply ingested diff from ${sourceAgent ?? "unknown source"}`, originalFiles, lastReason, process.cwd());
574
+ if (!esc.ok) {
575
+ console.error(`\nšŸ›‘ Escalation failed: ${esc.reason}`);
576
+ process.exitCode = 1;
577
+ return;
578
+ }
579
+ workingFiles = esc.files.map((f) => ({ path: f.path, new_content: f.new_content }));
580
+ fromEscalation = true;
581
+ }
582
+ }
583
+ else {
584
+ recordEvent("verification_pass", { component_id: componentId, operation: "diff_ingest", detail: { stage: "direct_ingest" } });
585
+ if (verification.idiomFindings.length > 0) {
586
+ console.log(`\n Idiom findings (soft, non-blocking):`);
587
+ for (const f of verification.idiomFindings)
588
+ console.log(` ${f.path}:${f.line} [${f.rule}] ${f.message}`);
589
+ }
590
+ }
591
+ // Node 3c: Test-Integrity Check — identical gate, same as the Instruction Path.
592
+ const changedFiles = workingFiles.filter((f) => {
593
+ const orig = originalFiles.find((o) => o.path === f.path);
594
+ return orig ? orig.content !== f.new_content : true;
595
+ });
596
+ const lang = resolveLanguage(componentId, process.cwd());
597
+ const provider = getLanguageProvider(lang);
598
+ const testFilesBefore = originalFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test."));
599
+ const testFilesAfter = workingFiles.filter((f) => provider?.testIntegrityChecker?.isTestFile(f.path) ?? f.path.includes(".test.")).map((f) => ({ path: f.path, content: f.new_content }));
600
+ const testIntegrity = provider?.testIntegrityChecker
601
+ ? provider.testIntegrityChecker.check(testFilesBefore, testFilesAfter)
602
+ : { flagged: true, findings: [{ path: "unknown", reason: `no test integrity checker registered for language ${lang} — failing closed` }] };
603
+ if (testIntegrity.flagged) {
604
+ recordEvent("test_integrity_flag", { component_id: componentId, operation: "diff_ingest", detail: { findings: testIntegrity.findings } });
605
+ console.log(`\n ⚠ Test-integrity check (§6.4): ${testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ")}`);
606
+ }
607
+ const hasCoverage = hasTestCoverage(changedFiles, process.cwd());
608
+ if (!hasCoverage) {
609
+ recordEvent("coverage_gate_flag", { component_id: componentId, operation: "diff_ingest", detail: { changed_files: changedFiles.map((f) => f.path) } });
610
+ }
611
+ recordEvent("gate_evaluation", { component_id: componentId, operation: "diff_ingest", detail: { path: "diff" } });
612
+ // TrustGate (§6.2) — now fed a real classifier confidence via
613
+ // diff-classify (Node 3b's second mode) instead of the old
614
+ // always-confirm placeholder (evaluateTrustGateForDiff). Every path
615
+ // converges on the identical evaluateTrustGate the Instruction Path
616
+ // uses, per Principle 12 (author-agnostic verification: nothing
617
+ // downstream gets a special case for "this came from a diff").
618
+ let decision = evaluateTrustGate({
619
+ confidence: diffVerdict.confidence,
620
+ contractChanging: diffVerdict.contract_changing,
621
+ hasCoverage,
622
+ testIntegrity: {
623
+ flagged: testIntegrity.flagged,
624
+ reason: testIntegrity.findings.map((f) => `${f.path}: ${f.reason}`).join("; ") || undefined,
625
+ },
626
+ });
627
+ // Same rule the Instruction Path applies (§7.5): an escalation-
628
+ // authored fix never auto-commits on the strength of a confidence
629
+ // score that described the ORIGINAL diff, not what escalation
630
+ // actually produced after that diff failed verification.
631
+ if (fromEscalation && decision.action === "auto_commit") {
632
+ decision = {
633
+ action: "human_confirm",
634
+ reason: `escalation-authored fix always requires confirmation, regardless of the original diff-classify confidence`,
635
+ };
636
+ }
637
+ console.log(`\n TrustGate: ${decision.action} — ${decision.reason}`);
638
+ if (decision.action === "abort") {
639
+ console.error(`\nšŸ›‘ ${decision.reason}`);
640
+ console.error(` No real files were touched — the diff was not applied.`);
641
+ process.exitCode = 1;
642
+ return;
643
+ }
644
+ if (decision.action === "human_confirm") {
645
+ try {
646
+ await assertAuthorizedToApprove();
647
+ }
648
+ catch (err) {
649
+ console.error(`\nšŸ›‘ ${err instanceof Error ? err.message : err}`);
650
+ process.exitCode = 1;
651
+ return;
652
+ }
653
+ const checkpointKind = fromEscalation ? "escalation_fix" : "diff_ingest";
654
+ const proceed = await confirmGated(`\nApply this ingested diff to real files now? (${decision.reason})`, checkpointKind, componentId);
655
+ if (!proceed) {
656
+ console.log("Cancelled — no real files touched.");
657
+ return;
658
+ }
659
+ }
660
+ // Node 5: Executor + atomic manifest commit
661
+ const beforeSnapshot = originalFiles.map((f) => ({ path: f.path, content: f.content }));
662
+ const newVersion = entry.current_version + 1;
663
+ // Previously always hardcoded to true — the conservative worst-case
664
+ // default drift.ts's acceptDrift still uses for the same reason
665
+ // (genuinely unknown, so don't guess "safe"). diff-classify (Node
666
+ // 3b's second mode) now supplies a real verdict for ingested diffs,
667
+ // so this uses that instead of guessing.
668
+ const contractChanged = diffVerdict.contract_changing;
669
+ const pendingId = createPendingOperation({
670
+ component_id: componentId,
671
+ before_snapshot: beforeSnapshot,
672
+ after_snapshot: workingFiles.map((f) => ({ path: f.path, content: f.new_content })),
673
+ new_version: newVersion,
674
+ operation: "diff_ingest",
675
+ contract_changed: contractChanged,
676
+ provenance: { source_type: "external_diff", source_agent: sourceAgent },
677
+ });
678
+ let backups;
679
+ try {
680
+ backups = await applyModificationFiles(workingFiles, process.cwd());
681
+ }
682
+ catch (err) {
683
+ console.error(`\nšŸ›‘ File write failed, rolled back: ${err instanceof Error ? err.message : err}`);
684
+ process.exitCode = 1;
685
+ return;
686
+ }
687
+ if (newPaths.length > 0) {
688
+ entry.files = Array.from(new Set([...entry.files, ...newPaths]));
689
+ }
690
+ entry.current_version = newVersion;
691
+ entry.verification_status = "pass";
692
+ entry.last_synced_hash = computeSyncHash(workingFiles.map((f) => ({ path: f.path, content: f.new_content })));
693
+ entry.version_history.push({
694
+ version: newVersion,
695
+ operation: "diff_ingest",
696
+ patch_ref: `v${newVersion}-diff-ingest`,
697
+ contract_changed: contractChanged,
698
+ timestamp: new Date().toISOString(),
699
+ provenance: { source_type: "external_diff", source_agent: sourceAgent },
700
+ });
701
+ const committed = await commitManifestWithRetry(entry, newVersion - 1);
702
+ if (!committed) {
703
+ await rollbackModification(backups);
704
+ deletePendingOperation(pendingId);
705
+ console.error(`\nšŸ›‘ Manifest write conflict — "${componentId}" changed underneath this run. Rolled back file writes. Re-run "purix ingest" against current state.`);
706
+ process.exitCode = 1;
707
+ return;
708
+ }
709
+ completeModification(entry, pendingId, newVersion);
710
+ console.log(`\nāœ… "${componentId}" now at v${newVersion} (ingested from ${sourceAgent ?? "unknown source"}).`);
711
+ const dependents = getDependents(entry);
712
+ if (contractChanged && dependents.length > 0) {
713
+ console.log(` Re-verifying ${dependents.length} dependent(s) — diff-classify flagged this as contract-changing.`);
714
+ const cascade = reVerifyDependents(dependents, process.cwd());
715
+ for (const c of cascade) {
716
+ console.log(c.status === "pass" ? ` āœ… ${c.component_id}` : ` āŒ ${c.component_id}: ${c.reason}`);
717
+ }
718
+ }
719
+ });
720
+ // ---------------------------------------------------------------------------
721
+ // delete — previously deleteManifestEntry existed in manifest/store.ts
722
+ // but was never wired to a command. create/modify had a lifecycle end
723
+ // but no exit. Refuses on live dependents unless --force; --force also
724
+ // strips the deleted id out of each dependent's own depends_on list
725
+ // (removeDependencyReference), so nothing is left pointing at a
726
+ // component that no longer resolves via readManifest. Manifest-only by
727
+ // default — files on disk are untouched unless --files is passed.
728
+ // ---------------------------------------------------------------------------
729
+ program
730
+ .command("delete <componentId>")
731
+ .description("Permanently remove a component from the manifest (DESTRUCTIVE)")
732
+ .option("--force", "Delete even if other components still depend on it")
733
+ .option("--files", "Also delete the component's files from disk")
734
+ .action(async (componentId, opts) => {
735
+ const entry = readManifest(componentId);
736
+ if (!entry) {
737
+ console.error(`No manifest entry for "${componentId}". Run "purix library" to see what's tracked.`);
738
+ process.exitCode = 1;
739
+ return;
740
+ }
741
+ if (entry.depended_on_by.length > 0 && !opts.force) {
742
+ console.error(`\nšŸ›‘ "${componentId}" still has dependent(s): ${entry.depended_on_by.join(", ")}.\n` +
743
+ ` Re-run with --force to delete anyway (dependents will have this reference removed from their depends_on list), ` +
744
+ `or remove those dependencies first.`);
745
+ process.exitCode = 1;
746
+ return;
747
+ }
748
+ const proceed = await confirmGated(`This will permanently delete "${componentId}" from the manifest${opts.files ? " and remove its files from disk" : ""}. This cannot be undone. Continue?`, "component_delete", componentId);
749
+ if (!proceed) {
750
+ console.log("Cancelled — nothing deleted.");
751
+ return;
752
+ }
753
+ // Clean up both directions of the dependency graph before the row
754
+ // itself is gone and readManifest(componentId) stops working.
755
+ for (const depId of entry.depends_on) {
756
+ removeDependent(depId, componentId);
757
+ }
758
+ for (const dependentId of entry.depended_on_by) {
759
+ removeDependencyReference(dependentId, componentId);
760
+ }
761
+ if (opts.files) {
762
+ for (const relPath of entry.files) {
763
+ const fullPath = join(process.cwd(), relPath);
764
+ if (existsSync(fullPath)) {
765
+ try {
766
+ rmSync(fullPath);
767
+ }
768
+ catch (err) {
769
+ console.warn(` warning: couldn't remove ${relPath}: ${err instanceof Error ? err.message : err}`);
770
+ }
771
+ }
772
+ }
773
+ }
774
+ deleteManifestEntry(componentId);
775
+ recordEvent("component_deleted", {
776
+ component_id: componentId,
777
+ detail: { forced: !!opts.force, files_removed: !!opts.files, had_dependents: entry.depended_on_by.length > 0 },
778
+ });
779
+ console.log(`\nšŸ—‘ļø Deleted "${componentId}"${opts.files ? " (files removed)" : " (manifest entry only — files left on disk)"}.`);
780
+ });
781
+ }
782
+ //# sourceMappingURL=lifecycle.js.map