trackops 1.0.1 → 1.1.0

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