trackops 2.0.6 → 2.2.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 (60) hide show
  1. package/README.md +307 -701
  2. package/bin/trackops.js +24 -16
  3. package/lib/config.js +265 -58
  4. package/lib/control.js +830 -292
  5. package/lib/init.js +46 -16
  6. package/lib/opera-bootstrap.js +85 -45
  7. package/lib/opera-phase-dod.js +485 -0
  8. package/lib/opera.js +8 -5
  9. package/lib/plans.js +1329 -0
  10. package/lib/quality-assert.js +49 -0
  11. package/lib/quality.js +1759 -0
  12. package/lib/release.js +18 -11
  13. package/lib/server.js +504 -192
  14. package/lib/skills.js +94 -41
  15. package/locales/en.json +249 -15
  16. package/locales/es.json +249 -15
  17. package/package.json +3 -2
  18. package/scripts/quality-unit-tests.js +130 -0
  19. package/scripts/skills-marketplace-smoke.js +156 -124
  20. package/scripts/smoke-tests.js +378 -71
  21. package/scripts/sync-skill-version.js +29 -19
  22. package/scripts/validate-skill.js +188 -103
  23. package/skills/trackops/SKILL.md +25 -7
  24. package/skills/trackops/locales/en/SKILL.md +25 -7
  25. package/skills/trackops/locales/en/references/activation.md +3 -3
  26. package/skills/trackops/locales/en/references/workflow.md +5 -4
  27. package/skills/trackops/references/activation.md +3 -3
  28. package/skills/trackops/references/workflow.md +5 -4
  29. package/skills/trackops/skill.json +29 -29
  30. package/skills/trackops-quality-guard/SKILL.md +78 -0
  31. package/skills/trackops-quality-guard/agents/openai.yaml +7 -0
  32. package/skills/trackops-quality-guard/locales/en/SKILL.md +78 -0
  33. package/skills/trackops-quality-guard/locales/en/references/commands.md +36 -0
  34. package/skills/trackops-quality-guard/locales/en/references/decision-policy.md +16 -0
  35. package/skills/trackops-quality-guard/locales/en/references/output-format.md +24 -0
  36. package/skills/trackops-quality-guard/references/commands.md +36 -0
  37. package/skills/trackops-quality-guard/references/decision-policy.md +16 -0
  38. package/skills/trackops-quality-guard/references/output-format.md +24 -0
  39. package/skills/trackops-quality-guard/skill.json +28 -0
  40. package/templates/skills/opera-skill/SKILL.md +12 -0
  41. package/templates/skills/opera-skill/locales/en/SKILL.md +12 -0
  42. package/templates/skills/trackops-quality-guard/SKILL.md +72 -0
  43. package/templates/skills/trackops-quality-guard/locales/en/SKILL.md +72 -0
  44. package/templates/skills/trackops-quality-guard/locales/en/references/commands.md +30 -0
  45. package/templates/skills/trackops-quality-guard/locales/en/references/decision-policy.md +14 -0
  46. package/templates/skills/trackops-quality-guard/locales/en/references/output-format.md +21 -0
  47. package/templates/skills/trackops-quality-guard/references/commands.md +30 -0
  48. package/templates/skills/trackops-quality-guard/references/decision-policy.md +14 -0
  49. package/templates/skills/trackops-quality-guard/references/output-format.md +21 -0
  50. package/ui/js/api.js +93 -26
  51. package/ui/js/app.js +13 -7
  52. package/ui/js/filters.js +49 -29
  53. package/ui/js/time-tracker.js +41 -28
  54. package/ui/js/views/board.js +22 -14
  55. package/ui/js/views/dashboard.js +206 -49
  56. package/ui/js/views/execution.js +7 -3
  57. package/ui/js/views/plans.js +284 -0
  58. package/ui/js/views/scrum.js +25 -13
  59. package/ui/js/views/sidebar.js +9 -8
  60. package/ui/js/views/tasks.js +238 -134
package/bin/trackops.js CHANGED
@@ -143,22 +143,30 @@ async function run() {
143
143
  break;
144
144
  }
145
145
 
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 "version":
159
- case "--version":
160
- case "-v":
161
- 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);
162
170
  break;
163
171
 
164
172
  case "help":
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,14 +292,178 @@ 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;
270
- }
271
-
272
- function validateControl(control) {
273
- if (!control || typeof control !== "object") throw new Error("project_control.json is not a valid object.");
274
- if (!control.meta || typeof control.meta !== "object") throw new Error("project_control.json is missing required field: meta");
275
- if (!Array.isArray(control.tasks)) throw new Error("project_control.json is missing required field: tasks");
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");
276
467
  for (let i = 0; i < control.tasks.length; i++) {
277
468
  const task = control.tasks[i];
278
469
  if (!task.id) throw new Error(`Task at index ${i} is missing required field: id`);
@@ -289,24 +480,30 @@ function loadControl(contextOrRoot) {
289
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}`);
290
481
  }
291
482
  let control;
292
- try {
293
- control = JSON.parse(raw);
294
- } catch (err) {
295
- throw new Error(`project_control.json esta corrupto o no es JSON valido.\n Ruta: ${filePath}\n Detalle: ${err.message}`);
296
- }
297
- validateControl(control);
298
- return control;
299
- }
300
-
301
- function saveControl(contextOrRoot, control, options) {
302
- control.meta = control.meta || {};
303
- if (!(options && options.skipTimestamp)) {
304
- control.meta.updatedAt = new Date().toISOString();
305
- }
306
- const filePath = controlFilePath(contextOrRoot);
307
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
308
- fs.writeFileSync(filePath, JSON.stringify(control, null, 2) + "\n", "utf8");
309
- }
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
+ }
310
507
 
311
508
  function loadWorkspaceManifest(contextOrRoot) {
312
509
  const context = ensureContext(contextOrRoot);
@@ -326,9 +523,13 @@ module.exports = {
326
523
  DEFAULT_LOCALE,
327
524
  DEFAULT_APP_DIR,
328
525
  DEFAULT_OPS_DIR,
329
- DEFAULT_DEV_BRANCH,
330
- DEFAULT_PUBLISH_BRANCH,
331
- 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,
332
533
  buildDefaultPhases,
333
534
  createSplitContext,
334
535
  createLegacyContext,
@@ -345,8 +546,14 @@ module.exports = {
345
546
  saveWorkspaceManifest,
346
547
  getPhases,
347
548
  getLocale,
348
- isOperaInstalled,
349
- getOperaVersion,
350
- loadControl,
351
- saveControl,
352
- };
549
+ isOperaInstalled,
550
+ getOperaVersion,
551
+ normalizeExecutionOwner,
552
+ normalizeTaskExecution,
553
+ normalizeAgentInbox,
554
+ normalizeTaskShape,
555
+ normalizeQualityMeta,
556
+ normalizeControlShape,
557
+ loadControl,
558
+ saveControl,
559
+ };