trackops 2.0.5 → 2.0.6

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 CHANGED
@@ -4,21 +4,22 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const { spawnSync } = require("child_process");
6
6
 
7
- const config = require("./config");
8
- const env = require("./env");
9
- const { t, setLocale, getLocale } = require("./i18n");
10
-
11
- const PRIORITY_ORDER = ["P0", "P1", "P2", "P3"];
12
- const STATUS_ORDER = ["in_progress", "in_review", "pending", "blocked", "completed", "cancelled"];
13
- const STATUS_ICONS = {
14
- pending: "\u23F3",
15
- in_progress: "\uD83D\uDEA7",
16
- in_review: "\uD83D\uDC40",
17
- blocked: "\u26D4",
18
- completed: "\u2705",
19
- cancelled: "\uD83D\uDDD1\uFE0F",
20
- };
21
- const CHECK_ICONS = {
7
+ const config = require("./config");
8
+ const env = require("./env");
9
+ const { t, setLocale, getLocale } = require("./i18n");
10
+ const fmt = require("./cli-format");
11
+
12
+ const PRIORITY_ORDER = ["P0", "P1", "P2", "P3"];
13
+ const STATUS_ORDER = ["in_progress", "in_review", "pending", "blocked", "completed", "cancelled"];
14
+ const STATUS_ICONS = {
15
+ pending: "\u23F3",
16
+ in_progress: "\uD83D\uDEA7",
17
+ in_review: "\uD83D\uDC40",
18
+ blocked: "\u26D4",
19
+ completed: "\u2705",
20
+ cancelled: "\uD83D\uDDD1\uFE0F",
21
+ };
22
+ const CHECK_ICONS = {
22
23
  pass: "\u2705",
23
24
  warn: "\u26A0\uFE0F",
24
25
  fail: "\u274C",
@@ -39,29 +40,79 @@ function nowIso() {
39
40
  return new Date().toISOString();
40
41
  }
41
42
 
42
- function git(args, root) {
43
- const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
44
- if (result.error || result.status !== 0) return null;
45
- return result.stdout.replace(/\s+$/, "");
46
- }
47
-
48
- function statusLabel(status) {
49
- return t(`status.${status}`);
50
- }
51
-
52
- /* ── repo snapshot ── */
53
-
54
- function getRepoSnapshot(contextOrRoot) {
55
- const context = config.ensureContext(contextOrRoot);
56
- const repoRoot = context.workspaceRoot;
57
- const branch = git(["branch", "--show-current"], repoRoot) || "detached";
58
- const status = git(["status", "--short"], repoRoot) || "";
59
- const lines = status.split(/\r?\n/).filter(Boolean);
60
- const lastCommitRaw = git(["log", "-1", "--pretty=format:%H%n%cs%n%s"], repoRoot);
61
- const divergenceRaw = git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], repoRoot);
62
-
63
- let staged = 0;
64
- let unstaged = 0;
43
+ function git(args, root) {
44
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
45
+ if (result.error || result.status !== 0) return null;
46
+ return result.stdout.replace(/\s+$/, "");
47
+ }
48
+
49
+ function gitResult(args, root) {
50
+ const result = spawnSync("git", args, { cwd: root, encoding: "utf8" });
51
+ return {
52
+ ok: !result.error && result.status === 0,
53
+ status: result.status,
54
+ stdout: String(result.stdout || "").replace(/\s+$/, ""),
55
+ stderr: String(result.stderr || "").replace(/\s+$/, ""),
56
+ error: result.error || null,
57
+ };
58
+ }
59
+
60
+ function statusLabel(status) {
61
+ return t(`status.${status}`);
62
+ }
63
+
64
+ function formatBootstrapStatus(status) {
65
+ return t(`bootstrap.status.${String(status || "").trim()}`) || status || t("locale.none");
66
+ }
67
+
68
+ function formatBootstrapMode(mode) {
69
+ return t(`bootstrap.mode.${String(mode || "").trim()}`) || mode || t("locale.none");
70
+ }
71
+
72
+ function formatBootstrapReason(reason) {
73
+ return t(`bootstrap.reason.${String(reason || "").trim()}`) || reason || t("locale.none");
74
+ }
75
+
76
+ function listOrNone(items) {
77
+ const values = (items || []).filter(Boolean);
78
+ return values.length ? values.join(", ") : t("locale.none");
79
+ }
80
+
81
+ /* ── repo snapshot ── */
82
+
83
+ function getRepoSnapshot(contextOrRoot) {
84
+ const context = config.ensureContext(contextOrRoot);
85
+ const repoRoot = context.workspaceRoot;
86
+ const insideWorkTree = gitResult(["rev-parse", "--is-inside-work-tree"], repoRoot);
87
+ if (!insideWorkTree.ok || insideWorkTree.stdout.trim() !== "true") {
88
+ return {
89
+ generatedAt: nowIso(),
90
+ available: false,
91
+ state: "not_initialized",
92
+ branch: null,
93
+ clean: null,
94
+ staged: 0,
95
+ unstaged: 0,
96
+ untracked: 0,
97
+ ahead: 0,
98
+ behind: 0,
99
+ hasUpstream: false,
100
+ lastCommit: null,
101
+ };
102
+ }
103
+
104
+ const branchResult = gitResult(["branch", "--show-current"], repoRoot);
105
+ const branch = branchResult.ok ? branchResult.stdout.trim() : "";
106
+ const status = git(["status", "--short"], repoRoot) || "";
107
+ const lines = status.split(/\r?\n/).filter(Boolean);
108
+ const lastCommitRaw = git(["log", "-1", "--pretty=format:%H%n%cs%n%s"], repoRoot);
109
+ const upstream = gitResult(["rev-parse", "--abbrev-ref", "@{upstream}"], repoRoot);
110
+ const divergenceRaw = upstream.ok
111
+ ? git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"], repoRoot)
112
+ : null;
113
+
114
+ let staged = 0;
115
+ let unstaged = 0;
65
116
  let untracked = 0;
66
117
 
67
118
  lines.forEach((line) => {
@@ -78,14 +129,27 @@ function getRepoSnapshot(contextOrRoot) {
78
129
 
79
130
  let ahead = 0;
80
131
  let behind = 0;
81
- if (divergenceRaw) {
82
- const [left, right] = divergenceRaw.split(/\s+/).map(Number);
83
- behind = Number.isFinite(left) ? left : 0;
84
- ahead = Number.isFinite(right) ? right : 0;
85
- }
86
-
87
- return { generatedAt: nowIso(), branch, clean: lines.length === 0, staged, unstaged, untracked, ahead, behind, lastCommit };
88
- }
132
+ if (divergenceRaw) {
133
+ const [left, right] = divergenceRaw.split(/\s+/).map(Number);
134
+ behind = Number.isFinite(left) ? left : 0;
135
+ ahead = Number.isFinite(right) ? right : 0;
136
+ }
137
+
138
+ return {
139
+ generatedAt: nowIso(),
140
+ available: true,
141
+ state: branch ? "ready" : "detached",
142
+ branch: branch || null,
143
+ clean: lines.length === 0,
144
+ staged,
145
+ unstaged,
146
+ untracked,
147
+ ahead,
148
+ behind,
149
+ hasUpstream: upstream.ok,
150
+ lastCommit,
151
+ };
152
+ }
89
153
 
90
154
  function refreshRepoRuntime(root, options = {}) {
91
155
  const context = config.ensureContext(root);
@@ -115,16 +179,42 @@ function compareTasks(a, b, phases) {
115
179
  return a.title.localeCompare(b.title, getLocale());
116
180
  }
117
181
 
182
+ function detectCircularDeps(tasks) {
183
+ const visited = new Set();
184
+ const inStack = new Set();
185
+ const cycles = [];
186
+ function dfs(id) {
187
+ if (inStack.has(id)) { cycles.push(id); return; }
188
+ if (visited.has(id)) return;
189
+ visited.add(id);
190
+ inStack.add(id);
191
+ const task = tasks.find((t) => t.id === id);
192
+ if (task) (task.dependsOn || []).forEach((dep) => dfs(dep));
193
+ inStack.delete(id);
194
+ }
195
+ tasks.forEach((t) => dfs(t.id));
196
+ return cycles;
197
+ }
198
+
118
199
  function derive(control) {
119
200
  const phases = config.getPhases(control);
120
201
  const tasks = [...control.tasks].sort((a, b) => compareTasks(a, b, phases));
121
202
  const completedIds = new Set(tasks.filter((t) => t.status === "completed").map((t) => t.id));
203
+ const allIds = new Set(tasks.map((t) => t.id));
122
204
  const closedStatuses = new Set(["completed", "cancelled"]);
205
+ const phantomDeps = [];
123
206
 
124
207
  const readyTasks = tasks
125
208
  .filter((task) => {
126
209
  if (task.status !== "pending") return false;
127
- return (task.dependsOn || []).every((dep) => completedIds.has(dep));
210
+ const validDeps = (task.dependsOn || []).filter((dep) => {
211
+ if (!allIds.has(dep)) {
212
+ phantomDeps.push({ taskId: task.id, missingDep: dep });
213
+ return false;
214
+ }
215
+ return true;
216
+ });
217
+ return validDeps.every((dep) => completedIds.has(dep));
128
218
  })
129
219
  .sort((a, b) => {
130
220
  const focusPhase = control.meta.focusPhase || "";
@@ -139,10 +229,11 @@ function derive(control) {
139
229
  const reviewTasks = tasks.filter((t) => t.status === "in_review");
140
230
  const openTasks = tasks.filter((t) => !["completed", "cancelled"].includes(t.status));
141
231
  const requiredOpenTasks = tasks.filter((t) => t.required !== false && !["completed", "cancelled"].includes(t.status));
232
+ const projectCompleted = requiredOpenTasks.length === 0 && tasks.length > 0;
142
233
 
143
234
  const activePhase =
144
235
  phases.find((p) => requiredOpenTasks.some((t) => t.phase === p.id)) ||
145
- phases[phases.length - 1];
236
+ (projectCompleted ? phases[phases.length - 1] : phases[0]);
146
237
 
147
238
  const phaseStats = phases.map((phase) => {
148
239
  const phaseTasks = tasks.filter((t) => t.phase === phase.id && t.required !== false);
@@ -152,9 +243,13 @@ function derive(control) {
152
243
  });
153
244
 
154
245
  const nextTask = activeTasks[0] || readyTasks[0] || blockers[0] || openTasks[0] || null;
246
+ const circularDeps = detectCircularDeps(tasks);
155
247
 
156
248
  return {
157
249
  tasks, blockers, activeTasks, reviewTasks, readyTasks, nextTask, activePhase, phaseStats,
250
+ projectCompleted,
251
+ circularDeps,
252
+ phantomDeps,
158
253
  openFindings: (control.findings || []).filter((f) => f.status === "open"),
159
254
  resolvedFindings: (control.findings || []).filter((f) => f.status === "resolved"),
160
255
  totals: {
@@ -171,12 +266,13 @@ function derive(control) {
171
266
 
172
267
  /* ── render ── */
173
268
 
174
- function renderTask(task, phases) {
175
- const phase = getPhaseInfo(task.phase, phases);
176
- const detail = task.blocker || task.summary || "";
177
- const detailSuffix = detail ? ` — ${detail}` : "";
178
- return `- ${STATUS_ICONS[task.status]} \`${task.id}\` [${task.priority}] ${task.title} (${phase.id} · ${phase.label} · ${task.stream})${detailSuffix}`;
179
- }
269
+ function renderTask(task, phases, options = {}) {
270
+ const phase = getPhaseInfo(task.phase, phases);
271
+ const detail = task.blocker || task.summary || "";
272
+ const detailSuffix = detail ? ` — ${detail}` : "";
273
+ const token = options.cli ? fmt.statusToken(task.status) : STATUS_ICONS[task.status];
274
+ return `- ${token} \`${task.id}\` [${task.priority}] ${task.title} (${phase.id} · ${phase.label} · ${task.stream})${detailSuffix}`;
275
+ }
180
276
 
181
277
  function renderTaskPlan(control) {
182
278
  const phases = config.getPhases(control);
@@ -284,7 +380,7 @@ function renderProgress(control) {
284
380
  `## ${t("doc.section.currentState")}`,
285
381
  `- ${t("doc.label.phase")}: ${state.activePhase.label} (${state.activePhase.index}/${phases.length})`,
286
382
  `- ${t("doc.label.blockers")}: ${blockersLabel}`,
287
- `- ${t("doc.label.lastTest")}: ${CHECK_ICONS[lastTest.status]} ${lastTest.date || t("status.pending")}${lastTest.note ? ` — ${lastTest.note}` : ""}`,
383
+ `- ${t("doc.label.lastTest")}: ${CHECK_ICONS[lastTest.status]} ${lastTest.date || t("status.pending")}${lastTest.note ? ` — ${lastTest.note}` : ""}`,
288
384
  `- ${t("doc.label.nextStepShort")}: ${nextStep}`,
289
385
  `- ${t("doc.label.lastUpdate")}: ${(control.meta.updatedAt || "").slice(0, 10)}`,
290
386
  "",
@@ -371,13 +467,26 @@ function getDocDrift(root, control) {
371
467
  .map(([name]) => name);
372
468
  }
373
469
 
374
- function syncDocs(root, control) {
470
+ function syncDocs(root, control, options = {}) {
375
471
  const context = config.ensureContext(root);
376
472
  const docs = buildDocMap(control);
377
473
  const docFiles = config.docFilePaths(context);
378
- writeText(docFiles.taskPlan, `${docs.taskPlan}\n`);
379
- writeText(docFiles.progress, `${docs.progress}\n`);
380
- writeText(docFiles.findings, `${docs.findings}\n`);
474
+ const pairs = [
475
+ [docFiles.taskPlan, docs.taskPlan],
476
+ [docFiles.progress, docs.progress],
477
+ [docFiles.findings, docs.findings],
478
+ ];
479
+ for (const [filePath, content] of pairs) {
480
+ if (!options.force && fs.existsSync(filePath)) {
481
+ const existing = fs.readFileSync(filePath, "utf8");
482
+ const isAutoGenerated = existing.includes(t("doc.autogenerated"));
483
+ if (!isAutoGenerated && existing.trim()) {
484
+ console.log(t("control.docsOverwriteWarning", { file: path.basename(filePath) }));
485
+ continue;
486
+ }
487
+ }
488
+ writeText(filePath, `${content}\n`);
489
+ }
381
490
  }
382
491
 
383
492
  /* ── task management ── */
@@ -396,6 +505,10 @@ function updateTask(root, control, action, taskId, note) {
396
505
  const nextStatus = actionMap[action];
397
506
  if (!nextStatus) throw new Error(t("cli.actionNotSupported", { action }));
398
507
 
508
+ if (task.status === nextStatus) {
509
+ console.log(t("control.taskAlreadyStatus", { taskId, status: nextStatus }));
510
+ return;
511
+ }
399
512
  task.status = nextStatus;
400
513
  if (nextStatus === "blocked") {
401
514
  task.blocker = note || task.blocker || t("cli.undocumentedBlocker");
@@ -432,66 +545,77 @@ function cmdStatus(root) {
432
545
  const drift = getDocDrift(context, control);
433
546
  const envAudit = env.auditEnvironment(context, control);
434
547
 
435
- console.log(t("cli.status.title", { projectName: control.meta.projectName }));
436
- console.log(t("cli.status.focus", { focus: control.meta.currentFocus }));
437
- console.log(t("cli.status.activePhase", { phaseId: state.activePhase.id, phaseLabel: state.activePhase.label }));
438
- console.log(`Layout: ${context.layout} | Workspace: ${context.workspaceRoot}`);
439
- if (context.layout === "split") {
440
- console.log(`App: ${context.appRoot}`);
441
- console.log(`Ops: ${context.opsRoot}`);
442
- }
443
- if (control.meta?.opera?.bootstrap?.status) {
444
- console.log(t("cli.status.bootstrap", { status: control.meta.opera.bootstrap.status, locale: config.getLocale(control) }));
445
- if (control.meta.opera.bootstrap.mode) {
446
- console.log(`Bootstrap mode: ${control.meta.opera.bootstrap.mode}`);
447
- }
448
- if (control.meta.opera.bootstrap.routeReason) {
449
- console.log(`Bootstrap route: ${control.meta.opera.bootstrap.routeReason}`);
450
- }
451
- }
548
+ console.log(t("cli.status.title", { projectName: control.meta.projectName }));
549
+ console.log(t("cli.status.focus", { focus: control.meta.currentFocus }));
550
+ console.log(t("cli.status.activePhase", { phaseId: state.activePhase.id, phaseLabel: state.activePhase.label }));
551
+ console.log(t("cli.status.layout", { layout: context.layout, workspace: context.workspaceRoot }));
552
+ if (context.layout === "split") {
553
+ console.log(t("cli.status.appRoot", { path: context.appRoot }));
554
+ console.log(t("cli.status.opsRoot", { path: context.opsRoot }));
555
+ }
556
+ if (control.meta?.opera?.bootstrap?.status) {
557
+ console.log(t("cli.status.bootstrap", {
558
+ status: formatBootstrapStatus(control.meta.opera.bootstrap.status),
559
+ locale: config.getLocale(control),
560
+ }));
561
+ if (control.meta.opera.bootstrap.mode) {
562
+ console.log(t("cli.status.bootstrapMode", { value: formatBootstrapMode(control.meta.opera.bootstrap.mode) }));
563
+ }
564
+ if (control.meta.opera.bootstrap.routeReason) {
565
+ console.log(t("cli.status.bootstrapReason", { value: formatBootstrapReason(control.meta.opera.bootstrap.routeReason) }));
566
+ }
567
+ }
452
568
  console.log(t("cli.status.tasks", {
453
569
  completed: state.totals.completed, inProgress: state.totals.inProgress,
454
570
  inReview: state.totals.inReview, pending: state.totals.pending, blocked: state.totals.blocked,
455
571
  }));
456
572
  console.log("");
457
- console.log(t("cli.status.readyTasks"));
458
- if (state.readyTasks.length) {
459
- state.readyTasks.slice(0, 5).forEach((task) => console.log(renderTask(task, phases)));
460
- } else {
461
- console.log(t("cli.status.noReadyTasks"));
462
- }
573
+ console.log(t("cli.status.readyTasks"));
574
+ if (state.readyTasks.length) {
575
+ state.readyTasks.slice(0, 5).forEach((task) => console.log(renderTask(task, phases, { cli: true })));
576
+ } else {
577
+ console.log(t("cli.status.noReadyTasks"));
578
+ }
463
579
  console.log("");
464
- console.log(t("cli.status.blockers"));
465
- if (state.blockers.length) {
466
- state.blockers.forEach((task) => console.log(renderTask(task, phases)));
467
- } else {
468
- console.log(t("cli.status.noBlockers"));
469
- }
580
+ console.log(t("cli.status.blockers"));
581
+ if (state.blockers.length) {
582
+ state.blockers.forEach((task) => console.log(renderTask(task, phases, { cli: true })));
583
+ } else {
584
+ console.log(t("cli.status.noBlockers"));
585
+ }
470
586
  console.log("");
471
587
  console.log(t("cli.status.decisions"));
472
588
  if ((control.decisionsPending || []).length) {
473
589
  control.decisionsPending.forEach((d) => console.log(`- ${d.title} (${d.owner}) — ${d.impact}`));
474
590
  } else {
475
591
  console.log(`- ${t("cli.status.noDecisions")}`);
476
- }
477
- console.log("");
478
- console.log(t("cli.status.repo"));
479
- const treeStatus = repo.clean
480
- ? t("cli.status.treeClean")
481
- : t("cli.status.treeDirty", { staged: repo.staged, unstaged: repo.unstaged, untracked: repo.untracked });
482
- console.log(`- ${t("cli.status.branch", { branch: repo.branch, treeStatus })}`);
483
- if (repo.lastCommit) {
484
- console.log(`- ${t("cli.status.lastCommit", { hash: repo.lastCommit.shortHash, subject: repo.lastCommit.subject })}`);
485
- }
486
- if (repo.ahead || repo.behind) {
487
- console.log(`- ${t("cli.status.divergence", { ahead: repo.ahead, behind: repo.behind })}`);
488
- }
489
- console.log(`- ${t("cli.status.runtime", { path: path.relative(context.workspaceRoot, config.runtimeFilePath(context)) })}`);
490
- console.log(`- Env present: ${envAudit.presentKeys.length ? envAudit.presentKeys.join(", ") : "none"}`);
491
- console.log(`- Env missing: ${envAudit.missingKeys.length ? envAudit.missingKeys.join(", ") : "none"}`);
492
- console.log("");
493
- const syncStatus = drift.length
494
- ? t("cli.status.docsSyncedNo", { files: drift.join(", ") })
592
+ }
593
+ console.log("");
594
+ console.log(t("cli.status.repo"));
595
+ if (!repo.available) {
596
+ console.log(`- ${t("cli.status.gitState", { state: t("cli.status.gitNotInitialized") })}`);
597
+ console.log(`- ${t("cli.status.gitAction")}`);
598
+ } else {
599
+ const branchLabel = repo.state === "detached" ? t("cli.status.branchDetached") : repo.branch;
600
+ const treeStatus = repo.clean
601
+ ? t("cli.status.treeClean")
602
+ : t("cli.status.treeDirty", { staged: repo.staged, unstaged: repo.unstaged, untracked: repo.untracked });
603
+ console.log(`- ${t("cli.status.branch", { branch: branchLabel, treeStatus })}`);
604
+ if (repo.lastCommit) {
605
+ console.log(`- ${t("cli.status.lastCommit", { hash: repo.lastCommit.shortHash, subject: repo.lastCommit.subject })}`);
606
+ }
607
+ if (!repo.hasUpstream) {
608
+ console.log(`- ${t("cli.status.noUpstream")}`);
609
+ } else if (repo.ahead || repo.behind) {
610
+ console.log(`- ${t("cli.status.divergence", { ahead: repo.ahead, behind: repo.behind })}`);
611
+ }
612
+ }
613
+ console.log(`- ${t("cli.status.runtime", { path: path.relative(context.workspaceRoot, config.runtimeFilePath(context)) })}`);
614
+ console.log(`- ${t("cli.status.envPresent", { value: listOrNone(envAudit.presentKeys) })}`);
615
+ console.log(`- ${t("cli.status.envMissing", { value: listOrNone(envAudit.missingKeys) })}`);
616
+ console.log("");
617
+ const syncStatus = drift.length
618
+ ? t("cli.status.docsSyncedNo", { files: drift.join(", ") })
495
619
  : t("cli.status.docsSyncedYes");
496
620
  console.log(t("cli.status.docsSynced", { status: syncStatus }));
497
621
  }
@@ -500,9 +624,22 @@ function cmdNext(root) {
500
624
  const context = config.ensureContext(root);
501
625
  initLocale(context);
502
626
  const control = config.loadControl(context);
503
- const ready = derive(control).readyTasks.slice(0, 10);
627
+ const state = derive(control);
628
+ if (state.circularDeps.length) {
629
+ console.log(t("control.circularDependency", { taskIds: state.circularDeps.join(", ") }));
630
+ }
631
+ const ready = state.readyTasks.slice(0, 10);
504
632
  if (!ready.length) {
505
- console.log(t("cli.noReadyTasks"));
633
+ if (state.projectCompleted) {
634
+ console.log(t("cli.noReadyTasks.allDone"));
635
+ } else if (state.blockers.length) {
636
+ console.log(t("cli.noReadyTasks.blocked", { count: state.blockers.length }));
637
+ for (const task of state.blockers.slice(0, 5)) {
638
+ console.log(` - ${task.id}: ${task.blocker || task.title}`);
639
+ }
640
+ } else {
641
+ console.log(t("cli.noReadyTasks"));
642
+ }
506
643
  return;
507
644
  }
508
645
  ready.forEach((task, i) => {
@@ -513,12 +650,34 @@ function cmdNext(root) {
513
650
  });
514
651
  }
515
652
 
516
- function cmdSync(root) {
653
+ function cmdSync(root, args) {
517
654
  const context = config.ensureContext(root);
518
655
  initLocale(context);
519
656
  const control = config.loadControl(context);
657
+ if ((args || []).includes("--dry-run")) {
658
+ const drift = getDocDrift(context, control);
659
+ if (drift.length) {
660
+ console.log(t("cli.sync.dryRunWouldUpdate", { files: drift.join(", ") }));
661
+ } else {
662
+ console.log(t("cli.sync.dryRunInSync"));
663
+ }
664
+ return;
665
+ }
666
+ const state = derive(control);
667
+ if (state.activePhase && control.meta.focusPhase !== state.activePhase.id) {
668
+ control.meta.focusPhase = state.activePhase.id;
669
+ config.saveControl(context, control);
670
+ }
520
671
  env.syncEnvironment(context, control);
521
- syncDocs(context, control);
672
+ if (config.isOperaInstalled(control)) {
673
+ const bootstrap = require("./opera-bootstrap");
674
+ const result = bootstrap.revalidateContract(context, control);
675
+ if (result.changed) {
676
+ console.log(t("control.contractStale"));
677
+ }
678
+ }
679
+ const force = (args || []).includes("--force");
680
+ syncDocs(context, control, { force });
522
681
  refreshRepoRuntime(context, { quiet: true });
523
682
  console.log(t("cli.docsSynced"));
524
683
  }
@@ -593,11 +752,14 @@ function cmdHelp() {
593
752
  console.log(" skill install|list|remove|catalog <name>");
594
753
  console.log(` ${t("cli.help.skill.desc")}`);
595
754
  console.log(" help");
596
- console.log(` ${t("cli.help.help.desc")}`);
597
- console.log("");
598
- console.log(t("cli.help.globalWorkflow"));
599
- console.log(` ${t("cli.help.globalWorkflow.line1")}`);
600
- console.log(` ${t("cli.help.globalWorkflow.line2")}`);
755
+ console.log(` ${t("cli.help.help.desc")}`);
756
+ console.log("");
757
+ console.log(t("cli.help.globalFlags"));
758
+ console.log(` ${t("cli.help.globalFlags.line1")}`);
759
+ console.log("");
760
+ console.log(t("cli.help.globalWorkflow"));
761
+ console.log(` ${t("cli.help.globalWorkflow.line1")}`);
762
+ console.log(` ${t("cli.help.globalWorkflow.line2")}`);
601
763
  }
602
764
 
603
765
  /* ── project-scoped API (used by server) ── */
package/lib/env.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require("fs");
4
- const path = require("path");
5
- const config = require("./config");
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const config = require("./config");
6
+ const { t, setLocale } = require("./i18n");
6
7
 
7
8
  const SERVICE_ENV_KEYS = {
8
9
  OpenAI: ["OPENAI_API_KEY"],
@@ -184,7 +185,7 @@ function syncEnvironment(contextOrRoot, controlState, options = {}) {
184
185
  return auditEnvironment(context, control);
185
186
  }
186
187
 
187
- function auditEnvironment(contextOrRoot, controlState) {
188
+ function auditEnvironment(contextOrRoot, controlState) {
188
189
  const context = config.ensureContext(contextOrRoot);
189
190
  const control = controlState || config.loadControl(context);
190
191
  const envMeta = normalizeEnvironmentMeta(control, context);
@@ -206,30 +207,41 @@ function auditEnvironment(contextOrRoot, controlState) {
206
207
  presentKeys,
207
208
  missingKeys,
208
209
  lastAuditAt: envMeta.lastAuditAt,
209
- };
210
- }
211
-
212
- function cmdStatus(contextOrRoot) {
213
- const audit = auditEnvironment(contextOrRoot);
214
- console.log("Environment:");
215
- console.log(` Root .env: ${audit.files.rootEnv}`);
216
- console.log(` Example: ${audit.files.rootExample}`);
217
- console.log(` App bridge: ${audit.files.appBridge}`);
218
- console.log(` Bridge mode: ${audit.bridgeMode}`);
219
- console.log(` Required keys: ${audit.requiredKeys.length ? audit.requiredKeys.join(", ") : "none"}`);
220
- console.log(` Present: ${audit.presentKeys.length ? audit.presentKeys.join(", ") : "none"}`);
221
- console.log(` Missing: ${audit.missingKeys.length ? audit.missingKeys.join(", ") : "none"}`);
222
- }
223
-
224
- function cmdSync(contextOrRoot) {
225
- const context = config.ensureContext(contextOrRoot);
226
- const control = config.loadControl(context);
227
- const audit = syncEnvironment(context, control);
228
- console.log(`Environment synced at ${path.relative(context.workspaceRoot, context.env.rootFile)}`);
229
- if (audit.missingKeys.length) {
230
- console.log(`Missing required keys: ${audit.missingKeys.join(", ")}`);
231
- }
232
- }
210
+ };
211
+ }
212
+
213
+ function initLocale(contextOrRoot) {
214
+ try {
215
+ const control = config.loadControl(config.ensureContext(contextOrRoot));
216
+ setLocale(config.getLocale(control));
217
+ } catch (_error) {
218
+ setLocale("es");
219
+ }
220
+ }
221
+
222
+ function cmdStatus(contextOrRoot) {
223
+ initLocale(contextOrRoot);
224
+ const audit = auditEnvironment(contextOrRoot);
225
+ console.log(t("env.status.title"));
226
+ console.log(t("env.status.rootEnv", { path: audit.files.rootEnv }));
227
+ console.log(t("env.status.example", { path: audit.files.rootExample }));
228
+ console.log(t("env.status.appBridge", { path: audit.files.appBridge }));
229
+ console.log(t("env.status.bridgeMode", { value: audit.bridgeMode }));
230
+ console.log(t("env.status.required", { value: audit.requiredKeys.length ? audit.requiredKeys.join(", ") : t("locale.none") }));
231
+ console.log(t("env.status.present", { value: audit.presentKeys.length ? audit.presentKeys.join(", ") : t("locale.none") }));
232
+ console.log(t("env.status.missing", { value: audit.missingKeys.length ? audit.missingKeys.join(", ") : t("locale.none") }));
233
+ }
234
+
235
+ function cmdSync(contextOrRoot) {
236
+ initLocale(contextOrRoot);
237
+ const context = config.ensureContext(contextOrRoot);
238
+ const control = config.loadControl(context);
239
+ const audit = syncEnvironment(context, control);
240
+ console.log(t("env.sync.updated", { path: path.relative(context.workspaceRoot, context.env.rootFile) }));
241
+ if (audit.missingKeys.length) {
242
+ console.log(t("env.sync.missing", { value: audit.missingKeys.join(", ") }));
243
+ }
244
+ }
233
245
 
234
246
  module.exports = {
235
247
  SERVICE_ENV_KEYS,
package/lib/i18n.js CHANGED
@@ -38,10 +38,11 @@ function getLocale() {
38
38
 
39
39
  function t(key, params) {
40
40
  ensureLoaded();
41
- let message =
42
- currentMessages[key] ||
43
- (fallbackMessages && fallbackMessages[key]) ||
44
- key;
41
+ const found = currentMessages[key] || (fallbackMessages && fallbackMessages[key]);
42
+ let message = found || key;
43
+ if (!found && process.env.NODE_ENV !== "production" && process.env.TRACKOPS_DEBUG) {
44
+ process.stderr.write(`[i18n] Missing key: ${key}\n`);
45
+ }
45
46
  if (params) {
46
47
  message = message.replace(/\{(\w+)\}/g, (match, paramKey) =>
47
48
  params[paramKey] !== undefined ? String(params[paramKey]) : match