trackops 2.0.4 → 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.
Files changed (92) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +660 -575
  3. package/bin/trackops.js +127 -106
  4. package/lib/cli-format.js +118 -0
  5. package/lib/config.js +352 -326
  6. package/lib/control.js +408 -246
  7. package/lib/env.js +234 -222
  8. package/lib/i18n.js +5 -4
  9. package/lib/init.js +390 -282
  10. package/lib/locale.js +41 -41
  11. package/lib/opera-bootstrap.js +1066 -880
  12. package/lib/opera.js +615 -444
  13. package/lib/preferences.js +74 -74
  14. package/lib/registry.js +214 -214
  15. package/lib/release.js +56 -56
  16. package/lib/runtime-state.js +144 -144
  17. package/lib/skills.js +114 -89
  18. package/lib/workspace.js +259 -248
  19. package/locales/en.json +311 -167
  20. package/locales/es.json +314 -170
  21. package/package.json +61 -58
  22. package/scripts/postinstall-locale.js +21 -21
  23. package/scripts/skills-marketplace-smoke.js +124 -124
  24. package/scripts/smoke-tests.js +563 -517
  25. package/scripts/sync-skill-version.js +21 -21
  26. package/scripts/validate-skill.js +103 -103
  27. package/skills/trackops/SKILL.md +126 -122
  28. package/skills/trackops/agents/openai.yaml +7 -7
  29. package/skills/trackops/locales/en/SKILL.md +126 -122
  30. package/skills/trackops/locales/en/references/activation.md +94 -90
  31. package/skills/trackops/locales/en/references/troubleshooting.md +73 -67
  32. package/skills/trackops/locales/en/references/workflow.md +55 -32
  33. package/skills/trackops/references/activation.md +94 -90
  34. package/skills/trackops/references/troubleshooting.md +73 -67
  35. package/skills/trackops/references/workflow.md +55 -32
  36. package/skills/trackops/skill.json +29 -29
  37. package/templates/hooks/post-checkout +2 -2
  38. package/templates/hooks/post-commit +2 -2
  39. package/templates/hooks/post-merge +2 -2
  40. package/templates/opera/agent.md +28 -27
  41. package/templates/opera/architecture/dependency-graph.md +24 -24
  42. package/templates/opera/architecture/runtime-automation.md +24 -24
  43. package/templates/opera/architecture/runtime-operations.md +34 -34
  44. package/templates/opera/en/agent.md +22 -21
  45. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  46. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  47. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  48. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  49. package/templates/opera/en/reviews/integration-audit.md +18 -18
  50. package/templates/opera/en/router.md +24 -19
  51. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  52. package/templates/opera/references/opera-cycle.md +193 -193
  53. package/templates/opera/registry.md +28 -28
  54. package/templates/opera/reviews/delivery-audit.md +18 -18
  55. package/templates/opera/reviews/integration-audit.md +18 -18
  56. package/templates/opera/router.md +54 -49
  57. package/templates/skills/changelog-updater/SKILL.md +69 -69
  58. package/templates/skills/commiter/SKILL.md +99 -99
  59. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  60. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  61. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  62. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  63. package/templates/skills/opera-skill/SKILL.md +279 -0
  64. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  65. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  66. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  67. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  68. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  69. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  70. package/ui/css/base.css +284 -284
  71. package/ui/css/charts.css +425 -425
  72. package/ui/css/components.css +1107 -1107
  73. package/ui/css/onboarding.css +133 -133
  74. package/ui/css/terminal.css +125 -125
  75. package/ui/css/timeline.css +58 -58
  76. package/ui/css/tokens.css +284 -284
  77. package/ui/favicon.svg +5 -5
  78. package/ui/index.html +99 -99
  79. package/ui/js/charts.js +526 -526
  80. package/ui/js/console-logger.js +172 -172
  81. package/ui/js/filters.js +247 -247
  82. package/ui/js/icons.js +129 -129
  83. package/ui/js/keyboard.js +229 -229
  84. package/ui/js/router.js +142 -142
  85. package/ui/js/theme.js +100 -100
  86. package/ui/js/time-tracker.js +248 -248
  87. package/ui/js/views/dashboard.js +870 -870
  88. package/ui/js/views/flash.js +47 -47
  89. package/ui/js/views/projects.js +745 -745
  90. package/ui/js/views/scrum.js +476 -476
  91. package/ui/js/views/settings.js +331 -331
  92. package/ui/js/views/timeline.js +265 -265
package/lib/control.js CHANGED
@@ -1,24 +1,25 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require("fs");
4
- const path = require("path");
5
- const { spawnSync } = require("child_process");
6
-
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { spawnSync } = require("child_process");
6
+
7
7
  const config = require("./config");
8
8
  const env = require("./env");
9
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 = {
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
-
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
+
54
83
  function getRepoSnapshot(contextOrRoot) {
55
84
  const context = config.ensureContext(contextOrRoot);
56
85
  const repoRoot = context.workspaceRoot;
57
- const branch = git(["branch", "--show-current"], repoRoot) || "detached";
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() : "";
58
106
  const status = git(["status", "--short"], repoRoot) || "";
59
107
  const lines = status.split(/\r?\n/).filter(Boolean);
60
108
  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;
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,27 +129,40 @@ 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
- }
89
-
90
- function refreshRepoRuntime(root, options = {}) {
91
- const context = config.ensureContext(root);
92
- const runtimeFile = config.runtimeFilePath(context);
93
- fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });
94
- const snapshot = getRepoSnapshot(context);
95
- writeJson(runtimeFile, snapshot);
96
- if (!options.quiet) {
97
- console.log(t("cli.runtimeUpdated", { path: path.relative(context.workspaceRoot, runtimeFile) }));
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;
98
136
  }
99
- return snapshot;
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
+ };
100
152
  }
101
153
 
154
+ function refreshRepoRuntime(root, options = {}) {
155
+ const context = config.ensureContext(root);
156
+ const runtimeFile = config.runtimeFilePath(context);
157
+ fs.mkdirSync(path.dirname(runtimeFile), { recursive: true });
158
+ const snapshot = getRepoSnapshot(context);
159
+ writeJson(runtimeFile, snapshot);
160
+ if (!options.quiet) {
161
+ console.log(t("cli.runtimeUpdated", { path: path.relative(context.workspaceRoot, runtimeFile) }));
162
+ }
163
+ return snapshot;
164
+ }
165
+
102
166
  /* ── derive ── */
103
167
 
104
168
  function getPhaseInfo(phaseId, phases) {
@@ -115,16 +179,42 @@ function compareTasks(a, b, phases) {
115
179
  return a.title.localeCompare(b.title, getLocale());
116
180
  }
117
181
 
118
- function derive(control) {
119
- const phases = config.getPhases(control);
120
- const tasks = [...control.tasks].sort((a, b) => compareTasks(a, b, phases));
121
- const completedIds = new Set(tasks.filter((t) => t.status === "completed").map((t) => t.id));
122
- const closedStatuses = new Set(["completed", "cancelled"]);
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
+
199
+ function derive(control) {
200
+ const phases = config.getPhases(control);
201
+ const tasks = [...control.tasks].sort((a, b) => compareTasks(a, b, phases));
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));
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,22 +229,27 @@ 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
- const phaseStats = phases.map((phase) => {
148
- const phaseTasks = tasks.filter((t) => t.phase === phase.id && t.required !== false);
149
- const completed = phaseTasks.filter((t) => t.status === "completed").length;
150
- const closed = phaseTasks.filter((t) => closedStatuses.has(t.status)).length;
151
- return { ...phase, total: phaseTasks.length, completed, closed, remaining: phaseTasks.length - closed };
152
- });
238
+ const phaseStats = phases.map((phase) => {
239
+ const phaseTasks = tasks.filter((t) => t.phase === phase.id && t.required !== false);
240
+ const completed = phaseTasks.filter((t) => t.status === "completed").length;
241
+ const closed = phaseTasks.filter((t) => closedStatuses.has(t.status)).length;
242
+ return { ...phase, total: phaseTasks.length, completed, closed, remaining: phaseTasks.length - closed };
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
  "",
@@ -359,11 +455,11 @@ function buildDocMap(control) {
359
455
  return { taskPlan: renderTaskPlan(control), progress: renderProgress(control), findings: renderFindings(control) };
360
456
  }
361
457
 
362
- function getDocDrift(root, control) {
363
- const context = config.ensureContext(root);
364
- const docs = buildDocMap(control);
365
- const docFiles = config.docFilePaths(context);
366
- return Object.entries({ task_plan: [docFiles.taskPlan, docs.taskPlan], progress: [docFiles.progress, docs.progress], findings: [docFiles.findings, docs.findings] })
458
+ function getDocDrift(root, control) {
459
+ const context = config.ensureContext(root);
460
+ const docs = buildDocMap(control);
461
+ const docFiles = config.docFilePaths(context);
462
+ return Object.entries({ task_plan: [docFiles.taskPlan, docs.taskPlan], progress: [docFiles.progress, docs.progress], findings: [docFiles.findings, docs.findings] })
367
463
  .filter(([, [filePath, expected]]) => {
368
464
  if (!fs.existsSync(filePath)) return true;
369
465
  return fs.readFileSync(filePath, "utf8").replace(/\r\n/g, "\n") !== `${expected}\n`;
@@ -371,21 +467,34 @@ function getDocDrift(root, control) {
371
467
  .map(([name]) => name);
372
468
  }
373
469
 
374
- function syncDocs(root, control) {
375
- const context = config.ensureContext(root);
376
- const docs = buildDocMap(control);
377
- 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`);
381
- }
470
+ function syncDocs(root, control, options = {}) {
471
+ const context = config.ensureContext(root);
472
+ const docs = buildDocMap(control);
473
+ const docFiles = config.docFilePaths(context);
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
+ }
490
+ }
382
491
 
383
492
  /* ── task management ── */
384
493
 
385
- function updateTask(root, control, action, taskId, note) {
386
- const context = config.ensureContext(root);
387
- const task = control.tasks.find((item) => item.id === taskId);
388
- if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
494
+ function updateTask(root, control, action, taskId, note) {
495
+ const context = config.ensureContext(root);
496
+ const task = control.tasks.find((item) => item.id === taskId);
497
+ if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
389
498
 
390
499
  const actionMap = { start: "in_progress", review: "in_review", complete: "completed", done: "completed", block: "blocked", pending: "pending", cancel: "cancelled" };
391
500
 
@@ -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");
@@ -406,47 +519,50 @@ function updateTask(root, control, action, taskId, note) {
406
519
  task.history.push({ at: nowIso(), action, note: note || "" });
407
520
  }
408
521
 
409
- config.saveControl(context, control);
410
- syncDocs(context, control);
411
- refreshRepoRuntime(context, { quiet: true });
412
- }
522
+ config.saveControl(context, control);
523
+ syncDocs(context, control);
524
+ refreshRepoRuntime(context, { quiet: true });
525
+ }
413
526
 
414
527
  /* ── CLI commands ── */
415
528
 
416
- function initLocale(root) {
417
- try {
418
- const control = config.loadControl(config.ensureContext(root));
419
- setLocale(config.getLocale(control));
420
- } catch (_err) {
421
- setLocale("es");
529
+ function initLocale(root) {
530
+ try {
531
+ const control = config.loadControl(config.ensureContext(root));
532
+ setLocale(config.getLocale(control));
533
+ } catch (_err) {
534
+ setLocale("es");
422
535
  }
423
536
  }
424
537
 
425
- function cmdStatus(root) {
426
- const context = config.ensureContext(root);
427
- initLocale(context);
428
- const control = config.loadControl(context);
429
- const state = derive(control);
430
- const phases = config.getPhases(control);
431
- const repo = refreshRepoRuntime(context, { quiet: true });
432
- const drift = getDocDrift(context, control);
433
- const envAudit = env.auditEnvironment(context, control);
434
-
538
+ function cmdStatus(root) {
539
+ const context = config.ensureContext(root);
540
+ initLocale(context);
541
+ const control = config.loadControl(context);
542
+ const state = derive(control);
543
+ const phases = config.getPhases(control);
544
+ const repo = refreshRepoRuntime(context, { quiet: true });
545
+ const drift = getDocDrift(context, control);
546
+ const envAudit = env.auditEnvironment(context, control);
547
+
435
548
  console.log(t("cli.status.title", { projectName: control.meta.projectName }));
436
549
  console.log(t("cli.status.focus", { focus: control.meta.currentFocus }));
437
550
  console.log(t("cli.status.activePhase", { phaseId: state.activePhase.id, phaseLabel: state.activePhase.label }));
438
- console.log(`Layout: ${context.layout} | Workspace: ${context.workspaceRoot}`);
551
+ console.log(t("cli.status.layout", { layout: context.layout, workspace: context.workspaceRoot }));
439
552
  if (context.layout === "split") {
440
- console.log(`App: ${context.appRoot}`);
441
- console.log(`Ops: ${context.opsRoot}`);
553
+ console.log(t("cli.status.appRoot", { path: context.appRoot }));
554
+ console.log(t("cli.status.opsRoot", { path: context.opsRoot }));
442
555
  }
443
556
  if (control.meta?.opera?.bootstrap?.status) {
444
- console.log(t("cli.status.bootstrap", { status: control.meta.opera.bootstrap.status, locale: config.getLocale(control) }));
557
+ console.log(t("cli.status.bootstrap", {
558
+ status: formatBootstrapStatus(control.meta.opera.bootstrap.status),
559
+ locale: config.getLocale(control),
560
+ }));
445
561
  if (control.meta.opera.bootstrap.mode) {
446
- console.log(`Bootstrap mode: ${control.meta.opera.bootstrap.mode}`);
562
+ console.log(t("cli.status.bootstrapMode", { value: formatBootstrapMode(control.meta.opera.bootstrap.mode) }));
447
563
  }
448
564
  if (control.meta.opera.bootstrap.routeReason) {
449
- console.log(`Bootstrap route: ${control.meta.opera.bootstrap.routeReason}`);
565
+ console.log(t("cli.status.bootstrapReason", { value: formatBootstrapReason(control.meta.opera.bootstrap.routeReason) }));
450
566
  }
451
567
  }
452
568
  console.log(t("cli.status.tasks", {
@@ -454,41 +570,49 @@ function cmdStatus(root) {
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 })}`);
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
+ }
488
612
  }
489
613
  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"}`);
614
+ console.log(`- ${t("cli.status.envPresent", { value: listOrNone(envAudit.presentKeys) })}`);
615
+ console.log(`- ${t("cli.status.envMissing", { value: listOrNone(envAudit.missingKeys) })}`);
492
616
  console.log("");
493
617
  const syncStatus = drift.length
494
618
  ? t("cli.status.docsSyncedNo", { files: drift.join(", ") })
@@ -496,13 +620,26 @@ function cmdStatus(root) {
496
620
  console.log(t("cli.status.docsSynced", { status: syncStatus }));
497
621
  }
498
622
 
499
- function cmdNext(root) {
500
- const context = config.ensureContext(root);
501
- initLocale(context);
502
- const control = config.loadControl(context);
503
- const ready = derive(control).readyTasks.slice(0, 10);
623
+ function cmdNext(root) {
624
+ const context = config.ensureContext(root);
625
+ initLocale(context);
626
+ const control = config.loadControl(context);
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,114 +650,139 @@ function cmdNext(root) {
513
650
  });
514
651
  }
515
652
 
516
- function cmdSync(root) {
517
- const context = config.ensureContext(root);
518
- initLocale(context);
519
- const control = config.loadControl(context);
520
- env.syncEnvironment(context, control);
521
- syncDocs(context, control);
522
- refreshRepoRuntime(context, { quiet: true });
523
- console.log(t("cli.docsSynced"));
524
- }
525
-
526
- function cmdRefreshRepo(root, args) {
527
- refreshRepoRuntime(config.ensureContext(root), { quiet: (args || []).includes("--quiet") });
528
- }
529
-
530
- function cmdTask(root, args) {
531
- const context = config.ensureContext(root);
532
- initLocale(context);
533
- const [action, taskId, ...noteParts] = args || [];
534
- if (!action || !taskId) throw new Error(t("cli.mustProvideActionAndId"));
535
- const control = config.loadControl(context);
536
- updateTask(context, control, action, taskId, noteParts.join(" ").trim());
537
- console.log(t("cli.taskUpdated", { taskId, action }));
538
- }
539
-
540
- function cmdInstallHooks(root) {
541
- const context = config.ensureContext(root);
542
- initLocale(context);
543
- const hooksPath = context.layout === "split" ? "ops/.githooks" : ".githooks";
544
- const result = spawnSync("git", ["config", "core.hooksPath", hooksPath], { cwd: context.workspaceRoot, encoding: "utf8" });
545
- if (result.error || result.status !== 0) throw new Error(t("cli.hooksError"));
546
- console.log(t("cli.hooksInstalled"));
547
- }
548
-
549
- function cmdHelp() {
550
- console.log(`trackops — ${t("cli.help.title")}`);
551
- console.log("");
552
- console.log(`${t("cli.help.usage")} trackops <command> [args]`);
553
- console.log("");
554
- console.log(t("cli.help.commands"));
555
- console.log(" init [--with-opera] [--legacy-layout] [--locale es|en] [--name \"...\"] [--no-bootstrap]");
556
- console.log(" [--bootstrap-mode auto|direct|handoff] [--technical-level low|medium|high|senior]");
557
- console.log(" [--project-state idea|draft|existing_repo|advanced] [--docs-state none|notes|sos|spec_dossier|repo_docs]");
558
- console.log(" [--decision-ownership user|shared|agent]");
559
- console.log(` ${t("cli.help.init.desc")}`);
560
- console.log(" workspace status|migrate");
561
- console.log(` ${t("cli.help.workspace.desc")}`);
562
- console.log(" env status|sync");
563
- console.log(` ${t("cli.help.env.desc")}`);
564
- console.log(" release [--push]");
565
- console.log(` ${t("cli.help.release.desc")}`);
566
- console.log(" version");
567
- console.log(` ${t("cli.help.version.desc")}`);
568
- console.log(" status");
569
- console.log(` ${t("cli.help.status.desc")}`);
570
- console.log(" next");
571
- console.log(` ${t("cli.help.next.desc")}`);
572
- console.log(" sync");
573
- console.log(` ${t("cli.help.sync.desc")}`);
574
- console.log(" dashboard [--port N] [--host HOST] [--public] [--strict-port]");
575
- console.log(` ${t("cli.help.dashboard.desc")}`);
576
- console.log(" refresh-repo [--quiet]");
577
- console.log(` ${t("cli.help.refreshRepo.desc")}`);
578
- console.log(" install-hooks");
579
- console.log(` ${t("cli.help.installHooks.desc")}`);
580
- console.log(" register");
581
- console.log(` ${t("cli.help.register.desc")}`);
582
- console.log(" projects");
583
- console.log(` ${t("cli.help.projects.desc")}`);
584
- console.log(" task <action> <id> [note]");
585
- console.log(` ${t("cli.help.task.desc")}`);
586
- console.log(" opera install|bootstrap|handoff|status|configure|upgrade");
587
- console.log(` ${t("cli.help.opera.desc")}`);
588
- console.log(` ${t("cli.help.opera.upgradeHint")}`);
589
- console.log(" locale get|set [es|en]");
590
- console.log(` ${t("cli.help.locale.desc")}`);
591
- console.log(" doctor locale");
592
- console.log(` ${t("cli.help.doctor.desc")}`);
593
- console.log(" skill install|list|remove|catalog <name>");
594
- console.log(` ${t("cli.help.skill.desc")}`);
595
- console.log(" help");
653
+ function cmdSync(root, args) {
654
+ const context = config.ensureContext(root);
655
+ initLocale(context);
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
+ }
671
+ env.syncEnvironment(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 });
681
+ refreshRepoRuntime(context, { quiet: true });
682
+ console.log(t("cli.docsSynced"));
683
+ }
684
+
685
+ function cmdRefreshRepo(root, args) {
686
+ refreshRepoRuntime(config.ensureContext(root), { quiet: (args || []).includes("--quiet") });
687
+ }
688
+
689
+ function cmdTask(root, args) {
690
+ const context = config.ensureContext(root);
691
+ initLocale(context);
692
+ const [action, taskId, ...noteParts] = args || [];
693
+ if (!action || !taskId) throw new Error(t("cli.mustProvideActionAndId"));
694
+ const control = config.loadControl(context);
695
+ updateTask(context, control, action, taskId, noteParts.join(" ").trim());
696
+ console.log(t("cli.taskUpdated", { taskId, action }));
697
+ }
698
+
699
+ function cmdInstallHooks(root) {
700
+ const context = config.ensureContext(root);
701
+ initLocale(context);
702
+ const hooksPath = context.layout === "split" ? "ops/.githooks" : ".githooks";
703
+ const result = spawnSync("git", ["config", "core.hooksPath", hooksPath], { cwd: context.workspaceRoot, encoding: "utf8" });
704
+ if (result.error || result.status !== 0) throw new Error(t("cli.hooksError"));
705
+ console.log(t("cli.hooksInstalled"));
706
+ }
707
+
708
+ function cmdHelp() {
709
+ console.log(`trackops — ${t("cli.help.title")}`);
710
+ console.log("");
711
+ console.log(`${t("cli.help.usage")} trackops <command> [args]`);
712
+ console.log("");
713
+ console.log(t("cli.help.commands"));
714
+ console.log(" init [--with-opera] [--legacy-layout] [--locale es|en] [--name \"...\"] [--no-bootstrap]");
715
+ console.log(" [--bootstrap-mode auto|direct|handoff] [--technical-level low|medium|high|senior]");
716
+ console.log(" [--project-state idea|draft|existing_repo|advanced] [--docs-state none|notes|sos|spec_dossier|repo_docs]");
717
+ console.log(" [--decision-ownership user|shared|agent]");
718
+ console.log(` ${t("cli.help.init.desc")}`);
719
+ console.log(" workspace status|migrate");
720
+ console.log(` ${t("cli.help.workspace.desc")}`);
721
+ console.log(" env status|sync");
722
+ console.log(` ${t("cli.help.env.desc")}`);
723
+ console.log(" release [--push]");
724
+ console.log(` ${t("cli.help.release.desc")}`);
725
+ console.log(" version");
726
+ console.log(` ${t("cli.help.version.desc")}`);
727
+ console.log(" status");
728
+ console.log(` ${t("cli.help.status.desc")}`);
729
+ console.log(" next");
730
+ console.log(` ${t("cli.help.next.desc")}`);
731
+ console.log(" sync");
732
+ console.log(` ${t("cli.help.sync.desc")}`);
733
+ console.log(" dashboard [--port N] [--host HOST] [--public] [--strict-port]");
734
+ console.log(` ${t("cli.help.dashboard.desc")}`);
735
+ console.log(" refresh-repo [--quiet]");
736
+ console.log(` ${t("cli.help.refreshRepo.desc")}`);
737
+ console.log(" install-hooks");
738
+ console.log(` ${t("cli.help.installHooks.desc")}`);
739
+ console.log(" register");
740
+ console.log(` ${t("cli.help.register.desc")}`);
741
+ console.log(" projects");
742
+ console.log(` ${t("cli.help.projects.desc")}`);
743
+ console.log(" task <action> <id> [note]");
744
+ console.log(` ${t("cli.help.task.desc")}`);
745
+ console.log(" opera install|bootstrap|handoff|status|configure|upgrade");
746
+ console.log(` ${t("cli.help.opera.desc")}`);
747
+ console.log(` ${t("cli.help.opera.upgradeHint")}`);
748
+ console.log(" locale get|set [es|en]");
749
+ console.log(` ${t("cli.help.locale.desc")}`);
750
+ console.log(" doctor locale");
751
+ console.log(` ${t("cli.help.doctor.desc")}`);
752
+ console.log(" skill install|list|remove|catalog <name>");
753
+ console.log(` ${t("cli.help.skill.desc")}`);
754
+ console.log(" help");
596
755
  console.log(` ${t("cli.help.help.desc")}`);
597
756
  console.log("");
757
+ console.log(t("cli.help.globalFlags"));
758
+ console.log(` ${t("cli.help.globalFlags.line1")}`);
759
+ console.log("");
598
760
  console.log(t("cli.help.globalWorkflow"));
599
761
  console.log(` ${t("cli.help.globalWorkflow.line1")}`);
600
762
  console.log(` ${t("cli.help.globalWorkflow.line2")}`);
601
- }
763
+ }
602
764
 
603
765
  /* ── project-scoped API (used by server) ── */
604
766
 
605
- function forProject(root) {
606
- const context = config.ensureContext(root);
607
- initLocale(context);
608
- return {
609
- loadControl: () => config.loadControl(context),
610
- saveControl: (ctrl) => config.saveControl(context, ctrl),
611
- derive,
612
- buildDocMap,
613
- getDocDrift: (ctrl) => getDocDrift(context, ctrl),
614
- syncDocs: (ctrl) => syncDocs(context, ctrl),
615
- updateTask: (ctrl, action, id, note) => updateTask(context, ctrl, action, id, note),
616
- getRepoSnapshot: () => getRepoSnapshot(context),
617
- refreshRepoRuntime: (opts) => refreshRepoRuntime(context, opts),
618
- getPhases: (ctrl) => config.getPhases(ctrl),
619
- getLocale: (ctrl) => config.getLocale(ctrl),
620
- statusLabel,
621
- context,
622
- };
623
- }
767
+ function forProject(root) {
768
+ const context = config.ensureContext(root);
769
+ initLocale(context);
770
+ return {
771
+ loadControl: () => config.loadControl(context),
772
+ saveControl: (ctrl) => config.saveControl(context, ctrl),
773
+ derive,
774
+ buildDocMap,
775
+ getDocDrift: (ctrl) => getDocDrift(context, ctrl),
776
+ syncDocs: (ctrl) => syncDocs(context, ctrl),
777
+ updateTask: (ctrl, action, id, note) => updateTask(context, ctrl, action, id, note),
778
+ getRepoSnapshot: () => getRepoSnapshot(context),
779
+ refreshRepoRuntime: (opts) => refreshRepoRuntime(context, opts),
780
+ getPhases: (ctrl) => config.getPhases(ctrl),
781
+ getLocale: (ctrl) => config.getLocale(ctrl),
782
+ statusLabel,
783
+ context,
784
+ };
785
+ }
624
786
 
625
787
  module.exports = {
626
788
  buildDocMap, derive, getDocDrift, getRepoSnapshot, refreshRepoRuntime, syncDocs, updateTask,