trackops 2.0.5 → 2.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.
package/bin/trackops.js CHANGED
@@ -1,13 +1,33 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const path = require("path");
4
- const config = require("../lib/config");
5
- const runtimeState = require("../lib/runtime-state");
6
- const { setLocale, t } = require("../lib/i18n");
7
- const pkg = require("../package.json");
3
+ const nodeVersion = parseInt(process.versions.node, 10);
4
+ if (nodeVersion < 18) {
5
+ console.error(`TrackOps requiere Node.js 18 o superior. Version actual: ${process.versions.node}`);
6
+ process.exit(1);
7
+ }
8
8
 
9
- const command = process.argv[2];
10
- const args = process.argv.slice(3);
9
+ const path = require("path");
10
+ const config = require("../lib/config");
11
+ const runtimeState = require("../lib/runtime-state");
12
+ const { setLocale, t } = require("../lib/i18n");
13
+ const fmt = require("../lib/cli-format");
14
+ const pkg = require("../package.json");
15
+
16
+ function parseCliTokens(argv) {
17
+ const plain = argv.includes("--plain") || argv.includes("--a11y");
18
+ const tokens = argv.filter((token) => !["--plain", "--a11y"].includes(token));
19
+ return {
20
+ plain,
21
+ command: tokens[0],
22
+ args: tokens.slice(1),
23
+ };
24
+ }
25
+
26
+ const parsed = parseCliTokens(process.argv.slice(2));
27
+ fmt.configure({ plain: parsed.plain });
28
+
29
+ const command = parsed.command;
30
+ const args = parsed.args;
11
31
 
12
32
  function initCliLocale() {
13
33
  let projectLocale = null;
@@ -27,6 +47,7 @@ function resolveRoot() {
27
47
  const context = config.resolveWorkspaceContext();
28
48
  if (!context) {
29
49
  console.error(t("cli.error.noWorkspace"));
50
+ console.error(t("cli.help.initFirst"));
30
51
  process.exit(1);
31
52
  }
32
53
  return context.workspaceRoot;
@@ -122,22 +143,30 @@ async function run() {
122
143
  break;
123
144
  }
124
145
 
125
- case "skill": {
126
- const skills = require("../lib/skills");
127
- const sub = args[0];
128
- const root = config.resolveProjectRoot() || process.cwd();
129
- if (sub === "install") skills.cmdInstall(root, args[1]);
130
- else if (sub === "list") skills.cmdList(root);
131
- else if (sub === "remove") skills.cmdRemove(root, args[1]);
132
- else if (sub === "catalog") skills.cmdCatalog();
133
- else { console.log(t("cli.usage.skill")); }
134
- break;
135
- }
136
-
137
- case "version":
138
- case "--version":
139
- case "-v":
140
- console.log(pkg.version);
146
+ case "skill": {
147
+ const skills = require("../lib/skills");
148
+ const sub = args[0];
149
+ const root = config.resolveProjectRoot() || process.cwd();
150
+ if (sub === "install") skills.cmdInstall(root, args[1]);
151
+ else if (sub === "list") skills.cmdList(root);
152
+ else if (sub === "remove") skills.cmdRemove(root, args[1]);
153
+ else if (sub === "catalog") skills.cmdCatalog();
154
+ else { console.log(t("cli.usage.skill")); }
155
+ break;
156
+ }
157
+
158
+ case "plan":
159
+ require("../lib/plans").cmdPlan(config.resolveProjectRoot() || process.cwd(), args);
160
+ break;
161
+
162
+ case "quality":
163
+ require("../lib/quality").cmdQuality(config.resolveProjectRoot() || process.cwd(), args);
164
+ break;
165
+
166
+ case "version":
167
+ case "--version":
168
+ case "-v":
169
+ console.log(pkg.version);
141
170
  break;
142
171
 
143
172
  case "help":
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI output formatting helpers for accessible, scannable terminal output.
5
+ * Supports a plain mode for screen readers, logs, and limited terminals.
6
+ */
7
+
8
+ const state = {
9
+ plain: false,
10
+ };
11
+
12
+ const UNICODE_SEP = "\u2500".repeat(48);
13
+ const PLAIN_SEP = "-".repeat(48);
14
+
15
+ const STATUS_TOKENS = {
16
+ pending: ["\u23F3", "PENDING"],
17
+ in_progress: ["\uD83D\uDEA7", "IN PROGRESS"],
18
+ in_review: ["\uD83D\uDC40", "IN REVIEW"],
19
+ blocked: ["\u26D4", "BLOCKED"],
20
+ completed: ["\u2705", "DONE"],
21
+ cancelled: ["\uD83D\uDDD1\uFE0F", "CANCELLED"],
22
+ };
23
+
24
+ const CHECK_TOKENS = {
25
+ pass: ["\u2705", "PASS"],
26
+ warn: ["\u26A0\uFE0F", "WARN"],
27
+ fail: ["\u274C", "FAIL"],
28
+ pending: ["\u23F3", "PENDING"],
29
+ };
30
+
31
+ function configure(options = {}) {
32
+ if (Object.prototype.hasOwnProperty.call(options, "plain")) {
33
+ state.plain = Boolean(options.plain);
34
+ }
35
+ }
36
+
37
+ function isPlain() {
38
+ return state.plain;
39
+ }
40
+
41
+ function pick(unicodeValue, plainValue) {
42
+ return state.plain ? plainValue : unicodeValue;
43
+ }
44
+
45
+ function sep() {
46
+ console.log(pick(UNICODE_SEP, PLAIN_SEP));
47
+ }
48
+
49
+ function blank() {
50
+ console.log("");
51
+ }
52
+
53
+ function header(text) {
54
+ console.log("");
55
+ console.log(pick(UNICODE_SEP, PLAIN_SEP));
56
+ console.log(` ${text}`);
57
+ console.log(pick(UNICODE_SEP, PLAIN_SEP));
58
+ }
59
+
60
+ function success(text) {
61
+ console.log(` ${pick("\u2713", "OK")} ${text}`);
62
+ }
63
+
64
+ function info(text) {
65
+ console.log(` ${text}`);
66
+ }
67
+
68
+ function step(label, value) {
69
+ if (value !== undefined) {
70
+ console.log(` ${pick("\u2192", "->")} ${label}: ${value}`);
71
+ } else {
72
+ console.log(` ${pick("\u2192", "->")} ${label}`);
73
+ }
74
+ }
75
+
76
+ function hint(text) {
77
+ console.log(` ${text}`);
78
+ }
79
+
80
+ function warn(text) {
81
+ console.log(` ${pick("\u26A0", "WARN")} ${text}`);
82
+ }
83
+
84
+ function bullet(text) {
85
+ console.log(` - ${text}`);
86
+ }
87
+
88
+ function statusToken(status) {
89
+ const pair = STATUS_TOKENS[status] || [status, String(status || "").toUpperCase()];
90
+ return pick(pair[0], `[${pair[1]}]`);
91
+ }
92
+
93
+ function checkToken(status) {
94
+ const pair = CHECK_TOKENS[status] || [status, String(status || "").toUpperCase()];
95
+ return pick(pair[0], `[${pair[1]}]`);
96
+ }
97
+
98
+ function boolToken(value) {
99
+ return value ? pick("\u2705", "[YES]") : pick("\u274C", "[NO]");
100
+ }
101
+
102
+ module.exports = {
103
+ configure,
104
+ isPlain,
105
+ pick,
106
+ sep,
107
+ blank,
108
+ header,
109
+ success,
110
+ info,
111
+ step,
112
+ hint,
113
+ warn,
114
+ bullet,
115
+ statusToken,
116
+ checkToken,
117
+ boolToken,
118
+ };
package/lib/config.js CHANGED
@@ -22,12 +22,27 @@ const DEFAULT_PHASE_LABELS = {
22
22
  },
23
23
  };
24
24
 
25
- const DEFAULT_LOCALE = "es";
26
- const WORKSPACE_MANIFEST = ".trackops-workspace.json";
27
- const DEFAULT_APP_DIR = "app";
28
- const DEFAULT_OPS_DIR = "ops";
29
- const DEFAULT_DEV_BRANCH = "develop";
30
- const DEFAULT_PUBLISH_BRANCH = "master";
25
+ const DEFAULT_LOCALE = "es";
26
+ const WORKSPACE_MANIFEST = ".trackops-workspace.json";
27
+ const DEFAULT_APP_DIR = "app";
28
+ const DEFAULT_OPS_DIR = "ops";
29
+ const DEFAULT_DEV_BRANCH = "develop";
30
+ const DEFAULT_PUBLISH_BRANCH = "master";
31
+ const DEFAULT_CONTROL_VERSION = 3;
32
+ const DEFAULT_MANAGED_PLAN_FIELDS = [
33
+ "title",
34
+ "summary",
35
+ "acceptance",
36
+ "dependsOn",
37
+ "phase",
38
+ "stream",
39
+ "priority",
40
+ "required",
41
+ "parentId",
42
+ "sequence",
43
+ ];
44
+ const DEFAULT_EXECUTION_OWNER = "shared";
45
+ const EXECUTION_OWNERS = ["agent", "user", "shared"];
31
46
 
32
47
  function buildDefaultPhases(locale) {
33
48
  const normalized = normalizeLocale(locale) || DEFAULT_LOCALE;
@@ -82,11 +97,11 @@ function createSplitContext(workspaceRoot, manifest = {}) {
82
97
  progress: path.join(opsRoot, "progress.md"),
83
98
  findings: path.join(opsRoot, "findings.md"),
84
99
  },
85
- paths: {
86
- taskPlan: path.join(opsRoot, "task_plan.md"),
87
- progress: path.join(opsRoot, "progress.md"),
88
- findings: path.join(opsRoot, "findings.md"),
89
- architectureDir: path.join(opsRoot, "architecture"),
100
+ paths: {
101
+ taskPlan: path.join(opsRoot, "task_plan.md"),
102
+ progress: path.join(opsRoot, "progress.md"),
103
+ findings: path.join(opsRoot, "findings.md"),
104
+ architectureDir: path.join(opsRoot, "architecture"),
90
105
  hooksDir: path.join(opsRoot, ".githooks"),
91
106
  tmpDir: path.join(opsRoot, ".tmp"),
92
107
  bootstrapDir: path.join(opsRoot, "bootstrap"),
@@ -96,10 +111,16 @@ function createSplitContext(workspaceRoot, manifest = {}) {
96
111
  autonomyPolicyFile: path.join(opsRoot, "policy", "autonomy.json"),
97
112
  reviewsDir: path.join(opsRoot, "reviews"),
98
113
  skillsDir: path.join(opsRoot, ".agents", "skills"),
99
- registryPath: path.join(opsRoot, ".agents", "skills", "_registry.md"),
100
- agentHubDir: path.join(opsRoot, ".agent", "hub"),
101
- genesisFile: path.join(opsRoot, "genesis.md"),
102
- },
114
+ registryPath: path.join(opsRoot, ".agents", "skills", "_registry.md"),
115
+ agentHubDir: path.join(opsRoot, ".agent", "hub"),
116
+ genesisFile: path.join(opsRoot, "genesis.md"),
117
+ plansDir: path.join(opsRoot, "plans"),
118
+ plansRegistryFile: path.join(opsRoot, "plans", "_registry.json"),
119
+ qualityDir: path.join(opsRoot, "quality"),
120
+ qualityLatestFile: path.join(opsRoot, "quality", "latest.json"),
121
+ qualityRunsDir: path.join(opsRoot, "quality", "runs"),
122
+ qualityWaiversFile: path.join(opsRoot, "quality", "waivers.json"),
123
+ },
103
124
  env: {
104
125
  rootFile: path.join(workspace, env.rootFile || ".env"),
105
126
  exampleFile: path.join(workspace, env.exampleFile || ".env.example"),
@@ -140,11 +161,11 @@ function createLegacyContext(rootDir) {
140
161
  progress: path.join(root, "progress.md"),
141
162
  findings: path.join(root, "findings.md"),
142
163
  },
143
- paths: {
144
- taskPlan: path.join(root, "task_plan.md"),
145
- progress: path.join(root, "progress.md"),
146
- findings: path.join(root, "findings.md"),
147
- architectureDir: path.join(root, "architecture"),
164
+ paths: {
165
+ taskPlan: path.join(root, "task_plan.md"),
166
+ progress: path.join(root, "progress.md"),
167
+ findings: path.join(root, "findings.md"),
168
+ architectureDir: path.join(root, "architecture"),
148
169
  hooksDir: path.join(root, ".githooks"),
149
170
  tmpDir: path.join(root, ".tmp"),
150
171
  bootstrapDir: path.join(root, "bootstrap"),
@@ -154,10 +175,16 @@ function createLegacyContext(rootDir) {
154
175
  autonomyPolicyFile: path.join(root, "policy", "autonomy.json"),
155
176
  reviewsDir: path.join(root, "reviews"),
156
177
  skillsDir: path.join(root, ".agents", "skills"),
157
- registryPath: path.join(root, ".agents", "skills", "_registry.md"),
158
- agentHubDir: path.join(root, ".agent", "hub"),
159
- genesisFile: path.join(root, "genesis.md"),
160
- },
178
+ registryPath: path.join(root, ".agents", "skills", "_registry.md"),
179
+ agentHubDir: path.join(root, ".agent", "hub"),
180
+ genesisFile: path.join(root, "genesis.md"),
181
+ plansDir: path.join(root, "plans"),
182
+ plansRegistryFile: path.join(root, "plans", "_registry.json"),
183
+ qualityDir: path.join(root, "quality"),
184
+ qualityLatestFile: path.join(root, "quality", "latest.json"),
185
+ qualityRunsDir: path.join(root, "quality", "runs"),
186
+ qualityWaiversFile: path.join(root, "quality", "waivers.json"),
187
+ },
161
188
  env: {
162
189
  rootFile: path.join(root, ".env"),
163
190
  exampleFile: path.join(root, ".env.example"),
@@ -265,22 +292,218 @@ function isOperaInstalled(control) {
265
292
  return control.meta?.opera?.installed === true;
266
293
  }
267
294
 
268
- function getOperaVersion(control) {
269
- return control.meta?.opera?.version || null;
295
+ function getOperaVersion(control) {
296
+ return control.meta?.opera?.version || null;
297
+ }
298
+
299
+ function normalizeStringList(values) {
300
+ return Array.isArray(values)
301
+ ? values.map((value) => String(value || "").trim()).filter(Boolean)
302
+ : [];
303
+ }
304
+
305
+ function normalizeExecutionOwner(value) {
306
+ const normalized = String(value || "").trim().toLowerCase();
307
+ return EXECUTION_OWNERS.includes(normalized) ? normalized : DEFAULT_EXECUTION_OWNER;
308
+ }
309
+
310
+ function normalizeTaskExecution(execution) {
311
+ const normalized = execution && typeof execution === "object" ? { ...execution } : {};
312
+ normalized.owner = normalizeExecutionOwner(normalized.owner);
313
+ normalized.lastActor = normalized.lastActor ? String(normalized.lastActor).trim() : null;
314
+ normalized.lastSource = normalized.lastSource ? String(normalized.lastSource).trim() : null;
315
+ normalized.currentSessionId = normalized.currentSessionId ? String(normalized.currentSessionId).trim() : null;
316
+ normalized.lastSessionId = normalized.lastSessionId ? String(normalized.lastSessionId).trim() : null;
317
+ normalized.lastSessionStatus = normalized.lastSessionStatus ? String(normalized.lastSessionStatus).trim() : null;
318
+ normalized.awaitingUserConfirmation = normalized.awaitingUserConfirmation === true;
319
+ normalized.verificationPending = normalized.verificationPending === true;
320
+ normalized.updatedAt = normalized.updatedAt ? String(normalized.updatedAt).trim() : null;
321
+ return normalized;
322
+ }
323
+
324
+ function normalizeAgentInboxItem(item) {
325
+ const normalized = item && typeof item === "object" ? { ...item } : {};
326
+ normalized.id = String(normalized.id || "").trim();
327
+ normalized.taskId = normalized.taskId ? String(normalized.taskId).trim() : null;
328
+ normalized.kind = String(normalized.kind || "verify_status").trim();
329
+ normalized.message = String(normalized.message || "").trim();
330
+ normalized.status = String(normalized.status || "pending").trim();
331
+ normalized.createdAt = normalized.createdAt ? String(normalized.createdAt).trim() : null;
332
+ normalized.updatedAt = normalized.updatedAt ? String(normalized.updatedAt).trim() : null;
333
+ normalized.createdBy = normalized.createdBy ? String(normalized.createdBy).trim() : null;
334
+ normalized.source = normalized.source ? String(normalized.source).trim() : null;
335
+ normalized.expectedStatus = normalized.expectedStatus ? String(normalized.expectedStatus).trim() : null;
336
+ normalized.sessionId = normalized.sessionId ? String(normalized.sessionId).trim() : null;
337
+ normalized.resolvedAt = normalized.resolvedAt ? String(normalized.resolvedAt).trim() : null;
338
+ normalized.resolutionNote = normalized.resolutionNote ? String(normalized.resolutionNote).trim() : null;
339
+ return normalized;
340
+ }
341
+
342
+ function normalizeAgentInbox(metaInbox) {
343
+ const normalized = metaInbox && typeof metaInbox === "object" ? { ...metaInbox } : {};
344
+ normalized.pending = Array.isArray(normalized.pending)
345
+ ? normalized.pending.map((item) => normalizeAgentInboxItem(item)).filter((item) => item.id)
346
+ : [];
347
+ normalized.history = Array.isArray(normalized.history)
348
+ ? normalized.history.map((item) => normalizeAgentInboxItem(item)).filter((item) => item.id)
349
+ : [];
350
+ normalized.lastIssuedAt = normalized.lastIssuedAt ? String(normalized.lastIssuedAt).trim() : null;
351
+ return normalized;
352
+ }
353
+
354
+ function normalizeTaskShape(task) {
355
+ const normalized = { ...(task || {}) };
356
+ normalized.id = String(normalized.id || "").trim();
357
+ normalized.title = String(normalized.title || normalized.id || "").trim();
358
+ normalized.phase = String(normalized.phase || DEFAULT_PHASE_IDS[0]).trim() || DEFAULT_PHASE_IDS[0];
359
+ normalized.stream = String(normalized.stream || "Operations").trim() || "Operations";
360
+ normalized.priority = String(normalized.priority || "P1").trim() || "P1";
361
+ normalized.status = String(normalized.status || "pending").trim() || "pending";
362
+ normalized.required = normalized.required !== false;
363
+ normalized.dependsOn = normalizeStringList(normalized.dependsOn);
364
+ normalized.summary = String(normalized.summary || "").trim();
365
+ normalized.acceptance = normalizeStringList(normalized.acceptance);
366
+ normalized.history = Array.isArray(normalized.history) ? normalized.history.map((entry) => ({ ...entry })) : [];
367
+ normalized.parentId = normalized.parentId == null ? null : String(normalized.parentId).trim() || null;
368
+ const numericSequence = Number(normalized.sequence);
369
+ normalized.sequence = Number.isFinite(numericSequence) ? numericSequence : null;
370
+
371
+ const origin = normalized.origin && typeof normalized.origin === "object"
372
+ ? { ...normalized.origin }
373
+ : {};
374
+ const kind = origin.kind === "plan_import" ? "plan_import" : "manual";
375
+ origin.kind = kind;
376
+ origin.sourceId = origin.sourceId ? String(origin.sourceId).trim() : null;
377
+ origin.adapter = origin.adapter ? String(origin.adapter).trim() : null;
378
+ origin.externalNodeId = origin.externalNodeId ? String(origin.externalNodeId).trim() : null;
379
+ origin.importId = origin.importId ? String(origin.importId).trim() : null;
380
+ origin.fingerprint = origin.fingerprint ? String(origin.fingerprint).trim() : null;
381
+ origin.lastImportedAt = origin.lastImportedAt ? String(origin.lastImportedAt).trim() : null;
382
+ origin.detached = origin.detached === true;
383
+ origin.managedFields = Array.isArray(origin.managedFields) && origin.managedFields.length
384
+ ? origin.managedFields.map((field) => String(field || "").trim()).filter(Boolean)
385
+ : kind === "plan_import"
386
+ ? [...DEFAULT_MANAGED_PLAN_FIELDS]
387
+ : [];
388
+ normalized.origin = origin;
389
+ normalized.execution = normalizeTaskExecution(normalized.execution);
390
+ return normalized;
391
+ }
392
+
393
+ function normalizePlansMeta(meta) {
394
+ const normalized = meta && typeof meta === "object" ? { ...meta } : {};
395
+ normalized.activeSourceId = normalized.activeSourceId ? String(normalized.activeSourceId).trim() : null;
396
+ normalized.lastImportAt = normalized.lastImportAt ? String(normalized.lastImportAt).trim() : null;
397
+ normalized.unresolvedConflicts = Number.isFinite(Number(normalized.unresolvedConflicts))
398
+ ? Number(normalized.unresolvedConflicts)
399
+ : 0;
400
+ normalized.sources = Array.isArray(normalized.sources)
401
+ ? normalized.sources.map((source) => ({
402
+ id: String(source?.id || "").trim(),
403
+ title: String(source?.title || source?.id || "").trim(),
404
+ adapter: String(source?.adapter || "unknown").trim(),
405
+ status: String(source?.status || "previewed").trim(),
406
+ lastPreviewAt: source?.lastPreviewAt ? String(source.lastPreviewAt).trim() : null,
407
+ lastApplyAt: source?.lastApplyAt ? String(source.lastApplyAt).trim() : null,
408
+ warnings: Number.isFinite(Number(source?.warnings)) ? Number(source.warnings) : 0,
409
+ conflicts: Number.isFinite(Number(source?.conflicts)) ? Number(source.conflicts) : 0,
410
+ managedTaskCount: Number.isFinite(Number(source?.managedTaskCount)) ? Number(source.managedTaskCount) : 0,
411
+ })).filter((source) => source.id)
412
+ : [];
413
+ return normalized;
414
+ }
415
+
416
+ function normalizeCommandList(value) {
417
+ if (Array.isArray(value)) {
418
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
419
+ }
420
+ if (typeof value === "string" && value.trim()) {
421
+ return [value.trim()];
422
+ }
423
+ return [];
424
+ }
425
+
426
+ function normalizeQualityMeta(meta) {
427
+ const normalized = meta && typeof meta === "object" ? { ...meta } : {};
428
+ normalized.baselineProfile = String(normalized.baselineProfile || "baseline").trim() || "baseline";
429
+ normalized.activeProfiles = normalizeStringList(normalized.activeProfiles);
430
+ normalized.verification = normalized.verification && typeof normalized.verification === "object"
431
+ ? { ...normalized.verification }
432
+ : {};
433
+ normalized.verification.testCommands = normalizeCommandList(normalized.verification.testCommands);
434
+ normalized.verification.buildCommands = normalizeCommandList(normalized.verification.buildCommands);
435
+ normalized.verification.smokeCommands = normalizeCommandList(normalized.verification.smokeCommands);
436
+ normalized.verification.reviewRequired = normalized.verification.reviewRequired === true;
437
+ normalized.lastReportAt = normalized.lastReportAt ? String(normalized.lastReportAt).trim() : null;
438
+ normalized.lastVerificationAt = normalized.lastVerificationAt ? String(normalized.lastVerificationAt).trim() : null;
439
+ normalized.lastReleaseReadiness = normalized.lastReleaseReadiness ? String(normalized.lastReleaseReadiness).trim() : null;
440
+ normalized.lastPromotionReadiness = normalized.lastPromotionReadiness ? String(normalized.lastPromotionReadiness).trim() : null;
441
+ return normalized;
442
+ }
443
+
444
+ function normalizeControlShape(control) {
445
+ const normalized = { ...(control || {}) };
446
+ normalized.meta = normalized.meta && typeof normalized.meta === "object" ? { ...normalized.meta } : {};
447
+ normalized.meta.controlVersion = DEFAULT_CONTROL_VERSION;
448
+ normalized.meta.plans = normalizePlansMeta(normalized.meta.plans);
449
+ normalized.meta.quality = normalizeQualityMeta(normalized.meta.quality);
450
+ normalized.meta.agentInbox = normalizeAgentInbox(normalized.meta.agentInbox);
451
+ normalized.tasks = Array.isArray(normalized.tasks)
452
+ ? normalized.tasks.map((task) => normalizeTaskShape(task))
453
+ : [];
454
+ const changed = JSON.stringify(control || {}) !== JSON.stringify(normalized);
455
+ Object.defineProperty(normalized, "__trackopsMigrated", {
456
+ value: changed,
457
+ enumerable: false,
458
+ configurable: true,
459
+ });
460
+ return normalized;
461
+ }
462
+
463
+ function validateControl(control) {
464
+ if (!control || typeof control !== "object") throw new Error("project_control.json is not a valid object.");
465
+ if (!control.meta || typeof control.meta !== "object") throw new Error("project_control.json is missing required field: meta");
466
+ if (!Array.isArray(control.tasks)) throw new Error("project_control.json is missing required field: tasks");
467
+ for (let i = 0; i < control.tasks.length; i++) {
468
+ const task = control.tasks[i];
469
+ if (!task.id) throw new Error(`Task at index ${i} is missing required field: id`);
470
+ if (!task.status) throw new Error(`Task at index ${i} is missing required field: status`);
471
+ }
270
472
  }
271
473
 
272
474
  function loadControl(contextOrRoot) {
273
475
  const filePath = controlFilePath(contextOrRoot);
274
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
275
- }
276
-
277
- function saveControl(contextOrRoot, control) {
278
- control.meta = control.meta || {};
279
- control.meta.updatedAt = new Date().toISOString();
280
- const filePath = controlFilePath(contextOrRoot);
281
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
282
- fs.writeFileSync(filePath, JSON.stringify(control, null, 2) + "\n", "utf8");
283
- }
476
+ let raw;
477
+ try {
478
+ raw = fs.readFileSync(filePath, "utf8");
479
+ } catch (err) {
480
+ throw new Error(`No se puede leer project_control.json.\n Ruta: ${filePath}\n Detalle: ${err.code === "ENOENT" ? "El archivo no existe. Ejecuta 'trackops init' primero." : err.message}`);
481
+ }
482
+ let control;
483
+ try {
484
+ control = JSON.parse(raw);
485
+ } catch (err) {
486
+ throw new Error(`project_control.json esta corrupto o no es JSON valido.\n Ruta: ${filePath}\n Detalle: ${err.message}`);
487
+ }
488
+ control = normalizeControlShape(control);
489
+ validateControl(control);
490
+ return control;
491
+ }
492
+
493
+ function saveControl(contextOrRoot, control, options) {
494
+ const normalized = normalizeControlShape(control);
495
+ if (process.env.TRACKOPS_ASSERT === "1") {
496
+ require("./quality-assert").assertControlInvariants(normalized, { context: ensureContext(contextOrRoot) });
497
+ }
498
+ normalized.meta = normalized.meta || {};
499
+ if (!(options && options.skipTimestamp)) {
500
+ normalized.meta.updatedAt = new Date().toISOString();
501
+ }
502
+ const filePath = controlFilePath(contextOrRoot);
503
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
504
+ fs.writeFileSync(filePath, JSON.stringify(normalized, null, 2) + "\n", "utf8");
505
+ return normalized;
506
+ }
284
507
 
285
508
  function loadWorkspaceManifest(contextOrRoot) {
286
509
  const context = ensureContext(contextOrRoot);
@@ -300,9 +523,13 @@ module.exports = {
300
523
  DEFAULT_LOCALE,
301
524
  DEFAULT_APP_DIR,
302
525
  DEFAULT_OPS_DIR,
303
- DEFAULT_DEV_BRANCH,
304
- DEFAULT_PUBLISH_BRANCH,
305
- WORKSPACE_MANIFEST,
526
+ DEFAULT_DEV_BRANCH,
527
+ DEFAULT_PUBLISH_BRANCH,
528
+ DEFAULT_CONTROL_VERSION,
529
+ DEFAULT_MANAGED_PLAN_FIELDS,
530
+ DEFAULT_EXECUTION_OWNER,
531
+ EXECUTION_OWNERS,
532
+ WORKSPACE_MANIFEST,
306
533
  buildDefaultPhases,
307
534
  createSplitContext,
308
535
  createLegacyContext,
@@ -319,8 +546,14 @@ module.exports = {
319
546
  saveWorkspaceManifest,
320
547
  getPhases,
321
548
  getLocale,
322
- isOperaInstalled,
323
- getOperaVersion,
324
- loadControl,
325
- saveControl,
326
- };
549
+ isOperaInstalled,
550
+ getOperaVersion,
551
+ normalizeExecutionOwner,
552
+ normalizeTaskExecution,
553
+ normalizeAgentInbox,
554
+ normalizeTaskShape,
555
+ normalizeQualityMeta,
556
+ normalizeControlShape,
557
+ loadControl,
558
+ saveControl,
559
+ };