@alecsibilia/luca 13.0.0-alpha.1

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 (128) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +47 -0
  3. package/bin/luca.js +3 -0
  4. package/dist/chunks/branch.mjs +47 -0
  5. package/dist/chunks/bun-runtime.mjs +46 -0
  6. package/dist/chunks/checks.mjs +53 -0
  7. package/dist/chunks/claim-verify.mjs +465 -0
  8. package/dist/chunks/classify.mjs +105 -0
  9. package/dist/chunks/confidence.mjs +199 -0
  10. package/dist/chunks/doctor.mjs +158 -0
  11. package/dist/chunks/hook.mjs +696 -0
  12. package/dist/chunks/init.mjs +715 -0
  13. package/dist/chunks/muninndb-health.mjs +66 -0
  14. package/dist/chunks/phase.mjs +38 -0
  15. package/dist/chunks/pr-review.mjs +122 -0
  16. package/dist/chunks/preferences.mjs +61 -0
  17. package/dist/chunks/repair.mjs +111 -0
  18. package/dist/chunks/repo.mjs +58 -0
  19. package/dist/chunks/retro.mjs +86 -0
  20. package/dist/chunks/roadmap.mjs +58 -0
  21. package/dist/chunks/rules.mjs +527 -0
  22. package/dist/chunks/stale-mcp-server.mjs +90 -0
  23. package/dist/chunks/state.mjs +57 -0
  24. package/dist/chunks/stray-local-install.mjs +200 -0
  25. package/dist/chunks/telemetry.mjs +165 -0
  26. package/dist/chunks/todo.mjs +151 -0
  27. package/dist/chunks/vault-init.mjs +300 -0
  28. package/dist/chunks/verification.mjs +95 -0
  29. package/dist/chunks/version.mjs +70 -0
  30. package/dist/chunks/workflow.mjs +47 -0
  31. package/dist/claude/.claude/agents/architect.md +410 -0
  32. package/dist/claude/.claude/agents/build.md +111 -0
  33. package/dist/claude/.claude/agents/discuss.md +93 -0
  34. package/dist/claude/.claude/agents/discussion.md +149 -0
  35. package/dist/claude/.claude/agents/execute.md +416 -0
  36. package/dist/claude/.claude/agents/executor.md +161 -0
  37. package/dist/claude/.claude/agents/fast.md +84 -0
  38. package/dist/claude/.claude/agents/finalize.md +484 -0
  39. package/dist/claude/.claude/agents/learner.md +160 -0
  40. package/dist/claude/.claude/agents/plan-reviewer.md +129 -0
  41. package/dist/claude/.claude/agents/plan.md +96 -0
  42. package/dist/claude/.claude/agents/research.md +327 -0
  43. package/dist/claude/.claude/agents/researcher.md +78 -0
  44. package/dist/claude/.claude/agents/review.md +283 -0
  45. package/dist/claude/.claude/agents/reviewer.md +163 -0
  46. package/dist/claude/.claude/agents/shadow-scanner.md +257 -0
  47. package/dist/claude/.claude/agents/triage.md +230 -0
  48. package/dist/claude/.claude/agents/verifier.md +131 -0
  49. package/dist/claude/.claude/commands/bug-diagnose.md +12 -0
  50. package/dist/claude/.claude/commands/gh-issue-triage.md +14 -0
  51. package/dist/claude/.claude/commands/gh-pr-address.md +235 -0
  52. package/dist/claude/.claude/commands/gh-prepare.md +12 -0
  53. package/dist/claude/.claude/commands/grill-me.md +12 -0
  54. package/dist/claude/.claude/commands/lu-review.md +51 -0
  55. package/dist/claude/.claude/commands/lu.md +75 -0
  56. package/dist/claude/.claude/commands/luca-init.md +14 -0
  57. package/dist/claude/.claude/commands/luca-telemetry-report.md +12 -0
  58. package/dist/claude/.claude/commands/memory-audit.md +12 -0
  59. package/dist/claude/.claude/commands/milestone-new.md +122 -0
  60. package/dist/claude/.claude/commands/phase-discuss.md +45 -0
  61. package/dist/claude/.claude/commands/phase-execute.md +39 -0
  62. package/dist/claude/.claude/commands/phase-plan.md +53 -0
  63. package/dist/claude/.claude/commands/repo-cleanup.md +80 -0
  64. package/dist/claude/.claude/commands/todo-add.md +28 -0
  65. package/dist/claude/.claude/commands/todo-check.md +36 -0
  66. package/dist/claude/.claude/hooks/context-refresher.ts +285 -0
  67. package/dist/claude/.claude/hooks/continuation-messages.ts +215 -0
  68. package/dist/claude/.claude/hooks/pipeline-guard.ts +182 -0
  69. package/dist/claude/.claude/settings.json +41 -0
  70. package/dist/claude/skills/arch-audit/SKILL.md +161 -0
  71. package/dist/claude/skills/autopilot/SKILL.md +1299 -0
  72. package/dist/claude/skills/bug-diagnose/SKILL.md +102 -0
  73. package/dist/claude/skills/choose/SKILL.md +124 -0
  74. package/dist/claude/skills/gh-issue-triage/SKILL.md +97 -0
  75. package/dist/claude/skills/gh-pr-address/SKILL.md +235 -0
  76. package/dist/claude/skills/gh-prepare/SKILL.md +209 -0
  77. package/dist/claude/skills/grill-me/SKILL.md +46 -0
  78. package/dist/claude/skills/lu/SKILL.md +112 -0
  79. package/dist/claude/skills/lu-review/SKILL.md +51 -0
  80. package/dist/claude/skills/luca-init/SKILL.md +91 -0
  81. package/dist/claude/skills/luca-telemetry-report/SKILL.md +145 -0
  82. package/dist/claude/skills/luca-write-surface/SKILL.md +213 -0
  83. package/dist/claude/skills/memory-audit/SKILL.md +217 -0
  84. package/dist/claude/skills/milestone-audit/SKILL.md +545 -0
  85. package/dist/claude/skills/milestone-complete/SKILL.md +168 -0
  86. package/dist/claude/skills/milestone-gaps/SKILL.md +60 -0
  87. package/dist/claude/skills/milestone-new/SKILL.md +125 -0
  88. package/dist/claude/skills/note/SKILL.md +162 -0
  89. package/dist/claude/skills/phase-add/SKILL.md +91 -0
  90. package/dist/claude/skills/phase-assumptions/SKILL.md +92 -0
  91. package/dist/claude/skills/phase-discuss/SKILL.md +165 -0
  92. package/dist/claude/skills/phase-execute/SKILL.md +1786 -0
  93. package/dist/claude/skills/phase-insert/SKILL.md +100 -0
  94. package/dist/claude/skills/phase-plan/SKILL.md +461 -0
  95. package/dist/claude/skills/phase-remove/SKILL.md +113 -0
  96. package/dist/claude/skills/phase-research/SKILL.md +80 -0
  97. package/dist/claude/skills/post-init-tour/SKILL.md +58 -0
  98. package/dist/claude/skills/progress/SKILL.md +271 -0
  99. package/dist/claude/skills/project-new/SKILL.md +609 -0
  100. package/dist/claude/skills/quick/SKILL.md +256 -0
  101. package/dist/claude/skills/rename-audit/SKILL.md +52 -0
  102. package/dist/claude/skills/repo-audit/SKILL.md +88 -0
  103. package/dist/claude/skills/repo-cleanup/SKILL.md +80 -0
  104. package/dist/claude/skills/seed-memory/SKILL.md +235 -0
  105. package/dist/claude/skills/session-pause/SKILL.md +126 -0
  106. package/dist/claude/skills/session-plan/SKILL.md +112 -0
  107. package/dist/claude/skills/session-resume/SKILL.md +75 -0
  108. package/dist/claude/skills/todo-add/SKILL.md +85 -0
  109. package/dist/claude/skills/todo-check/SKILL.md +77 -0
  110. package/dist/claude/skills/workflow-save/SKILL.md +277 -0
  111. package/dist/index.d.mts +33 -0
  112. package/dist/index.d.ts +33 -0
  113. package/dist/index.mjs +69 -0
  114. package/dist/shared/luca.B3Mimc0P.mjs +52 -0
  115. package/dist/shared/luca.B3saVjJm.mjs +163 -0
  116. package/dist/shared/luca.BYdjkfnz.mjs +217 -0
  117. package/dist/shared/luca.BmhNkYe2.mjs +56 -0
  118. package/dist/shared/luca.C4gMUoBd.mjs +358 -0
  119. package/dist/shared/luca.CQ3g1xrD.mjs +19 -0
  120. package/dist/shared/luca.CRmaAfXR.mjs +713 -0
  121. package/dist/shared/luca.CrXzXueR.mjs +57 -0
  122. package/dist/shared/luca.DTomPq7I.mjs +91 -0
  123. package/dist/shared/luca.DjDTeDCi.mjs +1904 -0
  124. package/dist/shared/luca.HZxBTBgD.mjs +201 -0
  125. package/dist/shared/luca.TSMg1t7I.mjs +10 -0
  126. package/dist/shared/luca.dM-MKlNE.mjs +25 -0
  127. package/dist/shared/luca.naWEcQ4B.mjs +7 -0
  128. package/package.json +76 -0
@@ -0,0 +1,1904 @@
1
+ import { z } from 'zod';
2
+ import { P as ProjectPreferencesSchema, b as ConfidenceCategorySchema, d as ConfidenceLevelSchema, e as lucaRootPaths, S as ShadowScanFindingSchema, f as RoadmapPhaseSchema, g as PipelineStep, i as isLegalTransition, h as PIPELINE_TRANSITIONS, j as PIPELINE_STEP_TO_COARSE_PHASE, k as PipelineStepValues, m as TodoIdSchema, s as slugFromTitle, n as TodoSchema, t as todoConceptFor, o as TodoStatus, p as TODO_CONCEPT_PREFIX, V as VerificationRefSchema, l as lucaStateSchema } from './luca.CRmaAfXR.mjs';
3
+ import { l as loadCurrentState, r as resolveActiveSlug } from './luca.CrXzXueR.mjs';
4
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { mkdir, writeFile, rename, rm, readFile, appendFile, unlink } from 'node:fs/promises';
6
+ import { join, dirname, resolve, relative, sep } from 'node:path';
7
+ import 'node:crypto';
8
+ import 'node:module';
9
+ import 'node:url';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { c as appendConfidenceEntry, d as appendLedger } from './luca.HZxBTBgD.mjs';
12
+ import { p as phasePathFor } from './luca.TSMg1t7I.mjs';
13
+ import { l as loadCurrentConfig } from './luca.CQ3g1xrD.mjs';
14
+ import { S as STEP_ARTIFACTS, W as WRITE_COMMAND_PHASES } from './luca.B3Mimc0P.mjs';
15
+
16
+ const PREFERENCE_SECTIONS = [
17
+ "schemaVersion",
18
+ "branching",
19
+ "commits",
20
+ "pr",
21
+ "release",
22
+ "tracker"
23
+ ];
24
+ function formatIssues(error) {
25
+ return error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
26
+ }
27
+ function extractPreferences(config) {
28
+ const raw = config.preferences != null ? config.preferences : {};
29
+ const result = ProjectPreferencesSchema.safeParse(raw);
30
+ if (!result.success) {
31
+ return { ok: false, error: formatIssues(result.error) };
32
+ }
33
+ return { ok: true, preferences: result.data };
34
+ }
35
+ function mergePreferences(config, partial) {
36
+ const currentPrefs = config.preferences && typeof config.preferences === "object" ? config.preferences : {};
37
+ const mergedPrefs = { ...currentPrefs };
38
+ const mergedSections = [];
39
+ for (const section of PREFERENCE_SECTIONS) {
40
+ if (section in partial) {
41
+ mergedPrefs[section] = partial[section];
42
+ mergedSections.push(section);
43
+ }
44
+ }
45
+ const ignoredKeys = Object.keys(partial).filter(
46
+ (k) => !PREFERENCE_SECTIONS.includes(k)
47
+ );
48
+ const result = ProjectPreferencesSchema.safeParse(mergedPrefs);
49
+ if (!result.success) {
50
+ return { ok: false, error: formatIssues(result.error) };
51
+ }
52
+ return {
53
+ ok: true,
54
+ nextConfig: { ...config, preferences: result.data },
55
+ mergedSections,
56
+ ignoredKeys
57
+ };
58
+ }
59
+
60
+ const DEFAULT_LINE_TOLERANCE = 2;
61
+ const DEFAULT_PROMOTABLE = /* @__PURE__ */ new Set([
62
+ "nit",
63
+ "info",
64
+ "optional",
65
+ "should-fix",
66
+ "should",
67
+ "style",
68
+ "improvement",
69
+ "minor",
70
+ "low"
71
+ ]);
72
+ function median(nums) {
73
+ if (nums.length === 0) return 0;
74
+ const sorted = [...nums].sort((a, b) => a - b);
75
+ const mid = Math.floor(sorted.length / 2);
76
+ if (sorted.length % 2 === 0) {
77
+ const a = sorted[mid - 1] ?? 0;
78
+ const b = sorted[mid] ?? 0;
79
+ return Math.floor((a + b) / 2);
80
+ }
81
+ return sorted[mid] ?? 0;
82
+ }
83
+ function distinctPerspectives(findings) {
84
+ const set = /* @__PURE__ */ new Set();
85
+ for (const f of findings) set.add(f.perspective);
86
+ return [...set].sort();
87
+ }
88
+ function groupFindings(findings, lineTolerance) {
89
+ const byPath = /* @__PURE__ */ new Map();
90
+ const orphaned = [];
91
+ for (const f of findings) {
92
+ if (!f.path || f.line == null) {
93
+ orphaned.push(f);
94
+ continue;
95
+ }
96
+ const arr = byPath.get(f.path) ?? [];
97
+ arr.push(f);
98
+ byPath.set(f.path, arr);
99
+ }
100
+ const groups = [];
101
+ for (const [path, items] of byPath) {
102
+ items.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
103
+ const buckets = [];
104
+ for (const f of items) {
105
+ const line = f.line ?? 0;
106
+ const target = buckets.find((b) => {
107
+ const maxLine = Math.max(...b.map((x) => x.line ?? 0));
108
+ return Math.abs(maxLine - line) <= lineTolerance;
109
+ });
110
+ if (target) {
111
+ target.push(f);
112
+ } else {
113
+ buckets.push([f]);
114
+ }
115
+ }
116
+ for (const b of buckets) {
117
+ const lines = b.map((x) => x.line ?? 0);
118
+ groups.push({
119
+ key: `${path}:${Math.min(...lines)}-${Math.max(...lines)}`,
120
+ path,
121
+ anchorLine: median(lines),
122
+ minLine: Math.min(...lines),
123
+ maxLine: Math.max(...lines),
124
+ perspectives: distinctPerspectives(b),
125
+ findings: b
126
+ });
127
+ }
128
+ }
129
+ for (const f of orphaned) {
130
+ groups.push({
131
+ key: `orphan:${f.perspective}:${f.id}`,
132
+ path: f.path ?? "<no-path>",
133
+ anchorLine: f.line ?? 0,
134
+ minLine: f.line ?? 0,
135
+ maxLine: f.line ?? 0,
136
+ perspectives: [f.perspective],
137
+ findings: [f]
138
+ });
139
+ }
140
+ return groups;
141
+ }
142
+ function detectConvergence(findings, opts = {}) {
143
+ const tolerance = opts.lineTolerance ?? DEFAULT_LINE_TOLERANCE;
144
+ const promotable = opts.promotableSeverities ? new Set(opts.promotableSeverities.map((s) => s.toLowerCase())) : DEFAULT_PROMOTABLE;
145
+ const groups = groupFindings(findings, tolerance);
146
+ const convergentGroups = groups.filter((g) => g.perspectives.length >= 2);
147
+ const promotions = [];
148
+ const promoted = findings.map((f) => ({ ...f }));
149
+ const indexById = /* @__PURE__ */ new Map();
150
+ promoted.forEach((f, i) => indexById.set(f.id, i));
151
+ for (const g of convergentGroups) {
152
+ for (const f of g.findings) {
153
+ const sev = f.severity.toLowerCase();
154
+ const idx = indexById.get(f.id);
155
+ if (promotable.has(sev)) {
156
+ promotions.push({
157
+ findingId: f.id,
158
+ perspective: f.perspective,
159
+ fromSeverity: f.severity,
160
+ toSeverity: "must-fix",
161
+ reason: `Converged with ${g.perspectives.length} perspectives at ${g.path}:${g.minLine}-${g.maxLine}: ${g.perspectives.join(", ")}.`,
162
+ groupKey: g.key
163
+ });
164
+ if (idx !== void 0) {
165
+ promoted[idx] = { ...f, severity: "must-fix" };
166
+ }
167
+ } else if (sev === "must-fix" || sev === "must" || sev === "high" || sev === "critical") {
168
+ promotions.push({
169
+ findingId: f.id,
170
+ perspective: f.perspective,
171
+ fromSeverity: f.severity,
172
+ toSeverity: f.severity,
173
+ reason: `Already must-fix; converged with ${g.perspectives.length} perspectives at ${g.path}:${g.minLine}-${g.maxLine}: ${g.perspectives.join(", ")}.`,
174
+ groupKey: g.key
175
+ });
176
+ }
177
+ }
178
+ }
179
+ return {
180
+ groups,
181
+ convergentGroups,
182
+ promotions,
183
+ promotedFindings: promoted
184
+ };
185
+ }
186
+
187
+ const DEFAULT_SEVERITY_RANK = [
188
+ "praise",
189
+ "nit",
190
+ "info",
191
+ "optional",
192
+ "style",
193
+ "improvement",
194
+ "should-fix",
195
+ "should",
196
+ "must-fix",
197
+ "must",
198
+ "high",
199
+ "critical"
200
+ ];
201
+ function findingIdentity(f) {
202
+ const summaryPrefix = (f.summary ?? "").replace(/\s+/g, " ").trim().slice(0, 80);
203
+ const anchor = f.line == null ? "?" : String(f.line);
204
+ return [f.perspective, f.path ?? "<no-path>", anchor, summaryPrefix].join(
205
+ "::"
206
+ );
207
+ }
208
+ function severityIndex(severity, ranking) {
209
+ const idx = ranking.indexOf(severity.toLowerCase());
210
+ return idx === -1 ? 0 : idx;
211
+ }
212
+ function pathIsTouched(findingPath, touched) {
213
+ if (!findingPath) return false;
214
+ return touched.has(findingPath);
215
+ }
216
+ function checkRegression(inputs, opts = {}) {
217
+ const ranking = opts.severityRank ?? DEFAULT_SEVERITY_RANK;
218
+ const touched = new Set(inputs.touchedPaths);
219
+ const beforeIndex = /* @__PURE__ */ new Map();
220
+ for (const f of inputs.before) {
221
+ const key = findingIdentity(f);
222
+ const existing = beforeIndex.get(key);
223
+ if (!existing || severityIndex(f.severity, ranking) > severityIndex(existing.severity, ranking)) {
224
+ beforeIndex.set(key, f);
225
+ }
226
+ }
227
+ const afterIndex = /* @__PURE__ */ new Map();
228
+ for (const f of inputs.after) {
229
+ const key = findingIdentity(f);
230
+ const existing = afterIndex.get(key);
231
+ if (!existing || severityIndex(f.severity, ranking) > severityIndex(existing.severity, ranking)) {
232
+ afterIndex.set(key, f);
233
+ }
234
+ }
235
+ const regressions = [];
236
+ const unchanged = [];
237
+ const newButUntouched = [];
238
+ const resolved = [];
239
+ for (const [key, f] of beforeIndex) {
240
+ if (!afterIndex.has(key)) {
241
+ resolved.push(f);
242
+ }
243
+ }
244
+ for (const [key, f] of afterIndex) {
245
+ const prev = beforeIndex.get(key);
246
+ if (!prev) {
247
+ if (pathIsTouched(f.path, touched)) {
248
+ regressions.push({
249
+ finding: f,
250
+ reason: "new-on-touched-path",
251
+ evidence: `New finding from '${f.perspective}' at ${f.path ?? "<no-path>"}:${f.line ?? "?"} on a path modified by this iteration.`
252
+ });
253
+ } else {
254
+ newButUntouched.push(f);
255
+ }
256
+ continue;
257
+ }
258
+ const prevSev = severityIndex(prev.severity, ranking);
259
+ const curSev = severityIndex(f.severity, ranking);
260
+ if (curSev > prevSev) {
261
+ regressions.push({
262
+ finding: f,
263
+ reason: "severity-escalated",
264
+ evidence: `Severity escalated from '${prev.severity}' to '${f.severity}' on the same finding.`
265
+ });
266
+ } else {
267
+ unchanged.push(f);
268
+ }
269
+ }
270
+ return { regressions, resolved, unchanged, newButUntouched };
271
+ }
272
+ function diffPaths(repoRoot, fromSha, toSha) {
273
+ const r = spawnSync(
274
+ "git",
275
+ ["diff", "--name-only", `${fromSha}..${toSha}`],
276
+ {
277
+ cwd: repoRoot,
278
+ encoding: "utf8",
279
+ timeout: 5e3
280
+ }
281
+ );
282
+ if (r.status !== 0) return [];
283
+ return (r.stdout ?? "").split("\n").map((s) => s.trim()).filter((s) => s.length > 0);
284
+ }
285
+
286
+ function extractHunkAnchorLines(diffHunk) {
287
+ const lines = diffHunk.split("\n");
288
+ const anchors = [];
289
+ for (const line of lines) {
290
+ if (line.startsWith("@@")) continue;
291
+ if (line.startsWith("-")) continue;
292
+ if (line.startsWith(" ") || line.startsWith("+")) {
293
+ anchors.push(line.slice(1));
294
+ } else if (line.length === 0) {
295
+ anchors.push("");
296
+ }
297
+ }
298
+ while (anchors.length > 0 && anchors[anchors.length - 1] === "") {
299
+ anchors.pop();
300
+ }
301
+ return anchors;
302
+ }
303
+ function readFileLines(repoRoot, relPath) {
304
+ const full = join(repoRoot, relPath);
305
+ if (!existsSync(full)) return null;
306
+ try {
307
+ return readFileSync(full, "utf8").split("\n");
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+ function git(repoRoot, args, timeoutMs = 5e3) {
313
+ try {
314
+ const r = spawnSync("git", args, {
315
+ cwd: repoRoot,
316
+ encoding: "utf8",
317
+ timeout: timeoutMs
318
+ });
319
+ if (r.status !== 0) return { ok: false, stdout: "" };
320
+ return { ok: true, stdout: r.stdout ?? "" };
321
+ } catch {
322
+ return { ok: false, stdout: "" };
323
+ }
324
+ }
325
+ function isCommitReachable(repoRoot, sha) {
326
+ if (!sha) return false;
327
+ return git(repoRoot, ["cat-file", "-e", `${sha}^{commit}`]).ok;
328
+ }
329
+ function pathChangedBetween(repoRoot, fromSha, toSha, path) {
330
+ const r = git(repoRoot, [
331
+ "diff",
332
+ "--name-only",
333
+ `${fromSha}..${toSha}`,
334
+ "--",
335
+ path
336
+ ]);
337
+ if (!r.ok) return false;
338
+ return r.stdout.split("\n").some((l) => l.trim() === path);
339
+ }
340
+ function getHeadSha(repoRoot) {
341
+ const r = git(repoRoot, ["rev-parse", "HEAD"]);
342
+ if (!r.ok) return void 0;
343
+ return r.stdout.trim() || void 0;
344
+ }
345
+ function findAnchorInFile(fileLines, anchors, expectedLine) {
346
+ const meaningfulAnchors = anchors.filter((a) => a.trim().length > 0);
347
+ if (meaningfulAnchors.length === 0) return void 0;
348
+ const expected = expectedLine ?? 1;
349
+ const windowSize = 50;
350
+ const windowStart = Math.max(0, expected - 1 - windowSize);
351
+ const windowEnd = Math.min(fileLines.length, expected - 1 + windowSize);
352
+ function scoreAt(startIdx) {
353
+ let matched = 0;
354
+ let mismatches = 0;
355
+ let cursor = startIdx;
356
+ for (const anchor of meaningfulAnchors) {
357
+ let found = -1;
358
+ for (let probe = 0; probe < 4 && cursor + probe < fileLines.length; probe++) {
359
+ if (fileLines[cursor + probe] === anchor) {
360
+ found = probe;
361
+ break;
362
+ }
363
+ }
364
+ if (found === -1) {
365
+ mismatches++;
366
+ if (mismatches > 2) return void 0;
367
+ cursor++;
368
+ continue;
369
+ }
370
+ matched++;
371
+ cursor += found + 1;
372
+ }
373
+ const ratio = matched / meaningfulAnchors.length;
374
+ if (ratio < 0.6) return void 0;
375
+ return { line: startIdx + 1, matchedRatio: ratio };
376
+ }
377
+ let best;
378
+ for (let i = windowStart; i < windowEnd; i++) {
379
+ const c = scoreAt(i);
380
+ if (!c) continue;
381
+ if (!best || c.matchedRatio > best.matchedRatio || Math.abs(c.line - expected) < Math.abs(best.line - expected)) {
382
+ best = c;
383
+ }
384
+ }
385
+ if (best) return best;
386
+ for (let i = 0; i < fileLines.length; i++) {
387
+ if (i >= windowStart && i < windowEnd) continue;
388
+ const c = scoreAt(i);
389
+ if (!c) continue;
390
+ if (!best || c.matchedRatio > best.matchedRatio) {
391
+ best = c;
392
+ }
393
+ }
394
+ return best;
395
+ }
396
+ function verdictFor(comment, opts) {
397
+ const { repoRoot } = opts;
398
+ const maxDrift = opts.maxDriftLines ?? 5;
399
+ if (comment.diff_hunk === "") {
400
+ return {
401
+ commentId: comment.id,
402
+ stale: false,
403
+ reason: "empty-diff-hunk",
404
+ evidence: "empty diff_hunk \u2014 cannot classify"
405
+ };
406
+ }
407
+ const fileLines = readFileLines(repoRoot, comment.path);
408
+ if (!fileLines) {
409
+ return {
410
+ commentId: comment.id,
411
+ stale: true,
412
+ reason: "file-missing",
413
+ evidence: `Cited path '${comment.path}' no longer exists in the working tree.`
414
+ };
415
+ }
416
+ const anchors = extractHunkAnchorLines(comment.diff_hunk);
417
+ const expectedLine = comment.line ?? comment.original_line;
418
+ const match = findAnchorInFile(fileLines, anchors, expectedLine);
419
+ if (!match) {
420
+ return {
421
+ commentId: comment.id,
422
+ stale: true,
423
+ reason: "content-mismatch",
424
+ evidence: `Diff hunk anchors not found in current '${comment.path}'. The cited code has likely been rewritten or removed.`
425
+ };
426
+ }
427
+ if (expectedLine != null) {
428
+ const drift = Math.abs(match.line - expectedLine);
429
+ if (drift > maxDrift) {
430
+ return {
431
+ commentId: comment.id,
432
+ stale: true,
433
+ reason: "line-out-of-range",
434
+ evidence: `Cited line ${expectedLine} differs from current anchor location ${match.line} by ${drift} lines (> ${maxDrift}).`,
435
+ currentLine: match.line
436
+ };
437
+ }
438
+ }
439
+ const headSha = opts.headSha ?? getHeadSha(repoRoot);
440
+ if (headSha && comment.commit_id && comment.commit_id !== headSha && isCommitReachable(repoRoot, comment.commit_id) && pathChangedBetween(repoRoot, comment.commit_id, headSha, comment.path)) {
441
+ if (match.matchedRatio < 0.85) {
442
+ return {
443
+ commentId: comment.id,
444
+ stale: true,
445
+ reason: "commit-outdated-and-path-modified",
446
+ evidence: `Path '${comment.path}' was modified between commit ${comment.commit_id.slice(0, 7)} and HEAD ${headSha.slice(0, 7)} and only ${(match.matchedRatio * 100).toFixed(0)}% of anchors match. Likely stale.`,
447
+ currentLine: match.line
448
+ };
449
+ }
450
+ }
451
+ return {
452
+ commentId: comment.id,
453
+ stale: false,
454
+ evidence: `Anchored at line ${match.line} (${(match.matchedRatio * 100).toFixed(0)}% match).`,
455
+ currentLine: match.line
456
+ };
457
+ }
458
+ function filterStaleComments(comments, opts) {
459
+ const headSha = opts.headSha ?? getHeadSha(opts.repoRoot);
460
+ const verdictOpts = {
461
+ repoRoot: opts.repoRoot,
462
+ headSha,
463
+ maxDriftLines: opts.maxDriftLines
464
+ };
465
+ const actionable = [];
466
+ const stale = [];
467
+ const replies = [];
468
+ const unknown = [];
469
+ const verdicts = {};
470
+ for (const c of comments) {
471
+ if (c.in_reply_to_id != null) {
472
+ replies.push(c);
473
+ continue;
474
+ }
475
+ const v = verdictFor(c, verdictOpts);
476
+ verdicts[c.id] = v;
477
+ if (v.stale) {
478
+ stale.push({ comment: c, verdict: v });
479
+ } else if (v.reason === "empty-diff-hunk") {
480
+ unknown.push(c);
481
+ } else {
482
+ actionable.push(c);
483
+ }
484
+ }
485
+ return { actionable, stale, replies, unknown, verdicts };
486
+ }
487
+
488
+ async function resolveRepoVault(opts) {
489
+ const config = await loadCurrentConfig({ cwd: opts.cwd });
490
+ const muninn = config.muninn;
491
+ if (muninn && typeof muninn === "object" && !Array.isArray(muninn)) {
492
+ const v = muninn.vault;
493
+ if (typeof v === "string" && v.length > 0) return v;
494
+ }
495
+ const topLevelVault = config.vault;
496
+ if (typeof topLevelVault === "string" && topLevelVault.length > 0) {
497
+ return topLevelVault;
498
+ }
499
+ const envVault = process.env.LUCA_MUNINN_VAULT;
500
+ if (envVault && envVault.length > 0) return envVault;
501
+ return "default";
502
+ }
503
+
504
+ async function writeAtomicFile(absPath, content) {
505
+ await mkdir(dirname(absPath), { recursive: true });
506
+ const tmp = `${absPath}.tmp`;
507
+ try {
508
+ await writeFile(tmp, content);
509
+ await rename(tmp, absPath);
510
+ } catch (err) {
511
+ await rm(tmp, { force: true });
512
+ throw err;
513
+ }
514
+ }
515
+
516
+ function buildMuninnInstruction(input) {
517
+ const argsJson = JSON.stringify(input.args);
518
+ const instructionForAgent = `Call ${input.tool}. ${input.description} Parse the args via JSON.parse on the argsJson field; do NOT interpolate any free-form values directly into the call.`;
519
+ return {
520
+ tool: input.tool,
521
+ argsJson,
522
+ instructionForAgent
523
+ };
524
+ }
525
+
526
+ async function validateVerificationRef(opts) {
527
+ const state = await loadCurrentState({ cwd: opts.cwd });
528
+ const slug = resolveActiveSlug(state);
529
+ if (!slug.ok) {
530
+ return {
531
+ code: "NO_ACTIVE_PHASE",
532
+ message: slug.error
533
+ };
534
+ }
535
+ const verifyPath = join(opts.cwd, phasePathFor(slug.slug, "verify"));
536
+ if (!existsSync(verifyPath)) {
537
+ return {
538
+ code: "VERIFY_FILE_MISSING",
539
+ message: `verify.json not found for phase ${slug.slug}. Run luca-verifier first and persist via luca_phase_write_verify.`
540
+ };
541
+ }
542
+ let parsed;
543
+ try {
544
+ parsed = JSON.parse(await readFile(verifyPath, "utf-8"));
545
+ } catch (err) {
546
+ return {
547
+ code: "VERIFY_FILE_INVALID",
548
+ message: `verify.json could not be parsed: ${err.message}`
549
+ };
550
+ }
551
+ const result = parsed;
552
+ const criteria = Array.isArray(result.criteria) ? result.criteria : [];
553
+ const found = criteria.find((c) => c.criterionId === opts.ref.criterionId);
554
+ if (!found) {
555
+ return {
556
+ code: "CRITERION_NOT_FOUND",
557
+ message: `criterion "${opts.ref.criterionId}" not present in ${slug.slug}/verify.json. Existing ids: ${criteria.map((c) => c.criterionId).filter(Boolean).join(", ") || "(none)"}.`
558
+ };
559
+ }
560
+ if (!found.met) {
561
+ return {
562
+ code: "CRITERION_UNMET",
563
+ message: `criterion "${opts.ref.criterionId}" is recorded as met=false. Cannot mark a todo done against an unmet criterion.`
564
+ };
565
+ }
566
+ if (!found.evidence || !found.evidence.trim()) {
567
+ return {
568
+ code: "CRITERION_NO_EVIDENCE",
569
+ message: `criterion "${opts.ref.criterionId}" has empty evidence. Re-run verification with concrete evidence (file:line or test name) before marking the todo done.`
570
+ };
571
+ }
572
+ if (result.status !== "PASS") {
573
+ return {
574
+ code: "VERIFY_NOT_PASS",
575
+ message: `verify.json status is "${result.status}", not "PASS". Cannot mark a todo done against a failing/stalled verification run.`
576
+ };
577
+ }
578
+ return null;
579
+ }
580
+
581
+ const inputSchema$h = z.object({
582
+ default_branch: z.string().min(1).default("main").describe(
583
+ "Branch name that must NOT equal the current branch (typically the repository default branch)."
584
+ )
585
+ });
586
+ async function readCurrentBranch(cwd) {
587
+ const proc = Bun.spawn(["git", "rev-parse", "--abbrev-ref", "HEAD"], {
588
+ cwd,
589
+ stdout: "pipe",
590
+ stderr: "pipe"
591
+ });
592
+ const code = await proc.exited;
593
+ if (code !== 0) return null;
594
+ const out = await new Response(proc.stdout).text();
595
+ return out.trim() || null;
596
+ }
597
+ const lucaBranchGuardTool = {
598
+ name: "luca_branch_guard",
599
+ description: "Assert that the current git branch is NOT the repository default branch. Returns isError when on the default branch, otherwise ok=true. Use before committing to prevent accidental main writes.",
600
+ inputSchema: inputSchema$h,
601
+ async handler(args, ctx) {
602
+ const current = await readCurrentBranch(ctx.cwd);
603
+ if (current === null) {
604
+ return {
605
+ content: [
606
+ {
607
+ type: "text",
608
+ text: `luca_branch_guard: could not read git branch in ${ctx.cwd} (not a git repo?)`
609
+ }
610
+ ],
611
+ isError: true
612
+ };
613
+ }
614
+ const result = {
615
+ ok: current !== args.default_branch,
616
+ current,
617
+ default: args.default_branch,
618
+ message: current === args.default_branch ? `on default branch "${args.default_branch}" \u2014 refusing to proceed; switch to a feature branch first.` : `current branch "${current}" differs from default "${args.default_branch}".`
619
+ };
620
+ return {
621
+ content: [
622
+ { type: "text", text: JSON.stringify(result, null, 2) }
623
+ ],
624
+ isError: !result.ok
625
+ };
626
+ }
627
+ };
628
+
629
+ const commandSchema = z.object({
630
+ argv: z.array(z.string().min(1)).min(1).describe(
631
+ "Argv array passed directly to the spawn. First element is the executable, remaining elements are arguments. No shell interpolation."
632
+ ),
633
+ label: z.string().min(1).optional().describe(
634
+ "Optional human-friendly label for this command in the summary output. Defaults to the argv joined by spaces."
635
+ )
636
+ });
637
+ const inputSchema$g = z.object({
638
+ commands: z.array(commandSchema).min(1).describe(
639
+ "Ordered list of commands to run sequentially. Each command runs only if the previous one is still within budget; failures do NOT stop the sequence."
640
+ ),
641
+ timeout_ms: z.number().int().min(100).max(6e5).default(9e4).describe(
642
+ "Per-command timeout in milliseconds (range 100\u2013600000, default 90000). On timeout the process is killed (SIGTERM then SIGKILL) and reported as timedOut=true."
643
+ )
644
+ });
645
+ const MAX_OUTPUT_BYTES = 16 * 1024;
646
+ function truncate(buf) {
647
+ if (buf.length <= MAX_OUTPUT_BYTES) return buf;
648
+ const head = buf.slice(0, MAX_OUTPUT_BYTES / 2);
649
+ const tail = buf.slice(-MAX_OUTPUT_BYTES / 2);
650
+ return `${head}
651
+ \u2026[truncated ${buf.length - MAX_OUTPUT_BYTES} bytes]\u2026
652
+ ${tail}`;
653
+ }
654
+ async function runOne(argv, label, cwd, timeoutMs) {
655
+ const [cmd, ...rest] = argv;
656
+ if (!cmd) {
657
+ return {
658
+ label,
659
+ argv,
660
+ ok: false,
661
+ exitCode: null,
662
+ timedOut: false,
663
+ stdout: "",
664
+ stderr: "empty argv"
665
+ };
666
+ }
667
+ const proc = Bun.spawn([cmd, ...rest], {
668
+ cwd,
669
+ stdout: "pipe",
670
+ stderr: "pipe",
671
+ stdin: "ignore"
672
+ });
673
+ let timedOut = false;
674
+ const timer = setTimeout(() => {
675
+ timedOut = true;
676
+ try {
677
+ proc.kill("SIGTERM");
678
+ } catch {
679
+ }
680
+ setTimeout(() => {
681
+ if (proc.exitCode === null) {
682
+ try {
683
+ proc.kill("SIGKILL");
684
+ } catch {
685
+ }
686
+ }
687
+ }, 250);
688
+ }, timeoutMs);
689
+ let exitCode = null;
690
+ let stdoutText = "";
691
+ let stderrText = "";
692
+ try {
693
+ const [code, stdout, stderr] = await Promise.all([
694
+ proc.exited,
695
+ new Response(proc.stdout).text(),
696
+ new Response(proc.stderr).text()
697
+ ]);
698
+ exitCode = code;
699
+ stdoutText = truncate(stdout);
700
+ stderrText = truncate(stderr);
701
+ } finally {
702
+ clearTimeout(timer);
703
+ }
704
+ return {
705
+ label,
706
+ argv,
707
+ ok: !timedOut && exitCode === 0,
708
+ exitCode,
709
+ timedOut,
710
+ stdout: stdoutText,
711
+ stderr: stderrText
712
+ };
713
+ }
714
+ const lucaChecksRunTool = {
715
+ name: "luca_checks_run",
716
+ description: "Run verification commands (typecheck/tests/lint) sequentially with per-command timeout and SIGTERM/SIGKILL cleanup. Returns structured summary per command. Only callable in execute/checks phases.",
717
+ inputSchema: inputSchema$g,
718
+ allowedPhases: ["execute", "checks"],
719
+ async handler(args, ctx) {
720
+ const results = [];
721
+ for (const cmd of args.commands) {
722
+ const label = cmd.label ?? cmd.argv.join(" ");
723
+ const r = await runOne(cmd.argv, label, ctx.cwd, args.timeout_ms);
724
+ results.push(r);
725
+ }
726
+ const passed = results.every((r) => r.ok);
727
+ const payload = { passed, summary: results };
728
+ return {
729
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
730
+ isError: !passed
731
+ };
732
+ }
733
+ };
734
+
735
+ const inputSchema$f = z.object({
736
+ phase: z.string().min(1).describe("Phase name from the plan / roadmap."),
737
+ wave: z.number().describe("Wave number within the phase."),
738
+ task: z.string().min(1).describe("Task ID or description from the plan."),
739
+ confidence: ConfidenceLevelSchema.describe(
740
+ "How confident the executor was in its decision (high|medium|low)."
741
+ ),
742
+ category: ConfidenceCategorySchema.describe(
743
+ "What kind of ambiguity was encountered."
744
+ ),
745
+ decision: z.string().min(1).describe("What the executor actually decided to do."),
746
+ alternatives: z.array(z.string()).describe("Other options that were considered."),
747
+ reasoning: z.string().min(1).describe("Why this choice was made over the alternatives."),
748
+ risk: z.string().min(1).describe("What could go wrong if this was the wrong call."),
749
+ files: z.array(z.string()).describe("Which files were affected by this decision."),
750
+ reviewHint: z.string().optional().describe("Suggested focus area for a human reviewer.")
751
+ });
752
+ const lucaConfidenceLogTool = {
753
+ name: "luca_confidence_log",
754
+ description: "Append a confidence entry to the active phase's confidence.jsonl. One JSONL line per call. Payload matches the canonical ConfidenceEntrySchema (phase, wave, task, confidence, category, decision, alternatives, reasoning, risk, files, reviewHint?).",
755
+ inputSchema: inputSchema$f,
756
+ async handler(args, ctx) {
757
+ const state = await loadCurrentState({ cwd: ctx.cwd });
758
+ const slug = resolveActiveSlug(state);
759
+ if (!slug.ok) {
760
+ return {
761
+ content: [{ type: "text", text: slug.error }],
762
+ isError: true
763
+ };
764
+ }
765
+ const entry = appendConfidenceEntry({
766
+ cwd: ctx.cwd,
767
+ slug: slug.slug,
768
+ entry: {
769
+ phase: args.phase,
770
+ wave: args.wave,
771
+ task: args.task,
772
+ confidence: args.confidence,
773
+ category: args.category,
774
+ decision: args.decision,
775
+ alternatives: args.alternatives,
776
+ reasoning: args.reasoning,
777
+ risk: args.risk,
778
+ files: args.files,
779
+ ...args.reviewHint !== void 0 ? { reviewHint: args.reviewHint } : {}
780
+ }
781
+ });
782
+ return {
783
+ content: [
784
+ {
785
+ type: "text",
786
+ text: `appended confidence entry (${entry.confidence}, ${entry.category}) to phase '${slug.slug}' (task: ${entry.task})`
787
+ }
788
+ ]
789
+ };
790
+ }
791
+ };
792
+
793
+ const inputSchema$e = z.object({});
794
+ const lucaPhaseCurrentTool = {
795
+ name: "luca_phase_current",
796
+ description: "Return information about the currently active phase: { active, NN, slug, dir }. When no phase is active (currentPhase=0), returns { active: false }.",
797
+ inputSchema: inputSchema$e,
798
+ async handler(_args, ctx) {
799
+ const state = await loadCurrentState({ cwd: ctx.cwd });
800
+ if (state.currentPhase === 0) {
801
+ return {
802
+ content: [
803
+ {
804
+ type: "text",
805
+ text: JSON.stringify({ active: false }, null, 2)
806
+ }
807
+ ]
808
+ };
809
+ }
810
+ const resolved = resolveActiveSlug(state);
811
+ if (!resolved.ok) {
812
+ return {
813
+ content: [{ type: "text", text: resolved.error }],
814
+ isError: true
815
+ };
816
+ }
817
+ const dir = phasePathFor(resolved.slug);
818
+ return {
819
+ content: [
820
+ {
821
+ type: "text",
822
+ text: JSON.stringify(
823
+ {
824
+ active: true,
825
+ NN: resolved.NN,
826
+ slug: resolved.slug,
827
+ dir
828
+ },
829
+ null,
830
+ 2
831
+ )
832
+ }
833
+ ]
834
+ };
835
+ }
836
+ };
837
+
838
+ z.object({
839
+ reviewer: z.string().regex(/^[a-z][a-z0-9-]*[a-z0-9]?$/, {
840
+ message: 'reviewer must be kebab-case (e.g. "code-review", "security")'
841
+ }).describe(
842
+ 'Reviewer name (kebab-case). Examples: "code-review", "security", "architect", "ux".'
843
+ ),
844
+ content: z.string().min(1).describe(
845
+ "Markdown audit content. Written to .luca/phases/<active-slug>/audits/<reviewer>.md."
846
+ )
847
+ });
848
+
849
+ z.object({
850
+ content: z.string().min(1).describe(
851
+ "Markdown content capturing user decisions from /phase-discuss. Written verbatim to .luca/phases/<active-slug>/context.md."
852
+ )
853
+ });
854
+
855
+ z.object({
856
+ content: z.string().min(1).describe(
857
+ "Markdown content capturing learnings from this phase. Written to .luca/phases/<active-slug>/learn.md."
858
+ )
859
+ });
860
+
861
+ z.object({
862
+ content: z.string().min(1).describe(
863
+ "Markdown plan-review output (APPROVED | NEEDS_REVISION | ESCALATE + findings). Written to .luca/phases/<active-slug>/plan-review.md."
864
+ )
865
+ });
866
+
867
+ z.object({
868
+ content: z.string().min(1).describe(
869
+ "Markdown content of the phase plan. Will be written verbatim to .luca/phases/<active-slug>/plan.md."
870
+ )
871
+ });
872
+
873
+ z.object({
874
+ content: z.string().min(1).describe(
875
+ "Markdown content of phase research notes. Written verbatim to .luca/phases/<active-slug>/research.md."
876
+ )
877
+ });
878
+
879
+ z.object({
880
+ content: z.string().min(1).describe(
881
+ "Markdown summary of the executed phase work. Written to .luca/phases/<active-slug>/execute/summary.md."
882
+ )
883
+ });
884
+
885
+ z.object({
886
+ result: z.record(z.string(), z.unknown()).describe(
887
+ "Structured verification result. Common fields: status (pass|fail), typecheck (bool), tests ({passed, failed}), lint (...). Written verbatim to .luca/phases/<active-slug>/verify.json."
888
+ )
889
+ });
890
+
891
+ z.object({
892
+ waveNumber: z.number().int().min(0).max(99).describe(
893
+ "Wave number (0\u201399). Zero-padded to two digits in the filename (e.g. wave 3 \u2192 03.md)."
894
+ ),
895
+ content: z.string().min(1).describe(
896
+ "Markdown content for this wave. Written to .luca/phases/<active-slug>/execute/waves/NN.md."
897
+ )
898
+ });
899
+
900
+ const findingSchema$1 = z.object({
901
+ id: z.string(),
902
+ perspective: z.string(),
903
+ path: z.string().optional(),
904
+ line: z.number().optional(),
905
+ severity: z.string(),
906
+ category: z.string().optional(),
907
+ summary: z.string()
908
+ });
909
+ const inputSchema$d = z.object({
910
+ findings: z.array(findingSchema$1).describe(
911
+ "Combined review findings across every perspective (PR comments, claim-verifier, reviewer subagents, CI annotations)."
912
+ ),
913
+ line_tolerance: z.number().int().min(0).optional().describe(
914
+ "Lines within +/- this distance count as the same location (default 2)."
915
+ )
916
+ });
917
+ const lucaPrReviewDetectConvergenceTool = {
918
+ name: "luca_pr_review_detect_convergence",
919
+ description: "Group review findings by location and auto-promote severity to must-fix when 2+ distinct perspectives flag the same line. Returns the convergence report plus promoted findings. Read-only.",
920
+ inputSchema: inputSchema$d,
921
+ async handler(args, _ctx) {
922
+ const report = detectConvergence(args.findings, {
923
+ lineTolerance: args.line_tolerance
924
+ });
925
+ const summary = {
926
+ counts: {
927
+ input: args.findings.length,
928
+ groups: report.groups.length,
929
+ convergentGroups: report.convergentGroups.length,
930
+ promotions: report.promotions.length
931
+ },
932
+ report
933
+ };
934
+ return {
935
+ content: [{ type: "text", text: JSON.stringify(summary, null, 2) }]
936
+ };
937
+ }
938
+ };
939
+
940
+ const commentSchema = z.object({
941
+ id: z.number(),
942
+ path: z.string(),
943
+ line: z.number().nullable(),
944
+ original_line: z.number().nullable(),
945
+ commit_id: z.string(),
946
+ original_commit_id: z.string(),
947
+ diff_hunk: z.string(),
948
+ body: z.string(),
949
+ in_reply_to_id: z.number().nullable().optional(),
950
+ user: z.object({
951
+ login: z.string().optional(),
952
+ type: z.string().optional()
953
+ }).optional()
954
+ });
955
+ const inputSchema$c = z.object({
956
+ comments: z.array(commentSchema).describe(
957
+ "PR review comments (gh api pulls/<n>/comments shape). Each is classified against the current working tree."
958
+ ),
959
+ head_sha: z.string().optional().describe(
960
+ "Override HEAD SHA used for stale detection. Defaults to current git HEAD."
961
+ ),
962
+ max_drift_lines: z.number().int().min(0).optional().describe(
963
+ "Max line drift before a relocated anchor is treated as stale (default 5)."
964
+ )
965
+ });
966
+ const lucaPrReviewFilterStaleTool = {
967
+ name: "luca_pr_review_filter_stale",
968
+ description: "Partition PR review comments into actionable / stale / replies / unknown buckets by re-anchoring each comment against the current working tree. Drops comments whose cited code was rewritten. Read-only.",
969
+ inputSchema: inputSchema$c,
970
+ async handler(args, ctx) {
971
+ const result = filterStaleComments(args.comments, {
972
+ repoRoot: ctx.cwd,
973
+ headSha: args.head_sha,
974
+ maxDriftLines: args.max_drift_lines
975
+ });
976
+ const summary = {
977
+ counts: {
978
+ input: args.comments.length,
979
+ actionable: result.actionable.length,
980
+ stale: result.stale.length,
981
+ replies: result.replies.length,
982
+ unknown: result.unknown.length
983
+ },
984
+ actionable: result.actionable,
985
+ stale: result.stale,
986
+ replies: result.replies,
987
+ unknown: result.unknown,
988
+ verdicts: result.verdicts
989
+ };
990
+ return {
991
+ content: [{ type: "text", text: JSON.stringify(summary, null, 2) }]
992
+ };
993
+ }
994
+ };
995
+
996
+ const findingSchema = z.object({
997
+ id: z.string(),
998
+ perspective: z.string(),
999
+ path: z.string().optional(),
1000
+ line: z.number().optional(),
1001
+ severity: z.string(),
1002
+ category: z.string().optional(),
1003
+ summary: z.string()
1004
+ });
1005
+ const inputSchema$b = z.object({
1006
+ before: z.array(findingSchema).describe("Findings snapshot taken BEFORE the fix iteration."),
1007
+ after: z.array(findingSchema).describe("Findings snapshot taken AFTER the fix iteration."),
1008
+ touched_paths: z.array(z.string()).default([]).describe(
1009
+ "Repo-relative paths modified by fix commits in this iteration. If empty and from_sha/to_sha are given, computed via git diff."
1010
+ ),
1011
+ from_sha: z.string().optional().describe("Iteration-start SHA (used to compute touched paths)."),
1012
+ to_sha: z.string().optional().describe("Iteration-end SHA (used to compute touched paths).")
1013
+ });
1014
+ const lucaPrReviewRegressionCheckTool = {
1015
+ name: "luca_pr_review_regression_check",
1016
+ description: "Diff before/after review-finding snapshots to surface regressions introduced by a fix iteration (new findings on touched paths, severity escalations). Returns isError when regressions are found. Read-only.",
1017
+ inputSchema: inputSchema$b,
1018
+ async handler(args, ctx) {
1019
+ let touchedPaths = args.touched_paths;
1020
+ if (touchedPaths.length === 0 && args.from_sha && args.to_sha) {
1021
+ touchedPaths = diffPaths(ctx.cwd, args.from_sha, args.to_sha);
1022
+ }
1023
+ const report = checkRegression({
1024
+ before: args.before,
1025
+ after: args.after,
1026
+ touchedPaths
1027
+ });
1028
+ const summary = {
1029
+ counts: {
1030
+ before: args.before.length,
1031
+ after: args.after.length,
1032
+ touchedPaths: touchedPaths.length,
1033
+ regressions: report.regressions.length,
1034
+ resolved: report.resolved.length,
1035
+ unchanged: report.unchanged.length,
1036
+ newButUntouched: report.newButUntouched.length
1037
+ },
1038
+ touchedPaths,
1039
+ report
1040
+ };
1041
+ const hasRegressions = report.regressions.length > 0;
1042
+ return {
1043
+ content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
1044
+ isError: hasRegressions
1045
+ };
1046
+ }
1047
+ };
1048
+
1049
+ const inputSchema$a = z.object({});
1050
+ const lucaPreferencesReadTool = {
1051
+ name: "luca_preferences_read",
1052
+ description: "Read the project preferences object from .luca/config.json#preferences. Returns ProjectPreferencesSchema-validated JSON, with defaults applied to unset sections. Pure read \u2014 allowed in every pipelineStep.",
1053
+ inputSchema: inputSchema$a,
1054
+ async handler(_args, ctx) {
1055
+ const config = await loadCurrentConfig({ cwd: ctx.cwd });
1056
+ const result = extractPreferences(config);
1057
+ if (!result.ok) {
1058
+ return {
1059
+ content: [
1060
+ {
1061
+ type: "text",
1062
+ text: `luca_preferences_read: .luca/config.json#preferences failed validation: ${result.error}`
1063
+ }
1064
+ ],
1065
+ isError: true
1066
+ };
1067
+ }
1068
+ return {
1069
+ content: [
1070
+ {
1071
+ type: "text",
1072
+ text: JSON.stringify(result.preferences, null, 2)
1073
+ }
1074
+ ]
1075
+ };
1076
+ }
1077
+ };
1078
+
1079
+ const inputSchema$9 = z.object({
1080
+ preferences: z.record(z.string(), z.unknown()).describe(
1081
+ "Partial preferences object. Top-level sections (branching, commits, pr, release, tracker, schemaVersion) overlay the existing ones; unspecified sections are left unchanged. The merged result is re-validated against ProjectPreferencesSchema before write."
1082
+ )
1083
+ });
1084
+ const lucaPreferencesWriteTool = {
1085
+ name: "luca_preferences_write",
1086
+ description: "Write/update the preferences section of .luca/config.json. Section-level shallow merge; preserves other config keys. Validated through ProjectPreferencesSchema (rejects unsafe free-form input and ReDoS-shaped regex).",
1087
+ inputSchema: inputSchema$9,
1088
+ async handler(args, ctx) {
1089
+ const config = await loadCurrentConfig({ cwd: ctx.cwd });
1090
+ const result = mergePreferences(config, args.preferences);
1091
+ if (!result.ok) {
1092
+ return {
1093
+ content: [
1094
+ {
1095
+ type: "text",
1096
+ text: `luca_preferences_write: validation failed: ${result.error}`
1097
+ }
1098
+ ],
1099
+ isError: true
1100
+ };
1101
+ }
1102
+ const absPath = join(ctx.cwd, lucaRootPaths.config);
1103
+ await writeAtomicFile(
1104
+ absPath,
1105
+ JSON.stringify(result.nextConfig, null, 2) + "\n"
1106
+ );
1107
+ const ignoredNote = result.ignoredKeys.length > 0 ? `; ignored ${result.ignoredKeys.length} unknown key(s): ${result.ignoredKeys.join(", ")}` : "";
1108
+ return {
1109
+ content: [
1110
+ {
1111
+ type: "text",
1112
+ text: `wrote .luca/config.json (preferences section updated; ${result.mergedSections.length} section(s) merged${ignoredNote})`
1113
+ }
1114
+ ]
1115
+ };
1116
+ }
1117
+ };
1118
+
1119
+ const inputSchema$8 = z.object({
1120
+ finding: ShadowScanFindingSchema.describe(
1121
+ "A single finding from a luca-shadow-scanner ShadowScanReport. recommended_action drives what gets applied."
1122
+ ),
1123
+ confirm: z.boolean().default(false).describe(
1124
+ "Must be true to actually apply the remediation. Default false so a stray call cannot delete/move files."
1125
+ )
1126
+ });
1127
+ function checkPath(cwd, relPath) {
1128
+ const root = resolve(cwd);
1129
+ const abs = resolve(root, relPath);
1130
+ const rel = relative(root, abs);
1131
+ if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`)) {
1132
+ return {
1133
+ ok: false,
1134
+ abs,
1135
+ error: `path "${relPath}" escapes the project root`
1136
+ };
1137
+ }
1138
+ if (rel === ".git" || rel.startsWith(`.git${sep}`)) {
1139
+ return {
1140
+ ok: false,
1141
+ abs,
1142
+ error: `refusing to operate inside .git/ ("${relPath}")`
1143
+ };
1144
+ }
1145
+ return { ok: true, abs };
1146
+ }
1147
+ const lucaRepoCleanupApplyTool = {
1148
+ name: "luca_repo_cleanup_apply",
1149
+ description: "Apply one luca-shadow-scanner finding: delete a debris file, move a misplaced file, or add a path to .gitignore. Destructive \u2014 requires confirm=true. Path-safety enforced (no traversal, no .git/).",
1150
+ inputSchema: inputSchema$8,
1151
+ async handler(args, ctx) {
1152
+ if (!args.confirm) {
1153
+ return {
1154
+ content: [
1155
+ {
1156
+ type: "text",
1157
+ text: "luca_repo_cleanup_apply refused: confirm=true required (delete/move are destructive)."
1158
+ }
1159
+ ],
1160
+ isError: true
1161
+ };
1162
+ }
1163
+ const { finding } = args;
1164
+ const src = checkPath(ctx.cwd, finding.file_path);
1165
+ if (!src.ok) {
1166
+ return {
1167
+ content: [
1168
+ {
1169
+ type: "text",
1170
+ text: `luca_repo_cleanup_apply: ${src.error}`
1171
+ }
1172
+ ],
1173
+ isError: true
1174
+ };
1175
+ }
1176
+ switch (finding.recommended_action) {
1177
+ case "delete": {
1178
+ if (!existsSync(src.abs)) {
1179
+ return {
1180
+ content: [
1181
+ {
1182
+ type: "text",
1183
+ text: `skipped: ${finding.file_path} not found (nothing to delete).`
1184
+ }
1185
+ ]
1186
+ };
1187
+ }
1188
+ await unlink(src.abs);
1189
+ return {
1190
+ content: [
1191
+ {
1192
+ type: "text",
1193
+ text: `applied delete: removed ${finding.file_path}`
1194
+ }
1195
+ ]
1196
+ };
1197
+ }
1198
+ case "move": {
1199
+ if (!finding.target_path) {
1200
+ return {
1201
+ content: [
1202
+ {
1203
+ type: "text",
1204
+ text: "luca_repo_cleanup_apply: target_path is required for a move action."
1205
+ }
1206
+ ],
1207
+ isError: true
1208
+ };
1209
+ }
1210
+ const dst = checkPath(ctx.cwd, finding.target_path);
1211
+ if (!dst.ok) {
1212
+ return {
1213
+ content: [
1214
+ {
1215
+ type: "text",
1216
+ text: `luca_repo_cleanup_apply: ${dst.error}`
1217
+ }
1218
+ ],
1219
+ isError: true
1220
+ };
1221
+ }
1222
+ if (!existsSync(src.abs)) {
1223
+ return {
1224
+ content: [
1225
+ {
1226
+ type: "text",
1227
+ text: `skipped: ${finding.file_path} not found (nothing to move).`
1228
+ }
1229
+ ]
1230
+ };
1231
+ }
1232
+ await mkdir(dirname(dst.abs), { recursive: true });
1233
+ await rename(src.abs, dst.abs);
1234
+ return {
1235
+ content: [
1236
+ {
1237
+ type: "text",
1238
+ text: `applied move: ${finding.file_path} \u2192 ${finding.target_path}`
1239
+ }
1240
+ ]
1241
+ };
1242
+ }
1243
+ case "gitignore": {
1244
+ const gitignorePath = join(resolve(ctx.cwd), ".gitignore");
1245
+ const existing = existsSync(gitignorePath) ? await readFile(gitignorePath, "utf-8") : "";
1246
+ const alreadyPresent = existing.split("\n").some((line) => {
1247
+ const trimmed = line.trim();
1248
+ return trimmed !== "" && !trimmed.startsWith("#") && trimmed === finding.file_path;
1249
+ });
1250
+ if (alreadyPresent) {
1251
+ return {
1252
+ content: [
1253
+ {
1254
+ type: "text",
1255
+ text: `skipped: ${finding.file_path} already in .gitignore.`
1256
+ }
1257
+ ]
1258
+ };
1259
+ }
1260
+ const newline = existing === "" || existing.endsWith("\n") ? "" : "\n";
1261
+ await appendFile(
1262
+ gitignorePath,
1263
+ `${newline}${finding.file_path}
1264
+ `
1265
+ );
1266
+ return {
1267
+ content: [
1268
+ {
1269
+ type: "text",
1270
+ text: `applied gitignore: added ${finding.file_path} to .gitignore`
1271
+ }
1272
+ ]
1273
+ };
1274
+ }
1275
+ }
1276
+ }
1277
+ };
1278
+
1279
+ const inputSchema$7 = z.object({
1280
+ phases: z.array(RoadmapPhaseSchema).min(1).describe(
1281
+ "Ordered list of roadmap phases. Each entry: { name, deps?, status?, complexity? }. Defaults: deps=[], status=pending."
1282
+ )
1283
+ });
1284
+ const lucaRoadmapCreateTool = {
1285
+ name: "luca_roadmap_create",
1286
+ description: "Replace the roadmap in .luca/state.json with a new ordered list of phases. Resets currentPhase to 0; updates totalPhases. Only callable in idle or triage pipelineSteps so an active roadmap cannot be clobbered mid-execution.",
1287
+ inputSchema: inputSchema$7,
1288
+ allowedPhases: ["idle", "triage"],
1289
+ async handler(args, ctx) {
1290
+ const state = await loadCurrentState({ cwd: ctx.cwd });
1291
+ const phases = args.phases.map((p) => ({
1292
+ ...p,
1293
+ deps: p.deps ?? [],
1294
+ status: p.status ?? "pending"
1295
+ }));
1296
+ const next = {
1297
+ ...state,
1298
+ roadmap: phases,
1299
+ totalPhases: phases.length,
1300
+ currentPhase: 0
1301
+ };
1302
+ const absPath = join(ctx.cwd, lucaRootPaths.state);
1303
+ await writeAtomicFile(absPath, JSON.stringify(next, null, 2) + "\n");
1304
+ return {
1305
+ content: [
1306
+ {
1307
+ type: "text",
1308
+ text: `wrote .luca/state.json (roadmap replaced with ${phases.length} phase(s); currentPhase reset to 0)`
1309
+ }
1310
+ ]
1311
+ };
1312
+ }
1313
+ };
1314
+
1315
+ const inputSchema$6 = z.object({});
1316
+ const lucaRoadmapReadTool = {
1317
+ name: "luca_roadmap_read",
1318
+ description: "Read the roadmap array from .luca/state.json, plus currentPhase and totalPhases for context. Each entry is { name, deps, status, complexity? }. Pure read \u2014 callable in every pipelineStep.",
1319
+ inputSchema: inputSchema$6,
1320
+ async handler(_args, ctx) {
1321
+ const state = await loadCurrentState({ cwd: ctx.cwd });
1322
+ const payload = {
1323
+ currentPhase: state.currentPhase,
1324
+ totalPhases: state.roadmap.length,
1325
+ roadmap: state.roadmap
1326
+ };
1327
+ return {
1328
+ content: [
1329
+ { type: "text", text: JSON.stringify(payload, null, 2) }
1330
+ ]
1331
+ };
1332
+ }
1333
+ };
1334
+
1335
+ function isPhaseHeading(line, phaseName) {
1336
+ const m = /^(#{1,6})\s+(.*)$/.exec(line);
1337
+ if (!m || !m[2]) return false;
1338
+ const headingText = m[2].trim();
1339
+ const target = phaseName.trim().toLowerCase();
1340
+ if (!target) return false;
1341
+ if (headingText.toLowerCase() === target) return true;
1342
+ const prefixed = /^Phase\s+\d+(?:\.\d+)*\s*[:\-—]\s*(.+)$/i.exec(
1343
+ headingText
1344
+ );
1345
+ if (prefixed && prefixed[1] && prefixed[1].trim().toLowerCase() === target)
1346
+ return true;
1347
+ if (headingText.toLowerCase() === `phase ${target}`) return true;
1348
+ return false;
1349
+ }
1350
+ function headingDepth(line) {
1351
+ const m = /^(#{1,6})\s+\S/.exec(line);
1352
+ return m && m[1] ? m[1].length : 0;
1353
+ }
1354
+ function tickPhaseTasks(planFile, phaseName) {
1355
+ const base = {
1356
+ tickedCount: 0,
1357
+ alreadyTickedCount: 0,
1358
+ planFile,
1359
+ phaseName,
1360
+ tickedLines: []
1361
+ };
1362
+ if (!planFile) {
1363
+ return {
1364
+ ...base,
1365
+ success: false,
1366
+ reason: "planFile path is empty"
1367
+ };
1368
+ }
1369
+ if (!phaseName) {
1370
+ return {
1371
+ ...base,
1372
+ success: false,
1373
+ reason: "phaseName is empty"
1374
+ };
1375
+ }
1376
+ if (!existsSync(planFile)) {
1377
+ return {
1378
+ ...base,
1379
+ success: false,
1380
+ reason: `plan.md not found at ${planFile}`
1381
+ };
1382
+ }
1383
+ let raw;
1384
+ try {
1385
+ raw = readFileSync(planFile, "utf8");
1386
+ } catch (err) {
1387
+ return {
1388
+ ...base,
1389
+ success: false,
1390
+ reason: `failed to read ${planFile}: ${err.message}`
1391
+ };
1392
+ }
1393
+ const hadTrailingNewline = raw.endsWith("\n");
1394
+ const lines = raw.split("\n");
1395
+ if (hadTrailingNewline) {
1396
+ lines.pop();
1397
+ }
1398
+ let sectionStart = -1;
1399
+ let sectionDepth = 0;
1400
+ for (let i = 0; i < lines.length; i++) {
1401
+ const candidate = lines[i] ?? "";
1402
+ if (isPhaseHeading(candidate, phaseName)) {
1403
+ sectionStart = i + 1;
1404
+ sectionDepth = headingDepth(candidate);
1405
+ break;
1406
+ }
1407
+ }
1408
+ if (sectionStart === -1) {
1409
+ return {
1410
+ ...base,
1411
+ success: false,
1412
+ reason: `no heading matching phase "${phaseName}" found in ${planFile}`
1413
+ };
1414
+ }
1415
+ let sectionEnd = lines.length;
1416
+ for (let i = sectionStart; i < lines.length; i++) {
1417
+ const d = headingDepth(lines[i] ?? "");
1418
+ if (d > 0 && d <= sectionDepth) {
1419
+ sectionEnd = i;
1420
+ break;
1421
+ }
1422
+ }
1423
+ const uncheckedRe = /^(\s*[-*]\s+)\[ \](\s)/;
1424
+ const checkedRe = /^(\s*[-*]\s+)\[[xX]\](\s)/;
1425
+ const tickedLines = [];
1426
+ let alreadyTicked = 0;
1427
+ for (let i = sectionStart; i < sectionEnd; i++) {
1428
+ const line = lines[i] ?? "";
1429
+ if (uncheckedRe.test(line)) {
1430
+ lines[i] = line.replace(uncheckedRe, "$1[x]$2");
1431
+ tickedLines.push(i + 1);
1432
+ } else if (checkedRe.test(line)) {
1433
+ alreadyTicked++;
1434
+ }
1435
+ }
1436
+ if (tickedLines.length === 0) {
1437
+ return {
1438
+ ...base,
1439
+ success: true,
1440
+ alreadyTickedCount: alreadyTicked,
1441
+ reason: alreadyTicked > 0 ? `all ${alreadyTicked} checkbox(es) in phase "${phaseName}" already ticked` : `no '- [ ]' checkboxes found under phase "${phaseName}"`
1442
+ };
1443
+ }
1444
+ const out = lines.join("\n") + (hadTrailingNewline ? "\n" : "");
1445
+ try {
1446
+ writeFileSync(planFile, out, "utf8");
1447
+ } catch (err) {
1448
+ return {
1449
+ ...base,
1450
+ success: false,
1451
+ reason: `failed to write ${planFile}: ${err.message}`
1452
+ };
1453
+ }
1454
+ return {
1455
+ ...base,
1456
+ success: true,
1457
+ tickedCount: tickedLines.length,
1458
+ alreadyTickedCount: alreadyTicked,
1459
+ tickedLines
1460
+ };
1461
+ }
1462
+
1463
+ const inputSchema$5 = z.object({
1464
+ toStep: PipelineStep.describe(
1465
+ "Target pipelineStep. Must be a legal transition from the current step (see the pipeline-transitions table)."
1466
+ )
1467
+ });
1468
+ function stepOrdinal(step) {
1469
+ return PipelineStepValues.indexOf(step);
1470
+ }
1471
+ const EMPTY_STEP_CHECK = {
1472
+ research: "research",
1473
+ discuss: "context",
1474
+ plan: "plan",
1475
+ "plan-review": "plan-review",
1476
+ execute: "execute/summary",
1477
+ verify: "verify",
1478
+ learn: "learn"
1479
+ };
1480
+ function expectedArtifactPath(cwd, slug, key) {
1481
+ switch (key) {
1482
+ case "research":
1483
+ case "context":
1484
+ case "plan":
1485
+ case "plan-review":
1486
+ case "verify":
1487
+ case "learn":
1488
+ return join(cwd, phasePathFor(slug, key));
1489
+ case "execute/summary":
1490
+ return join(cwd, phasePathFor(slug, "execute/summary"));
1491
+ default:
1492
+ return null;
1493
+ }
1494
+ }
1495
+ const lucaStateAdvanceTool = {
1496
+ name: "luca_state_advance",
1497
+ description: "Atomically advance the workflow pipelineStep. Validates the transition against the pipeline-transitions table; legal forward + loop-back transitions allowed, illegal jumps rejected.",
1498
+ inputSchema: inputSchema$5,
1499
+ async handler(args, ctx) {
1500
+ const state = await loadCurrentState({ cwd: ctx.cwd });
1501
+ const from = state.pipelineStep;
1502
+ const to = args.toStep;
1503
+ if (!isLegalTransition(from, to)) {
1504
+ const allowed = PIPELINE_TRANSITIONS[from].join(", ");
1505
+ return {
1506
+ content: [
1507
+ {
1508
+ type: "text",
1509
+ text: `illegal transition: '${from}' \u2192 '${to}'. Allowed next steps from '${from}': [${allowed}].`
1510
+ }
1511
+ ],
1512
+ isError: true
1513
+ };
1514
+ }
1515
+ const next = { ...state, pipelineStep: to };
1516
+ const path = join(ctx.cwd, ".luca", "state.json");
1517
+ await writeAtomicFile(path, JSON.stringify(next, null, 2) + "\n");
1518
+ try {
1519
+ const runId = typeof state.sessionId === "string" ? state.sessionId : "";
1520
+ appendLedger({
1521
+ cwd: ctx.cwd,
1522
+ runId,
1523
+ event: "mode-transition",
1524
+ data: { from, to }
1525
+ });
1526
+ if (stepOrdinal(to) < stepOrdinal(from)) {
1527
+ appendLedger({
1528
+ cwd: ctx.cwd,
1529
+ runId,
1530
+ event: "pipeline-re-entered",
1531
+ data: {
1532
+ targetMode: to,
1533
+ from,
1534
+ reason: `loop-back from '${from}' to '${to}' (rework / fix iteration)`
1535
+ }
1536
+ });
1537
+ }
1538
+ const expectedKey = EMPTY_STEP_CHECK[from];
1539
+ if (expectedKey !== void 0) {
1540
+ const declared = STEP_ARTIFACTS[from];
1541
+ if (declared.includes(expectedKey)) {
1542
+ const slugResult = resolveActiveSlug(state);
1543
+ if (slugResult.ok) {
1544
+ const artifactPath = expectedArtifactPath(
1545
+ ctx.cwd,
1546
+ slugResult.slug,
1547
+ expectedKey
1548
+ );
1549
+ if (artifactPath !== null && !existsSync(artifactPath)) {
1550
+ appendLedger({
1551
+ cwd: ctx.cwd,
1552
+ runId,
1553
+ event: "phase-empty-detected",
1554
+ data: {
1555
+ from,
1556
+ to,
1557
+ slug: slugResult.slug,
1558
+ expectedArtifact: expectedKey,
1559
+ reason: `step '${from}' advanced to '${to}' without writing its expected artifact ('${expectedKey}') \u2014 possible empty/skipped step`
1560
+ }
1561
+ });
1562
+ }
1563
+ }
1564
+ }
1565
+ }
1566
+ } catch {
1567
+ }
1568
+ try {
1569
+ const fromCoarse = PIPELINE_STEP_TO_COARSE_PHASE[from];
1570
+ const toCoarse = PIPELINE_STEP_TO_COARSE_PHASE[to];
1571
+ if (fromCoarse === "EXECUTING" && toCoarse !== "EXECUTING") {
1572
+ const slugResult = resolveActiveSlug(state);
1573
+ if (slugResult.ok) {
1574
+ const planFile = join(
1575
+ ctx.cwd,
1576
+ phasePathFor(slugResult.slug, "plan")
1577
+ );
1578
+ const roadmapEntry = state.roadmap[state.currentPhase - 1];
1579
+ const phaseName = roadmapEntry?.name ?? slugResult.slug;
1580
+ const tickResult = tickPhaseTasks(planFile, phaseName);
1581
+ const runIdForTick = typeof state.sessionId === "string" ? state.sessionId : "";
1582
+ appendLedger({
1583
+ cwd: ctx.cwd,
1584
+ runId: runIdForTick,
1585
+ event: "plan-tick-result",
1586
+ data: {
1587
+ phase: phaseName,
1588
+ slug: slugResult.slug,
1589
+ planFile: tickResult.planFile,
1590
+ success: tickResult.success,
1591
+ tickedCount: tickResult.tickedCount,
1592
+ alreadyTickedCount: tickResult.alreadyTickedCount,
1593
+ reason: tickResult.reason ?? null
1594
+ }
1595
+ });
1596
+ }
1597
+ }
1598
+ } catch {
1599
+ }
1600
+ return {
1601
+ content: [
1602
+ {
1603
+ type: "text",
1604
+ text: `pipelineStep advanced: '${from}' \u2192 '${to}'`
1605
+ }
1606
+ ]
1607
+ };
1608
+ }
1609
+ };
1610
+
1611
+ const inputSchema$4 = z.object({});
1612
+ const lucaStateReadTool = {
1613
+ name: "luca_state_read",
1614
+ description: "Read the current workflow state from .luca/state.json. Returns the parsed JSON including pipelineStep, currentPhase, iteration counters, and the roadmap.",
1615
+ inputSchema: inputSchema$4,
1616
+ // No allowedPhases — read-only tool available in every phase.
1617
+ async handler(_args, ctx) {
1618
+ const state = await loadCurrentState({ cwd: ctx.cwd });
1619
+ return {
1620
+ content: [
1621
+ {
1622
+ type: "text",
1623
+ text: JSON.stringify(state, null, 2)
1624
+ }
1625
+ ]
1626
+ };
1627
+ }
1628
+ };
1629
+
1630
+ const inputSchema$3 = z.object({
1631
+ title: z.string().min(1).max(200).describe(
1632
+ "Short imperative description of the todo. Used to derive the id when one is not supplied."
1633
+ ),
1634
+ body: z.string().max(8192).optional().describe(
1635
+ "Optional longer markdown body \u2014 context, acceptance criteria, references."
1636
+ ),
1637
+ /**
1638
+ * Only pending and backlog are allowed at create time. Promoting
1639
+ * to `done` happens via luca_todo_update with a verificationRef
1640
+ * that points at a met criterion in verify.json.
1641
+ */
1642
+ status: z.enum(["pending", "backlog"]).default("pending"),
1643
+ source: z.string().max(120).optional().describe(
1644
+ 'Where this todo originated \u2014 e.g. "gh-issue-#42", "phase-research", "manual".'
1645
+ ),
1646
+ metadata: z.record(z.string(), z.unknown()).optional().describe(
1647
+ "Arbitrary structured fields the caller wants to record alongside the todo."
1648
+ ),
1649
+ id: TodoIdSchema.optional().describe(
1650
+ "Optional explicit id (kebab-case). When omitted, derived from the title."
1651
+ )
1652
+ });
1653
+ const lucaTodoAddTool = {
1654
+ name: "luca_todo_add",
1655
+ description: 'Create a new todo. Validates inputs server-side and returns a muninn_remember instruction for the agent to execute (delegation pattern \u2014 todos persist in MuninnDB under concept "todo:<id>"). Status defaults to pending; promotion to "done" goes through luca_todo_update.',
1656
+ inputSchema: inputSchema$3,
1657
+ async handler(args, ctx) {
1658
+ const vault = await resolveRepoVault({ cwd: ctx.cwd });
1659
+ let id;
1660
+ try {
1661
+ id = args.id ?? slugFromTitle(args.title);
1662
+ } catch (err) {
1663
+ return {
1664
+ content: [{ type: "text", text: err.message }],
1665
+ isError: true
1666
+ };
1667
+ }
1668
+ const todo = TodoSchema.parse({
1669
+ schemaVersion: 1,
1670
+ id,
1671
+ title: args.title,
1672
+ ...args.body !== void 0 ? { body: args.body } : {},
1673
+ status: args.status,
1674
+ ...args.source !== void 0 ? { source: args.source } : {},
1675
+ ...args.metadata !== void 0 ? { metadata: args.metadata } : {},
1676
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1677
+ });
1678
+ const instruction = buildMuninnInstruction({
1679
+ tool: "mcp__muninn__muninn_remember",
1680
+ args: {
1681
+ vault,
1682
+ concept: todoConceptFor(id),
1683
+ content: JSON.stringify(todo)
1684
+ },
1685
+ description: `Persist todo "${id}" (status=${todo.status}) to MuninnDB.`
1686
+ });
1687
+ return {
1688
+ content: [
1689
+ { type: "text", text: JSON.stringify(instruction, null, 2) }
1690
+ ]
1691
+ };
1692
+ }
1693
+ };
1694
+
1695
+ const inputSchema$2 = z.object({
1696
+ status: TodoStatus.optional().describe(
1697
+ "Optional status filter. Applied post-recall by the agent (muninn cannot filter on content metadata)."
1698
+ ),
1699
+ limit: z.number().int().min(1).max(200).default(50).describe("Max todos to recall (range 1\u2013200, default 50).")
1700
+ });
1701
+ const lucaTodoListTool = {
1702
+ name: "luca_todo_list",
1703
+ description: `List todos from MuninnDB. Returns a muninn_recall instruction with context ["todo:"] in the repo vault. Optional status filter is applied by the agent post-recall (recall returns the todo's JSON content; the agent filters by content.status).`,
1704
+ inputSchema: inputSchema$2,
1705
+ async handler(args, ctx) {
1706
+ const vault = await resolveRepoVault({ cwd: ctx.cwd });
1707
+ const description = args.status ? `Recall up to ${args.limit} todos. Parse each result's content JSON and keep only those where content.status === "${args.status}".` : `Recall up to ${args.limit} todos. Each result's content is JSON conforming to TodoSchema.`;
1708
+ const instruction = buildMuninnInstruction({
1709
+ tool: "mcp__muninn__muninn_recall",
1710
+ args: {
1711
+ vault,
1712
+ context: [TODO_CONCEPT_PREFIX],
1713
+ mode: "balanced",
1714
+ limit: args.limit
1715
+ },
1716
+ description
1717
+ });
1718
+ return {
1719
+ content: [
1720
+ { type: "text", text: JSON.stringify(instruction, null, 2) }
1721
+ ]
1722
+ };
1723
+ }
1724
+ };
1725
+
1726
+ const inputSchema$1 = z.object({
1727
+ id: TodoIdSchema.describe(
1728
+ "Existing todo id (kebab-case). Used as the muninn concept suffix: todo:<id>."
1729
+ ),
1730
+ title: z.string().min(1).max(200).describe(
1731
+ "Full title of the todo. Pass the existing title unchanged unless renaming."
1732
+ ),
1733
+ body: z.string().max(8192).optional(),
1734
+ status: TodoStatus.describe(
1735
+ `New status. Promoting to "done" requires verificationRef pointing at a met PASS criterion in the active phase's verify.json.`
1736
+ ),
1737
+ source: z.string().max(120).optional(),
1738
+ metadata: z.record(z.string(), z.unknown()).optional(),
1739
+ verificationRef: VerificationRefSchema.optional().describe(
1740
+ `Required when status === "done". Points at a criterionId in the active phase's verify.json that is met=true with non-empty evidence and parent status=PASS.`
1741
+ )
1742
+ });
1743
+ const lucaTodoUpdateTool = {
1744
+ name: "luca_todo_update",
1745
+ description: `Update a todo's state. Returns a muninn_remember instruction the agent executes (delegation pattern). Server-side verification-ref guard: transitioning to status="done" requires a verificationRef pointing at a met PASS criterion in the active phase's verify.json.`,
1746
+ inputSchema: inputSchema$1,
1747
+ async handler(args, ctx) {
1748
+ if (args.status === "done") {
1749
+ if (!args.verificationRef) {
1750
+ return {
1751
+ content: [
1752
+ {
1753
+ type: "text",
1754
+ text: `luca_todo_update: verificationRef is required when status === "done". Provide { criterionId } pointing at a met PASS criterion in the active phase's verify.json.`
1755
+ }
1756
+ ],
1757
+ isError: true
1758
+ };
1759
+ }
1760
+ const err = await validateVerificationRef({
1761
+ cwd: ctx.cwd,
1762
+ ref: args.verificationRef
1763
+ });
1764
+ if (err) {
1765
+ return {
1766
+ content: [
1767
+ {
1768
+ type: "text",
1769
+ text: `luca_todo_update: ${err.code} \u2014 ${err.message}`
1770
+ }
1771
+ ],
1772
+ isError: true
1773
+ };
1774
+ }
1775
+ }
1776
+ const todo = TodoSchema.parse({
1777
+ schemaVersion: 1,
1778
+ id: args.id,
1779
+ title: args.title,
1780
+ ...args.body !== void 0 ? { body: args.body } : {},
1781
+ status: args.status,
1782
+ ...args.source !== void 0 ? { source: args.source } : {},
1783
+ ...args.metadata !== void 0 ? { metadata: args.metadata } : {},
1784
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
1785
+ ...args.verificationRef !== void 0 ? { verificationRef: args.verificationRef } : {}
1786
+ });
1787
+ const vault = await resolveRepoVault({ cwd: ctx.cwd });
1788
+ const instruction = buildMuninnInstruction({
1789
+ tool: "mcp__muninn__muninn_remember",
1790
+ args: {
1791
+ vault,
1792
+ concept: todoConceptFor(args.id),
1793
+ content: JSON.stringify(todo)
1794
+ },
1795
+ description: `Update todo "${args.id}" (status=${todo.status}).`
1796
+ });
1797
+ return {
1798
+ content: [
1799
+ {
1800
+ type: "text",
1801
+ text: JSON.stringify(instruction, null, 2)
1802
+ }
1803
+ ]
1804
+ };
1805
+ }
1806
+ };
1807
+
1808
+ const inputSchema = z.object({
1809
+ confirm: z.boolean().default(false).describe(
1810
+ "Must be true to actually perform the reset. Default false so accidental invocations are refused."
1811
+ )
1812
+ });
1813
+ const lucaWorkflowResetTool = {
1814
+ name: "luca_workflow_reset",
1815
+ description: "Reset .luca/state.json to schema defaults and remove the pipeline lock. Destructive: requires confirm=true. Use when the workflow is wedged or a session ended mid-step and needs a clean restart.",
1816
+ inputSchema,
1817
+ async handler(args, ctx) {
1818
+ if (!args.confirm) {
1819
+ return {
1820
+ content: [
1821
+ {
1822
+ type: "text",
1823
+ text: "luca_workflow_reset refused: confirm=true required (destructive operation)."
1824
+ }
1825
+ ],
1826
+ isError: true
1827
+ };
1828
+ }
1829
+ const defaultState = lucaStateSchema.parse({});
1830
+ const statePath = join(ctx.cwd, lucaRootPaths.state);
1831
+ const lockPath = join(ctx.cwd, lucaRootPaths.lock);
1832
+ await writeAtomicFile(
1833
+ statePath,
1834
+ JSON.stringify(defaultState, null, 2) + "\n"
1835
+ );
1836
+ await rm(lockPath, { force: true });
1837
+ return {
1838
+ content: [
1839
+ {
1840
+ type: "text",
1841
+ text: `reset ${lucaRootPaths.state} to defaults; pipeline lock cleared if present.`
1842
+ }
1843
+ ]
1844
+ };
1845
+ }
1846
+ };
1847
+
1848
+ async function runWriteHandler(command, tool, rawArgs) {
1849
+ const cwd = process.cwd();
1850
+ const allowedPhases = WRITE_COMMAND_PHASES[command];
1851
+ if (allowedPhases && allowedPhases.length > 0) {
1852
+ const state = await loadCurrentState({ cwd });
1853
+ if (!allowedPhases.includes(state.pipelineStep)) {
1854
+ console.error(
1855
+ `luca ${command}: refused \u2014 current pipelineStep is '${state.pipelineStep}', but this command is only allowed in: [${allowedPhases.join(", ")}].`
1856
+ );
1857
+ process.exit(1);
1858
+ }
1859
+ }
1860
+ const parsed = tool.inputSchema.safeParse(rawArgs);
1861
+ if (!parsed.success) {
1862
+ console.error(
1863
+ `luca ${command}: invalid arguments \u2014 ${parsed.error.issues.map((i) => `${i.path.join(".") || "<root>"}: ${i.message}`).join("; ")}`
1864
+ );
1865
+ process.exit(1);
1866
+ }
1867
+ let result;
1868
+ try {
1869
+ result = await tool.handler(parsed.data, { cwd });
1870
+ } catch (err) {
1871
+ console.error(
1872
+ `luca ${command}: handler error \u2014 ${err instanceof Error ? err.message : String(err)}`
1873
+ );
1874
+ process.exit(1);
1875
+ }
1876
+ const text = result.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
1877
+ if (result.isError) {
1878
+ console.error(text);
1879
+ process.exit(1);
1880
+ }
1881
+ console.log(text);
1882
+ process.exit(0);
1883
+ }
1884
+ async function readJsonPayload(command, filePath) {
1885
+ let raw;
1886
+ try {
1887
+ raw = await readFile(filePath, "utf-8");
1888
+ } catch (err) {
1889
+ console.error(
1890
+ `luca ${command}: could not read --file '${filePath}' \u2014 ${err instanceof Error ? err.message : String(err)}`
1891
+ );
1892
+ process.exit(1);
1893
+ }
1894
+ try {
1895
+ return JSON.parse(raw);
1896
+ } catch (err) {
1897
+ console.error(
1898
+ `luca ${command}: --file '${filePath}' is not valid JSON \u2014 ${err instanceof Error ? err.message : String(err)}`
1899
+ );
1900
+ process.exit(1);
1901
+ }
1902
+ }
1903
+
1904
+ export { lucaStateReadTool as a, lucaPhaseCurrentTool as b, readJsonPayload as c, lucaRoadmapReadTool as d, lucaRoadmapCreateTool as e, lucaPreferencesReadTool as f, lucaPreferencesWriteTool as g, lucaTodoUpdateTool as h, lucaTodoListTool as i, lucaTodoAddTool as j, lucaPrReviewDetectConvergenceTool as k, lucaStateAdvanceTool as l, lucaPrReviewFilterStaleTool as m, lucaPrReviewRegressionCheckTool as n, lucaRepoCleanupApplyTool as o, lucaChecksRunTool as p, lucaBranchGuardTool as q, runWriteHandler as r, lucaWorkflowResetTool as s, lucaConfidenceLogTool as t };