trackops 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/control.js ADDED
@@ -0,0 +1,575 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { spawnSync } = require("child_process");
6
+
7
+ const config = require("./config");
8
+ const { t, setLocale, getLocale } = require("./i18n");
9
+
10
+ const PRIORITY_ORDER = ["P0", "P1", "P2", "P3"];
11
+ const STATUS_ORDER = ["in_progress", "in_review", "pending", "blocked", "completed", "cancelled"];
12
+ const STATUS_ICONS = {
13
+ pending: "\u23F3",
14
+ in_progress: "\uD83D\uDEA7",
15
+ in_review: "\uD83D\uDC40",
16
+ blocked: "\u26D4",
17
+ completed: "\u2705",
18
+ cancelled: "\uD83D\uDDD1\uFE0F",
19
+ };
20
+ const CHECK_ICONS = {
21
+ pass: "\u2705",
22
+ warn: "\u26A0\uFE0F",
23
+ fail: "\u274C",
24
+ pending: "\u23F3",
25
+ };
26
+
27
+ /* ── helpers ── */
28
+
29
+ function writeText(filePath, content) {
30
+ fs.writeFileSync(filePath, content.replace(/\r?\n/g, "\n"), "utf8");
31
+ }
32
+
33
+ function writeJson(filePath, data) {
34
+ writeText(filePath, `${JSON.stringify(data, null, 2)}\n`);
35
+ }
36
+
37
+ function nowIso() {
38
+ return new Date().toISOString();
39
+ }
40
+
41
+ function git(args, root) {
42
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
43
+ if (result.error || result.status !== 0) return null;
44
+ return result.stdout.replace(/\s+$/, "");
45
+ }
46
+
47
+ function statusLabel(status) {
48
+ return t(`status.${status}`);
49
+ }
50
+
51
+ /* ── repo snapshot ── */
52
+
53
+ function getRepoSnapshot(root) {
54
+ const branch = git(["branch", "--show-current"], root) || "detached";
55
+ const status = git(["status", "--short"], root) || "";
56
+ const lines = status.split(/\r?\n/).filter(Boolean);
57
+ const lastCommitRaw = git(["log", "-1", "--pretty=format:%H%n%cs%n%s"], root);
58
+ const divergenceRaw = git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], root);
59
+
60
+ let staged = 0;
61
+ let unstaged = 0;
62
+ let untracked = 0;
63
+
64
+ lines.forEach((line) => {
65
+ if (line.startsWith("??")) { untracked += 1; return; }
66
+ if (line[0] && line[0] !== " ") staged += 1;
67
+ if (line[1] && line[1] !== " ") unstaged += 1;
68
+ });
69
+
70
+ let lastCommit = null;
71
+ if (lastCommitRaw) {
72
+ const [hash, date, subject] = lastCommitRaw.split(/\r?\n/);
73
+ lastCommit = { hash, shortHash: hash ? hash.slice(0, 7) : null, date, subject };
74
+ }
75
+
76
+ let ahead = 0;
77
+ let behind = 0;
78
+ if (divergenceRaw) {
79
+ const [left, right] = divergenceRaw.split(/\s+/).map(Number);
80
+ behind = Number.isFinite(left) ? left : 0;
81
+ ahead = Number.isFinite(right) ? right : 0;
82
+ }
83
+
84
+ return { generatedAt: nowIso(), branch, clean: lines.length === 0, staged, unstaged, untracked, ahead, behind, lastCommit };
85
+ }
86
+
87
+ function refreshRepoRuntime(root, options = {}) {
88
+ const runtimeFile = config.runtimeFilePath(root);
89
+ fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });
90
+ const snapshot = getRepoSnapshot(root);
91
+ writeJson(runtimeFile, snapshot);
92
+ if (!options.quiet) {
93
+ console.log(t("cli.runtimeUpdated", { path: path.relative(root, runtimeFile) }));
94
+ }
95
+ return snapshot;
96
+ }
97
+
98
+ /* ── derive ── */
99
+
100
+ function getPhaseInfo(phaseId, phases) {
101
+ return phases.find((p) => p.id === phaseId) || { id: phaseId, label: phaseId, index: 99 };
102
+ }
103
+
104
+ function compareTasks(a, b, phases) {
105
+ const phaseDelta = getPhaseInfo(a.phase, phases).index - getPhaseInfo(b.phase, phases).index;
106
+ if (phaseDelta !== 0) return phaseDelta;
107
+ const priorityDelta = PRIORITY_ORDER.indexOf(a.priority) - PRIORITY_ORDER.indexOf(b.priority);
108
+ if (priorityDelta !== 0) return priorityDelta;
109
+ const statusDelta = STATUS_ORDER.indexOf(a.status) - STATUS_ORDER.indexOf(b.status);
110
+ if (statusDelta !== 0) return statusDelta;
111
+ return a.title.localeCompare(b.title, getLocale());
112
+ }
113
+
114
+ function derive(control) {
115
+ const phases = config.getPhases(control);
116
+ const tasks = [...control.tasks].sort((a, b) => compareTasks(a, b, phases));
117
+ const completedIds = new Set(tasks.filter((t) => t.status === "completed").map((t) => t.id));
118
+
119
+ const readyTasks = tasks
120
+ .filter((task) => {
121
+ if (task.status !== "pending") return false;
122
+ return (task.dependsOn || []).every((dep) => completedIds.has(dep));
123
+ })
124
+ .sort((a, b) => {
125
+ const focusPhase = control.meta.focusPhase || "";
126
+ const aFocused = a.phase === focusPhase ? 0 : 1;
127
+ const bFocused = b.phase === focusPhase ? 0 : 1;
128
+ if (aFocused !== bFocused) return aFocused - bFocused;
129
+ return compareTasks(a, b, phases);
130
+ });
131
+
132
+ const blockers = tasks.filter((t) => t.status === "blocked");
133
+ const activeTasks = tasks.filter((t) => t.status === "in_progress");
134
+ const reviewTasks = tasks.filter((t) => t.status === "in_review");
135
+ const openTasks = tasks.filter((t) => !["completed", "cancelled"].includes(t.status));
136
+ const requiredOpenTasks = tasks.filter((t) => t.required !== false && !["completed", "cancelled"].includes(t.status));
137
+
138
+ const activePhase =
139
+ phases.find((p) => requiredOpenTasks.some((t) => t.phase === p.id)) ||
140
+ phases[phases.length - 1];
141
+
142
+ const phaseStats = phases.map((phase) => {
143
+ const phaseTasks = tasks.filter((t) => t.phase === phase.id && t.required !== false);
144
+ const completed = phaseTasks.filter((t) => t.status === "completed").length;
145
+ return { ...phase, total: phaseTasks.length, completed, remaining: phaseTasks.length - completed };
146
+ });
147
+
148
+ const nextTask = activeTasks[0] || readyTasks[0] || blockers[0] || openTasks[0] || null;
149
+
150
+ return {
151
+ tasks, blockers, activeTasks, reviewTasks, readyTasks, nextTask, activePhase, phaseStats,
152
+ openFindings: (control.findings || []).filter((f) => f.status === "open"),
153
+ resolvedFindings: (control.findings || []).filter((f) => f.status === "resolved"),
154
+ totals: {
155
+ all: tasks.length,
156
+ completed: tasks.filter((t) => t.status === "completed").length,
157
+ pending: tasks.filter((t) => t.status === "pending").length,
158
+ inProgress: activeTasks.length,
159
+ inReview: reviewTasks.length,
160
+ blocked: blockers.length,
161
+ cancelled: tasks.filter((t) => t.status === "cancelled").length,
162
+ },
163
+ };
164
+ }
165
+
166
+ /* ── render ── */
167
+
168
+ function renderTask(task, phases) {
169
+ const phase = getPhaseInfo(task.phase, phases);
170
+ const detail = task.blocker || task.summary || "";
171
+ const detailSuffix = detail ? ` — ${detail}` : "";
172
+ return `- ${STATUS_ICONS[task.status]} \`${task.id}\` [${task.priority}] ${task.title} (${phase.id} · ${phase.label} · ${task.stream})${detailSuffix}`;
173
+ }
174
+
175
+ function renderTaskPlan(control) {
176
+ const phases = config.getPhases(control);
177
+ const state = derive(control);
178
+ const blockersLabel = state.blockers.length
179
+ ? state.blockers.map((t) => t.title).join("; ")
180
+ : t("doc.label.noBlockers");
181
+ const nextStep = state.nextTask ? state.nextTask.title : t("doc.label.noOpenTasks");
182
+
183
+ const externalDecisions = (control.decisionsPending || []).length
184
+ ? control.decisionsPending.map((d) => `- [${d.owner}] ${d.title} — ${d.impact}`).join("\n")
185
+ : `- ${t("doc.label.noDecisions")}`;
186
+
187
+ const readyTasks = state.readyTasks.length
188
+ ? state.readyTasks.slice(0, 6).map((task) => renderTask(task, phases)).join("\n")
189
+ : `- ${t("doc.label.noReadyTasks")}`;
190
+
191
+ const phaseBlocks = phases.map((phase) => {
192
+ const phaseTasks = state.tasks.filter((task) => task.phase === phase.id);
193
+ const stats = state.phaseStats.find((s) => s.id === phase.id);
194
+ const lines = phaseTasks.length
195
+ ? phaseTasks.map((task) => renderTask(task, phases)).join("\n")
196
+ : `- ${t("doc.label.noTasks")}`;
197
+
198
+ const phaseStatus = phase.id === state.activePhase.id
199
+ ? t("doc.label.phaseActive")
200
+ : stats.remaining === 0
201
+ ? t("doc.label.phaseClosed")
202
+ : t("doc.label.phasePending");
203
+
204
+ return [
205
+ `## ${t("doc.section.phase", { phaseId: phase.id, phaseLabel: phase.label })}`,
206
+ `- ${t("doc.label.progress", { completed: stats.completed, total: stats.total })}`,
207
+ `- ${t("doc.label.findingStatus")}: ${phaseStatus}`,
208
+ "",
209
+ lines,
210
+ ].join("\n");
211
+ }).join("\n\n---\n\n");
212
+
213
+ return [
214
+ `# ${t("doc.header.taskPlan", { projectName: control.meta.projectName })}`,
215
+ "",
216
+ `> ${t("doc.autogenerated")}`,
217
+ "",
218
+ `## ${t("doc.section.operativeState")}`,
219
+ `- ${t("doc.label.activePhase")}: ${state.activePhase.id} — ${state.activePhase.label} (${state.activePhase.index}/${phases.length})`,
220
+ `- ${t("doc.label.currentFocus")}: ${control.meta.currentFocus}`,
221
+ `- ${t("doc.label.deliveryTarget")}: ${control.meta.deliveryTarget}`,
222
+ `- ${t("doc.label.blockers")}: ${blockersLabel}`,
223
+ `- ${t("doc.label.nextStep")}: ${nextStep}`,
224
+ "",
225
+ `### ${t("doc.section.externalDecisions")}`,
226
+ externalDecisions,
227
+ "",
228
+ `### ${t("doc.section.readyTasks")}`,
229
+ readyTasks,
230
+ "",
231
+ "---",
232
+ "",
233
+ phaseBlocks,
234
+ ].join("\n");
235
+ }
236
+
237
+ function renderProgress(control) {
238
+ const phases = config.getPhases(control);
239
+ const state = derive(control);
240
+ const blockersLabel = state.blockers.length
241
+ ? state.blockers.map((t) => t.title).join("; ")
242
+ : t("doc.label.noBlockers");
243
+ const nextStep = state.nextTask ? state.nextTask.title : t("doc.label.noOpenTasks");
244
+ const lastTest = (control.checks || {}).lastTest || { status: "pending" };
245
+ const latestHistory = state.tasks
246
+ .flatMap((task) => (task.history || []).map((entry) => ({ ...entry, taskId: task.id, taskTitle: task.title })))
247
+ .sort((a, b) => (a.at < b.at ? 1 : -1))
248
+ .slice(0, 8);
249
+
250
+ const activeLines = state.activeTasks.length
251
+ ? state.activeTasks.map((task) => renderTask(task, phases)).join("\n")
252
+ : `- ${t("doc.label.noActiveTasks")}`;
253
+
254
+ const reviewLines = state.reviewTasks.length
255
+ ? state.reviewTasks.map((task) => renderTask(task, phases)).join("\n")
256
+ : `- ${t("doc.label.noReviewTasks")}`;
257
+
258
+ const blockerLines = state.blockers.length
259
+ ? state.blockers.map((task) => renderTask(task, phases)).join("\n")
260
+ : `- ${t("doc.label.noActiveBlockers")}`;
261
+
262
+ const historyLines = latestHistory.length
263
+ ? latestHistory.map((e) => `- [${e.at.slice(0, 10)}] \`${e.taskId}\` ${e.action}${e.note ? ` — ${e.note}` : ""}`).join("\n")
264
+ : `- ${t("doc.label.noHistory")}`;
265
+
266
+ const milestoneLines = (control.milestones || [])
267
+ .map((m) => {
268
+ const items = m.items.map((item) => `- ${item}`).join("\n");
269
+ return [`### [${m.date}] — ${m.title}`, items].join("\n");
270
+ })
271
+ .join("\n\n");
272
+
273
+ return [
274
+ `# ${t("doc.header.progress", { projectName: control.meta.projectName })}`,
275
+ "",
276
+ `> ${t("doc.autogenerated")}`,
277
+ "",
278
+ `## ${t("doc.section.currentState")}`,
279
+ `- ${t("doc.label.phase")}: ${state.activePhase.label} (${state.activePhase.index}/${phases.length})`,
280
+ `- ${t("doc.label.blockers")}: ${blockersLabel}`,
281
+ `- ${t("doc.label.lastTest")}: ${CHECK_ICONS[lastTest.status]} ${lastTest.date || t("status.pending")}${lastTest.note ? ` — ${lastTest.note}` : ""}`,
282
+ `- ${t("doc.label.nextStepShort")}: ${nextStep}`,
283
+ `- ${t("doc.label.lastUpdate")}: ${(control.meta.updatedAt || "").slice(0, 10)}`,
284
+ "",
285
+ "---",
286
+ "",
287
+ `## ${t("doc.section.executionSummary")}`,
288
+ `- ${t("doc.label.totalTasks")}: ${state.totals.all}`,
289
+ `- ${t("doc.label.completedTasks")}: ${state.totals.completed}`,
290
+ `- ${t("doc.label.inProgressTasks")}: ${state.totals.inProgress}`,
291
+ `- ${t("doc.label.inReviewTasks")}: ${state.totals.inReview}`,
292
+ `- ${t("doc.label.pendingTasks")}: ${state.totals.pending}`,
293
+ `- ${t("doc.label.blockedTasks")}: ${state.totals.blocked}`,
294
+ "",
295
+ `### ${t("doc.section.activeTasks")}`,
296
+ activeLines,
297
+ "",
298
+ `### ${t("doc.section.reviewTasks")}`,
299
+ reviewLines,
300
+ "",
301
+ `### ${t("doc.section.activeBlockers")}`,
302
+ blockerLines,
303
+ "",
304
+ `### ${t("doc.section.recentActivity")}`,
305
+ historyLines,
306
+ "",
307
+ "---",
308
+ "",
309
+ `## ${t("doc.section.milestones")}`,
310
+ "",
311
+ milestoneLines,
312
+ ].join("\n");
313
+ }
314
+
315
+ function renderFindings(control) {
316
+ const state = derive(control);
317
+ const openLines = state.openFindings.length
318
+ ? state.openFindings
319
+ .map((f) =>
320
+ `### [${f.severity.toUpperCase()}] ${f.title}\n- ${t("doc.label.findingStatus")}: ${t("doc.label.findingOpen")}\n- ${t("doc.label.findingDetail")}: ${f.detail}\n- ${t("doc.label.findingImpact")}: ${f.impact}`
321
+ )
322
+ .join("\n\n")
323
+ : t("doc.label.noFindings");
324
+
325
+ const resolvedLines = state.resolvedFindings.length
326
+ ? state.resolvedFindings
327
+ .map((f) =>
328
+ `### [${f.severity.toUpperCase()}] ${f.title}\n- ${t("doc.label.findingStatus")}: ${t("doc.label.findingResolved")}\n- ${t("doc.label.findingDetail")}: ${f.detail}\n- ${t("doc.label.findingImpact")}: ${f.impact}`
329
+ )
330
+ .join("\n\n")
331
+ : t("doc.label.noResolvedFindings");
332
+
333
+ return [
334
+ `# ${t("doc.header.findings", { projectName: control.meta.projectName })}`,
335
+ "",
336
+ `> ${t("doc.autogenerated")}`,
337
+ "",
338
+ `## ${t("doc.section.openFindings")}`,
339
+ "",
340
+ openLines,
341
+ "",
342
+ "---",
343
+ "",
344
+ `## ${t("doc.section.resolvedFindings")}`,
345
+ "",
346
+ resolvedLines,
347
+ ].join("\n");
348
+ }
349
+
350
+ /* ── doc sync ── */
351
+
352
+ function buildDocMap(control) {
353
+ return { taskPlan: renderTaskPlan(control), progress: renderProgress(control), findings: renderFindings(control) };
354
+ }
355
+
356
+ function getDocDrift(root, control) {
357
+ const docs = buildDocMap(control);
358
+ const docFiles = config.docFilePaths(root);
359
+ return Object.entries({ task_plan: [docFiles.taskPlan, docs.taskPlan], progress: [docFiles.progress, docs.progress], findings: [docFiles.findings, docs.findings] })
360
+ .filter(([, [filePath, expected]]) => {
361
+ if (!fs.existsSync(filePath)) return true;
362
+ return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n") !== `${expected}\n`;
363
+ })
364
+ .map(([name]) => name);
365
+ }
366
+
367
+ function syncDocs(root, control) {
368
+ const docs = buildDocMap(control);
369
+ const docFiles = config.docFilePaths(root);
370
+ writeText(docFiles.taskPlan, `${docs.taskPlan}\n`);
371
+ writeText(docFiles.progress, `${docs.progress}\n`);
372
+ writeText(docFiles.findings, `${docs.findings}\n`);
373
+ }
374
+
375
+ /* ── task management ── */
376
+
377
+ function updateTask(root, control, action, taskId, note) {
378
+ const task = control.tasks.find((item) => item.id === taskId);
379
+ if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
380
+
381
+ const actionMap = { start: "in_progress", review: "in_review", complete: "completed", done: "completed", block: "blocked", pending: "pending", cancel: "cancelled" };
382
+
383
+ if (action === "note") {
384
+ task.history = task.history || [];
385
+ task.history.push({ at: nowIso(), action: "note", note: note || t("cli.emptyNote") });
386
+ } else {
387
+ const nextStatus = actionMap[action];
388
+ if (!nextStatus) throw new Error(t("cli.actionNotSupported", { action }));
389
+
390
+ task.status = nextStatus;
391
+ if (nextStatus === "blocked") {
392
+ task.blocker = note || task.blocker || t("cli.undocumentedBlocker");
393
+ } else {
394
+ delete task.blocker;
395
+ }
396
+ task.history = task.history || [];
397
+ task.history.push({ at: nowIso(), action, note: note || "" });
398
+ }
399
+
400
+ config.saveControl(root, control);
401
+ syncDocs(root, control);
402
+ refreshRepoRuntime(root, { quiet: true });
403
+ }
404
+
405
+ /* ── CLI commands ── */
406
+
407
+ function initLocale(root) {
408
+ try {
409
+ const control = config.loadControl(root);
410
+ setLocale(config.getLocale(control));
411
+ } catch (_err) {
412
+ setLocale("es");
413
+ }
414
+ }
415
+
416
+ function cmdStatus(root) {
417
+ initLocale(root);
418
+ const control = config.loadControl(root);
419
+ const state = derive(control);
420
+ const phases = config.getPhases(control);
421
+ const repo = refreshRepoRuntime(root, { quiet: true });
422
+ const drift = getDocDrift(root, control);
423
+
424
+ console.log(t("cli.status.title", { projectName: control.meta.projectName }));
425
+ console.log(t("cli.status.focus", { focus: control.meta.currentFocus }));
426
+ console.log(t("cli.status.activePhase", { phaseId: state.activePhase.id, phaseLabel: state.activePhase.label }));
427
+ console.log(t("cli.status.tasks", {
428
+ completed: state.totals.completed, inProgress: state.totals.inProgress,
429
+ inReview: state.totals.inReview, pending: state.totals.pending, blocked: state.totals.blocked,
430
+ }));
431
+ console.log("");
432
+ console.log(t("cli.status.readyTasks"));
433
+ if (state.readyTasks.length) {
434
+ state.readyTasks.slice(0, 5).forEach((task) => console.log(renderTask(task, phases)));
435
+ } else {
436
+ console.log(t("cli.status.noReadyTasks"));
437
+ }
438
+ console.log("");
439
+ console.log(t("cli.status.blockers"));
440
+ if (state.blockers.length) {
441
+ state.blockers.forEach((task) => console.log(renderTask(task, phases)));
442
+ } else {
443
+ console.log(t("cli.status.noBlockers"));
444
+ }
445
+ console.log("");
446
+ console.log(t("cli.status.decisions"));
447
+ if ((control.decisionsPending || []).length) {
448
+ control.decisionsPending.forEach((d) => console.log(`- ${d.title} (${d.owner}) — ${d.impact}`));
449
+ } else {
450
+ console.log(`- ${t("cli.status.noDecisions")}`);
451
+ }
452
+ console.log("");
453
+ console.log(t("cli.status.repo"));
454
+ const treeStatus = repo.clean
455
+ ? t("cli.status.treeClean")
456
+ : t("cli.status.treeDirty", { staged: repo.staged, unstaged: repo.unstaged, untracked: repo.untracked });
457
+ console.log(`- ${t("cli.status.branch", { branch: repo.branch, treeStatus })}`);
458
+ if (repo.lastCommit) {
459
+ console.log(`- ${t("cli.status.lastCommit", { hash: repo.lastCommit.shortHash, subject: repo.lastCommit.subject })}`);
460
+ }
461
+ if (repo.ahead || repo.behind) {
462
+ console.log(`- ${t("cli.status.divergence", { ahead: repo.ahead, behind: repo.behind })}`);
463
+ }
464
+ console.log(`- ${t("cli.status.runtime", { path: path.relative(root, config.runtimeFilePath(root)) })}`);
465
+ console.log("");
466
+ const syncStatus = drift.length
467
+ ? t("cli.status.docsSyncedNo", { files: drift.join(", ") })
468
+ : t("cli.status.docsSyncedYes");
469
+ console.log(t("cli.status.docsSynced", { status: syncStatus }));
470
+ }
471
+
472
+ function cmdNext(root) {
473
+ initLocale(root);
474
+ const control = config.loadControl(root);
475
+ const ready = derive(control).readyTasks.slice(0, 10);
476
+ if (!ready.length) {
477
+ console.log(t("cli.noReadyTasks"));
478
+ return;
479
+ }
480
+ ready.forEach((task, i) => {
481
+ console.log(`${i + 1}. ${task.title}`);
482
+ console.log(` id: ${task.id}`);
483
+ console.log(` ${t("cli.next.phase")}: ${task.phase} · ${t("cli.next.priority")}: ${task.priority} · ${t("cli.next.stream")}: ${task.stream}`);
484
+ if (task.summary) console.log(` ${t("cli.next.summary")}: ${task.summary}`);
485
+ });
486
+ }
487
+
488
+ function cmdSync(root) {
489
+ initLocale(root);
490
+ const control = config.loadControl(root);
491
+ syncDocs(root, control);
492
+ refreshRepoRuntime(root, { quiet: true });
493
+ console.log(t("cli.docsSynced"));
494
+ }
495
+
496
+ function cmdRefreshRepo(root, args) {
497
+ refreshRepoRuntime(root, { quiet: (args || []).includes("--quiet") });
498
+ }
499
+
500
+ function cmdTask(root, args) {
501
+ initLocale(root);
502
+ const [action, taskId, ...noteParts] = args || [];
503
+ if (!action || !taskId) throw new Error(t("cli.mustProvideActionAndId"));
504
+ const control = config.loadControl(root);
505
+ updateTask(root, control, action, taskId, noteParts.join(" ").trim());
506
+ console.log(t("cli.taskUpdated", { taskId, action }));
507
+ }
508
+
509
+ function cmdInstallHooks(root) {
510
+ initLocale(root);
511
+ const result = spawnSync("git", ["config", "core.hooksPath", ".githooks"], { cwd: root, encoding: "utf8" });
512
+ if (result.error || result.status !== 0) throw new Error(t("cli.hooksError"));
513
+ console.log(t("cli.hooksInstalled"));
514
+ }
515
+
516
+ function cmdHelp() {
517
+ console.log("trackops — Operational project control");
518
+ console.log("");
519
+ console.log("Usage: trackops <command> [args]");
520
+ console.log("");
521
+ console.log("Commands:");
522
+ console.log(" init [--with-opera] [--locale es|en] [--name \"...\"]");
523
+ console.log(" Initialize trackops in the current directory.");
524
+ console.log(" status");
525
+ console.log(" Show project state: focus, active phase, ready tasks, blockers, repo.");
526
+ console.log(" next");
527
+ console.log(" Prioritized queue of next executable tasks.");
528
+ console.log(" sync");
529
+ console.log(" Regenerate task_plan.md, progress.md, findings.md from project_control.json.");
530
+ console.log(" dashboard");
531
+ console.log(" Launch local web dashboard.");
532
+ console.log(" refresh-repo [--quiet]");
533
+ console.log(" Update .tmp/project-control-runtime.json with repo state.");
534
+ console.log(" install-hooks");
535
+ console.log(" Configure git core.hooksPath to use .githooks.");
536
+ console.log(" register");
537
+ console.log(" Register current project in the multi-project portfolio.");
538
+ console.log(" projects");
539
+ console.log(" List registered projects.");
540
+ console.log(" task <action> <id> [note]");
541
+ console.log(" Actions: start, review, complete, block, pending, cancel, note.");
542
+ console.log(" opera install|status|configure|upgrade");
543
+ console.log(" Manage OPERA methodology.");
544
+ console.log(" skill install|list|remove|catalog <name>");
545
+ console.log(" Manage skills.");
546
+ console.log(" help");
547
+ console.log(" Show this help.");
548
+ }
549
+
550
+ /* ── project-scoped API (used by server) ── */
551
+
552
+ function forProject(root) {
553
+ initLocale(root);
554
+ return {
555
+ loadControl: () => config.loadControl(root),
556
+ saveControl: (ctrl) => config.saveControl(root, ctrl),
557
+ derive,
558
+ buildDocMap,
559
+ getDocDrift: (ctrl) => getDocDrift(root, ctrl),
560
+ syncDocs: (ctrl) => syncDocs(root, ctrl),
561
+ updateTask: (ctrl, action, id, note) => updateTask(root, ctrl, action, id, note),
562
+ getRepoSnapshot: () => getRepoSnapshot(root),
563
+ refreshRepoRuntime: (opts) => refreshRepoRuntime(root, opts),
564
+ getPhases: (ctrl) => config.getPhases(ctrl),
565
+ getLocale: (ctrl) => config.getLocale(ctrl),
566
+ statusLabel,
567
+ };
568
+ }
569
+
570
+ module.exports = {
571
+ buildDocMap, derive, getDocDrift, getRepoSnapshot, refreshRepoRuntime, syncDocs, updateTask,
572
+ forProject, statusLabel, renderTask, getPhaseInfo,
573
+ cmdStatus, cmdNext, cmdSync, cmdRefreshRepo, cmdTask, cmdInstallHooks, cmdHelp,
574
+ PRIORITY_ORDER, STATUS_ORDER, STATUS_ICONS, CHECK_ICONS,
575
+ };
package/lib/i18n.js ADDED
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require("path");
4
+
5
+ const LOCALES_DIR = path.join(__dirname, "..", "locales");
6
+
7
+ let currentLocale = "es";
8
+ let currentMessages = null;
9
+ let fallbackMessages = null;
10
+
11
+ function loadMessages(localeId) {
12
+ try {
13
+ return require(path.join(LOCALES_DIR, `${localeId}.json`));
14
+ } catch (_error) {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function ensureLoaded() {
20
+ if (currentMessages) return;
21
+ currentMessages = loadMessages(currentLocale) || {};
22
+ if (currentLocale !== "es") {
23
+ fallbackMessages = loadMessages("es") || {};
24
+ } else {
25
+ fallbackMessages = currentMessages;
26
+ }
27
+ }
28
+
29
+ function setLocale(localeId) {
30
+ currentLocale = localeId || "es";
31
+ currentMessages = null;
32
+ fallbackMessages = null;
33
+ }
34
+
35
+ function getLocale() {
36
+ return currentLocale;
37
+ }
38
+
39
+ function t(key, params) {
40
+ ensureLoaded();
41
+ let message =
42
+ currentMessages[key] ||
43
+ (fallbackMessages && fallbackMessages[key]) ||
44
+ key;
45
+ if (params) {
46
+ message = message.replace(/\{(\w+)\}/g, (match, paramKey) =>
47
+ params[paramKey] !== undefined ? String(params[paramKey]) : match
48
+ );
49
+ }
50
+ return message;
51
+ }
52
+
53
+ module.exports = { setLocale, getLocale, t };