trackops 2.0.2 → 2.0.4

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 (48) hide show
  1. package/README.md +238 -0
  2. package/lib/init.js +2 -2
  3. package/lib/locale.js +41 -17
  4. package/lib/opera-bootstrap.js +68 -7
  5. package/lib/opera.js +10 -2
  6. package/lib/registry.js +18 -0
  7. package/lib/server.js +312 -207
  8. package/locales/en.json +4 -0
  9. package/locales/es.json +4 -0
  10. package/package.json +1 -1
  11. package/skills/trackops/SKILL.md +39 -4
  12. package/skills/trackops/agents/openai.yaml +2 -2
  13. package/skills/trackops/locales/en/SKILL.md +39 -4
  14. package/skills/trackops/locales/en/references/activation.md +15 -0
  15. package/skills/trackops/locales/en/references/troubleshooting.md +12 -0
  16. package/skills/trackops/references/activation.md +15 -0
  17. package/skills/trackops/references/troubleshooting.md +12 -0
  18. package/skills/trackops/skill.json +4 -4
  19. package/ui/css/base.css +19 -1
  20. package/ui/css/charts.css +106 -8
  21. package/ui/css/components.css +554 -17
  22. package/ui/css/onboarding.css +133 -0
  23. package/ui/css/panels.css +345 -406
  24. package/ui/css/terminal.css +125 -0
  25. package/ui/css/timeline.css +58 -0
  26. package/ui/css/tokens.css +170 -113
  27. package/ui/index.html +3 -0
  28. package/ui/js/api.js +49 -13
  29. package/ui/js/app.js +28 -32
  30. package/ui/js/charts.js +526 -0
  31. package/ui/js/filters.js +247 -0
  32. package/ui/js/icons.js +82 -57
  33. package/ui/js/keyboard.js +229 -0
  34. package/ui/js/onboarding.js +33 -42
  35. package/ui/js/router.js +20 -3
  36. package/ui/js/views/board.js +84 -114
  37. package/ui/js/views/dashboard.js +870 -0
  38. package/ui/js/views/projects.js +745 -0
  39. package/ui/js/views/scrum.js +476 -0
  40. package/ui/js/views/settings.js +197 -247
  41. package/ui/js/views/sidebar.js +37 -31
  42. package/ui/js/views/tasks.js +218 -101
  43. package/ui/js/views/timeline.js +265 -0
  44. package/ui/js/views/topbar.js +94 -107
  45. package/ui/app.js +0 -950
  46. package/ui/js/views/insights.js +0 -340
  47. package/ui/js/views/overview.js +0 -369
  48. package/ui/styles.css +0 -688
package/lib/server.js CHANGED
@@ -7,13 +7,13 @@ const os = require("os");
7
7
  const path = require("path");
8
8
  const { spawn, spawnSync } = require("child_process");
9
9
 
10
- const config = require("./config");
11
- const control = require("./control");
12
- const env = require("./env");
13
- const registry = require("./registry");
14
- const { t, setLocale, getMessages } = require("./i18n");
15
- const { normalizeLocale } = require("./locale");
16
- const runtimeState = require("./runtime-state");
10
+ const config = require("./config");
11
+ const control = require("./control");
12
+ const env = require("./env");
13
+ const registry = require("./registry");
14
+ const { t, setLocale, getMessages } = require("./i18n");
15
+ const { normalizeLocale } = require("./locale");
16
+ const runtimeState = require("./runtime-state");
17
17
 
18
18
  const UI_DIR = path.join(__dirname, "..", "ui");
19
19
  const DEFAULT_HOST = "127.0.0.1";
@@ -42,19 +42,19 @@ function sendJson(res, statusCode, payload) {
42
42
  res.end(JSON.stringify(payload));
43
43
  }
44
44
 
45
- function sendText(res, statusCode, message) {
46
- res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
47
- res.end(message);
48
- }
49
-
50
- function readJsonFileSafe(filePath) {
51
- if (!filePath || !fs.existsSync(filePath)) return null;
52
- try {
53
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
54
- } catch (_error) {
55
- return null;
56
- }
57
- }
45
+ function sendText(res, statusCode, message) {
46
+ res.writeHead(statusCode, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
47
+ res.end(message);
48
+ }
49
+
50
+ function readJsonFileSafe(filePath) {
51
+ if (!filePath || !fs.existsSync(filePath)) return null;
52
+ try {
53
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
54
+ } catch (_error) {
55
+ return null;
56
+ }
57
+ }
58
58
 
59
59
  function parseBody(req) {
60
60
  return new Promise((resolve, reject) => {
@@ -365,61 +365,61 @@ function loadControlApi(projectRoot) {
365
365
  return control.forProject(projectRoot);
366
366
  }
367
367
 
368
- function buildI18nPayload(controlState) {
368
+ function buildI18nPayload(controlState) {
369
369
  const phases = config.getPhases(controlState);
370
370
  const locale = config.getLocale(controlState);
371
371
  const statusLabels = {};
372
372
  for (const s of control.STATUS_ORDER) {
373
373
  statusLabels[s] = control.statusLabel(s);
374
374
  }
375
- return { locale, statusLabels, phases, messages: getMessages(locale) };
376
- }
377
-
378
- function buildOperaState(projectRoot, controlState) {
379
- const context = config.ensureContext(projectRoot);
380
- const operaBootstrap = require("./opera-bootstrap");
381
- const bootstrapState =
382
- controlState.meta?.opera?.bootstrap ||
383
- operaBootstrap.detectLegacyBootstrap(context, controlState) ||
384
- operaBootstrap.createAwaitingBootstrapState(context);
385
- const qualityReport = readJsonFileSafe(path.join(context.paths.bootstrapDir, "quality-report.json"));
386
- const localeDoctor = runtimeState.doctorLocale(controlState.meta?.locale || null);
387
-
388
- return {
389
- installed: config.isOperaInstalled(controlState),
390
- version: controlState.meta?.opera?.version || null,
391
- model: controlState.meta?.opera?.model || null,
392
- stableTag: controlState.meta?.opera?.stableTag || null,
393
- contractVersion: controlState.meta?.opera?.contractVersion || null,
394
- contractReadiness: controlState.meta?.opera?.contractReadiness || qualityReport?.contractReadiness || "hypothesis",
395
- qualityStatus: controlState.meta?.opera?.qualityStatus || qualityReport?.status || null,
396
- qualityReport,
397
- legacyStatus: controlState.meta?.opera?.legacyStatus || bootstrapState?.status || "supported",
398
- localeSource: localeDoctor.source,
399
- bootstrap: bootstrapState,
400
- contractFile: context.paths.contractFile,
401
- policyFile: context.paths.autonomyPolicyFile,
402
- };
403
- }
404
-
405
- function getStatePayload(projectRef) {
406
- const project = resolveProjectEntry(projectRef);
407
- const api = loadControlApi(project.root);
408
- const controlState = api.loadControl();
409
- const runtime = api.refreshRepoRuntime({ quiet: true });
410
- const envState = env.auditEnvironment(project.root, controlState);
411
- const operaState = buildOperaState(project.root, controlState);
412
-
413
- return {
414
- project,
415
- control: controlState,
416
- derived: api.derive(controlState),
417
- runtime,
418
- env: envState,
419
- opera: operaState,
420
- docsDirty: api.getDocDrift(controlState),
421
- i18n: buildI18nPayload(controlState),
422
- generatedAt: new Date().toISOString(),
375
+ return { locale, statusLabels, phases, messages: getMessages(locale) };
376
+ }
377
+
378
+ function buildOperaState(projectRoot, controlState) {
379
+ const context = config.ensureContext(projectRoot);
380
+ const operaBootstrap = require("./opera-bootstrap");
381
+ const bootstrapState =
382
+ controlState.meta?.opera?.bootstrap ||
383
+ operaBootstrap.detectLegacyBootstrap(context, controlState) ||
384
+ operaBootstrap.createAwaitingBootstrapState(context);
385
+ const qualityReport = readJsonFileSafe(path.join(context.paths.bootstrapDir, "quality-report.json"));
386
+ const localeDoctor = runtimeState.doctorLocale(controlState.meta?.locale || null);
387
+
388
+ return {
389
+ installed: config.isOperaInstalled(controlState),
390
+ version: controlState.meta?.opera?.version || null,
391
+ model: controlState.meta?.opera?.model || null,
392
+ stableTag: controlState.meta?.opera?.stableTag || null,
393
+ contractVersion: controlState.meta?.opera?.contractVersion || null,
394
+ contractReadiness: controlState.meta?.opera?.contractReadiness || qualityReport?.contractReadiness || "hypothesis",
395
+ qualityStatus: controlState.meta?.opera?.qualityStatus || qualityReport?.status || null,
396
+ qualityReport,
397
+ legacyStatus: controlState.meta?.opera?.legacyStatus || bootstrapState?.status || "supported",
398
+ localeSource: localeDoctor.source,
399
+ bootstrap: bootstrapState,
400
+ contractFile: context.paths.contractFile,
401
+ policyFile: context.paths.autonomyPolicyFile,
402
+ };
403
+ }
404
+
405
+ function getStatePayload(projectRef) {
406
+ const project = resolveProjectEntry(projectRef);
407
+ const api = loadControlApi(project.root);
408
+ const controlState = api.loadControl();
409
+ const runtime = api.refreshRepoRuntime({ quiet: true });
410
+ const envState = env.auditEnvironment(project.root, controlState);
411
+ const operaState = buildOperaState(project.root, controlState);
412
+
413
+ return {
414
+ project,
415
+ control: controlState,
416
+ derived: api.derive(controlState),
417
+ runtime,
418
+ env: envState,
419
+ opera: operaState,
420
+ docsDirty: api.getDocDrift(controlState),
421
+ i18n: buildI18nPayload(controlState),
422
+ generatedAt: new Date().toISOString(),
423
423
  };
424
424
  }
425
425
 
@@ -650,29 +650,50 @@ async function handleApi(req, res, url) {
650
650
  if (!body.root) { sendJson(res, 400, { ok: false, error: "Project path required." }); return; }
651
651
  try {
652
652
  const initMod = require("./init");
653
- const result = initMod.initProject(body.root, { locale: body.locale || null });
654
- if (body.withOpera) {
655
- const opera = require("./opera");
656
- await opera.install(result.root, {
657
- locale: body.locale || null,
658
- bootstrap: body.bootstrap !== false,
659
- interactive: false,
660
- answers: body.bootstrapAnswers || {},
661
- bootstrapMode: body.bootstrapMode || "auto",
662
- technicalLevel: body.technicalLevel || null,
663
- projectState: body.projectState || null,
664
- docsState: body.docsState || null,
665
- decisionOwnership: body.decisionOwnership || null,
666
- });
667
- }
668
- const project = registry.registerProject(result.root);
669
- sendJson(res, 201, { ok: true, project, projects: registry.listProjects() });
653
+ const result = initMod.initProject(body.root, { locale: body.locale || null });
654
+ if (body.withOpera) {
655
+ const opera = require("./opera");
656
+ await opera.install(result.root, {
657
+ locale: body.locale || null,
658
+ bootstrap: body.bootstrap !== false,
659
+ interactive: false,
660
+ answers: body.bootstrapAnswers || {},
661
+ bootstrapMode: body.bootstrapMode || "auto",
662
+ technicalLevel: body.technicalLevel || null,
663
+ projectState: body.projectState || null,
664
+ docsState: body.docsState || null,
665
+ decisionOwnership: body.decisionOwnership || null,
666
+ });
667
+ }
668
+ const project = registry.registerProject(result.root);
669
+ sendJson(res, 201, { ok: true, project, projects: registry.listProjects() });
670
670
  } catch (err) {
671
671
  sendJson(res, 500, { ok: false, error: err.message });
672
672
  }
673
673
  return;
674
674
  }
675
675
 
676
+ /* POST /api/projects/purge-unavailable — Elimina todos los proyectos no disponibles */
677
+ if (req.method === "POST" && url.pathname === "/api/projects/purge-unavailable") {
678
+ try {
679
+ const { removed } = registry.purgeUnavailable();
680
+ sendJson(res, 200, { ok: true, removed, projects: registry.listProjects() });
681
+ } catch (err) {
682
+ sendJson(res, 500, { ok: false, error: err.message });
683
+ }
684
+ return;
685
+ }
686
+
687
+ /* DELETE /api/projects/:id — Desregistrar proyecto */
688
+ if (req.method === "DELETE" && /^\/api\/projects\/[^/]+$/.test(url.pathname)) {
689
+ const projectId = decodeURIComponent(url.pathname.split("/").pop());
690
+ const exists = registry.loadRegistry().projects.find((p) => p.id === projectId);
691
+ if (!exists) { sendJson(res, 404, { ok: false, error: "Project not found." }); return; }
692
+ registry.unregisterById(projectId);
693
+ sendJson(res, 200, { ok: true, projects: registry.listProjects() });
694
+ return;
695
+ }
696
+
676
697
  if (req.method === "GET" && url.pathname === "/api/state") {
677
698
  sendJson(res, 200, getStatePayload(url.searchParams.get("project")));
678
699
  return;
@@ -720,7 +741,7 @@ async function handleApi(req, res, url) {
720
741
  return;
721
742
  }
722
743
 
723
- if (req.method === "POST" && url.pathname === "/api/sync") {
744
+ if (req.method === "POST" && url.pathname === "/api/sync") {
724
745
  const body = await parseBody(req);
725
746
  const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
726
747
  const api = loadControlApi(project.root);
@@ -728,116 +749,200 @@ async function handleApi(req, res, url) {
728
749
  api.syncDocs(controlState);
729
750
  api.refreshRepoRuntime({ quiet: true });
730
751
  sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
731
- return;
732
- }
733
-
734
- if (req.method === "GET" && url.pathname === "/api/env") {
735
- const project = resolveProjectEntry(url.searchParams.get("project"));
736
- const api = loadControlApi(project.root);
737
- const controlState = api.loadControl();
738
- sendJson(res, 200, env.auditEnvironment(project.root, controlState));
739
- return;
740
- }
741
-
742
- if (req.method === "POST" && url.pathname === "/api/env/sync") {
743
- const body = await parseBody(req);
744
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
745
- const api = loadControlApi(project.root);
746
- const controlState = api.loadControl();
747
- const result = env.syncEnvironment(project.root, controlState);
748
- api.syncDocs(api.loadControl());
749
- api.refreshRepoRuntime({ quiet: true });
750
- sendJson(res, 200, result);
751
- return;
752
- }
753
-
754
- if (req.method === "GET" && url.pathname === "/api/opera/bootstrap") {
755
- const project = resolveProjectEntry(url.searchParams.get("project"));
756
- const api = loadControlApi(project.root);
757
- const controlState = api.loadControl();
758
- const operaState = buildOperaState(project.root, controlState);
759
- const bootstrap = operaState.bootstrap;
760
- sendJson(res, 200, {
761
- ok: true,
762
- mode: bootstrap.mode || null,
763
- status: bootstrap.status || "awaiting_intake",
764
- technicalLevel: bootstrap.technicalLevel || null,
765
- projectState: bootstrap.projectState || null,
766
- documentationState: bootstrap.documentationState || null,
767
- decisionOwnership: bootstrap.decisionOwnership || null,
768
- handoffFiles: bootstrap.handoffFiles || null,
769
- intakeFiles: bootstrap.intakeFiles || null,
770
- reviewFiles: bootstrap.reviewFiles || null,
771
- contractVersion: operaState.contractVersion,
772
- contractReadiness: operaState.contractReadiness,
773
- legacyStatus: operaState.legacyStatus,
774
- qualityReport: operaState.qualityReport,
775
- });
776
- return;
777
- }
778
-
779
- if (req.method === "GET" && url.pathname === "/api/opera/handoff") {
780
- const project = resolveProjectEntry(url.searchParams.get("project"));
781
- const context = config.ensureContext(project.root);
782
- const operaBootstrap = require("./opera-bootstrap");
783
- const files = operaBootstrap.bootstrapFilePaths(context);
784
- let handoffJson = null;
785
- if (fs.existsSync(files.json)) {
786
- try {
787
- handoffJson = JSON.parse(fs.readFileSync(files.json, "utf8"));
788
- } catch (_error) {
789
- handoffJson = null;
790
- }
791
- }
792
- sendJson(res, 200, {
793
- ok: true,
794
- markdownFile: files.markdown,
795
- jsonFile: files.json,
796
- openQuestionsFile: files.openQuestions,
797
- qualityReportFile: files.qualityReport,
798
- markdown: fs.existsSync(files.markdown) ? fs.readFileSync(files.markdown, "utf8") : "",
799
- json: handoffJson,
800
- openQuestions: fs.existsSync(files.openQuestions) ? fs.readFileSync(files.openQuestions, "utf8") : "",
801
- qualityReport: readJsonFileSafe(files.qualityReport),
802
- });
803
- return;
804
- }
805
-
806
- if (req.method === "POST" && url.pathname === "/api/opera/bootstrap/intake") {
807
- const body = await parseBody(req);
808
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
809
- const opera = require("./opera");
810
- const profile = await opera.runBootstrap(project.root, {
811
- interactive: false,
812
- bootstrapMode: body.bootstrapMode || "auto",
813
- technicalLevel: body.technicalLevel || null,
814
- projectState: body.projectState || null,
815
- docsState: body.documentationState || body.docsState || null,
816
- decisionOwnership: body.decisionOwnership || null,
817
- answers: body.answers || {},
818
- });
819
- sendJson(res, 200, { ok: true, profile, state: getStatePayload(project.id) });
820
- return;
821
- }
822
-
823
- if (req.method === "GET" && url.pathname === "/api/opera/status") {
824
- const project = resolveProjectEntry(url.searchParams.get("project"));
825
- const api = loadControlApi(project.root);
826
- const controlState = api.loadControl();
827
- sendJson(res, 200, { ok: true, ...buildOperaState(project.root, controlState) });
828
- return;
829
- }
830
-
831
- if (req.method === "POST" && url.pathname === "/api/opera/bootstrap/resume") {
832
- const body = await parseBody(req);
833
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
834
- const opera = require("./opera");
835
- const profile = await opera.runBootstrap(project.root, { interactive: false, resume: true });
836
- sendJson(res, 200, { ok: true, profile, state: getStatePayload(project.id) });
837
- return;
838
- }
839
-
840
- if (req.method === "POST" && url.pathname === "/api/commands") {
752
+ return;
753
+ }
754
+
755
+ /* GET /api/analytics Datos analíticos computados */
756
+ if (req.method === "GET" && url.pathname === "/api/analytics") {
757
+ const project = resolveProjectEntry(url.searchParams.get("project"));
758
+ const api = loadControlApi(project.root);
759
+ const controlState = api.loadControl();
760
+ const range = url.searchParams.get("range") || "30";
761
+ const tasks = controlState.tasks || [];
762
+ const now = new Date();
763
+
764
+ // Rango de fechas
765
+ const rangeDays = range === "all" ? 365 : parseInt(range, 10) || 30;
766
+ const startDate = new Date(now);
767
+ startDate.setDate(startDate.getDate() - rangeDays);
768
+ const startISO = startDate.toISOString().slice(0, 10);
769
+
770
+ // Velocity: completions por semana
771
+ const velocity = [];
772
+ const completions = tasks.flatMap(t =>
773
+ (t.history || []).filter(h => h.action === "complete" && h.at >= startISO).map(h => h.at.slice(0, 10))
774
+ );
775
+ const weekMap = new Map();
776
+ completions.forEach(dateStr => {
777
+ const d = new Date(dateStr);
778
+ const year = d.getFullYear();
779
+ const jan1 = new Date(year, 0, 1);
780
+ const weekNum = Math.ceil(((d - jan1) / 86400000 + jan1.getDay() + 1) / 7);
781
+ const weekKey = `${year}-W${String(weekNum).padStart(2, "0")}`;
782
+ weekMap.set(weekKey, (weekMap.get(weekKey) || 0) + 1);
783
+ });
784
+ for (const [week, completed] of [...weekMap.entries()].sort()) {
785
+ velocity.push({ week, completed });
786
+ }
787
+
788
+ // Burndown: remaining required tasks over time
789
+ const burndown = [];
790
+ const requiredTasks = tasks.filter(t => t.required !== false);
791
+ for (let d = new Date(startDate); d <= now; d.setDate(d.getDate() + 1)) {
792
+ const dateStr = d.toISOString().slice(0, 10);
793
+ const remaining = requiredTasks.filter(t => {
794
+ const completeEntry = (t.history || []).find(h => h.action === "complete");
795
+ return !completeEntry || completeEntry.at.slice(0, 10) > dateStr;
796
+ }).length;
797
+ burndown.push({ date: dateStr, remaining });
798
+ }
799
+
800
+ // Heatmap: activity counts per day
801
+ const heatmap = [];
802
+ const dayMap = new Map();
803
+ tasks.forEach(t => {
804
+ (t.history || []).forEach(h => {
805
+ if (h.at >= startISO) {
806
+ const day = h.at.slice(0, 10);
807
+ dayMap.set(day, (dayMap.get(day) || 0) + 1);
808
+ }
809
+ });
810
+ });
811
+ for (const [date, count] of [...dayMap.entries()].sort()) {
812
+ heatmap.push({ date, count });
813
+ }
814
+
815
+ // Average cycle time (start → complete)
816
+ let totalCycle = 0, cycleCount = 0;
817
+ tasks.forEach(t => {
818
+ const startEntry = (t.history || []).find(h => h.action === "start" || h.action === "create");
819
+ const completeEntry = (t.history || []).find(h => h.action === "complete");
820
+ if (startEntry && completeEntry) {
821
+ totalCycle += new Date(completeEntry.at) - new Date(startEntry.at);
822
+ cycleCount++;
823
+ }
824
+ });
825
+ const avgCycleTime = cycleCount > 0 ? Math.round(totalCycle / cycleCount) : 0;
826
+
827
+ sendJson(res, 200, {
828
+ ok: true,
829
+ velocity,
830
+ burndown: burndown.filter((_, i) => i % Math.max(1, Math.floor(burndown.length / 60)) === 0), // Max ~60 points
831
+ heatmap,
832
+ avgCycleTime,
833
+ range,
834
+ generatedAt: now.toISOString(),
835
+ });
836
+ return;
837
+ }
838
+
839
+ if (req.method === "GET" && url.pathname === "/api/env") {
840
+ const project = resolveProjectEntry(url.searchParams.get("project"));
841
+ const api = loadControlApi(project.root);
842
+ const controlState = api.loadControl();
843
+ sendJson(res, 200, env.auditEnvironment(project.root, controlState));
844
+ return;
845
+ }
846
+
847
+ if (req.method === "POST" && url.pathname === "/api/env/sync") {
848
+ const body = await parseBody(req);
849
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
850
+ const api = loadControlApi(project.root);
851
+ const controlState = api.loadControl();
852
+ const result = env.syncEnvironment(project.root, controlState);
853
+ api.syncDocs(api.loadControl());
854
+ api.refreshRepoRuntime({ quiet: true });
855
+ sendJson(res, 200, result);
856
+ return;
857
+ }
858
+
859
+ if (req.method === "GET" && url.pathname === "/api/opera/bootstrap") {
860
+ const project = resolveProjectEntry(url.searchParams.get("project"));
861
+ const api = loadControlApi(project.root);
862
+ const controlState = api.loadControl();
863
+ const operaState = buildOperaState(project.root, controlState);
864
+ const bootstrap = operaState.bootstrap;
865
+ sendJson(res, 200, {
866
+ ok: true,
867
+ mode: bootstrap.mode || null,
868
+ status: bootstrap.status || "awaiting_intake",
869
+ technicalLevel: bootstrap.technicalLevel || null,
870
+ projectState: bootstrap.projectState || null,
871
+ documentationState: bootstrap.documentationState || null,
872
+ decisionOwnership: bootstrap.decisionOwnership || null,
873
+ handoffFiles: bootstrap.handoffFiles || null,
874
+ intakeFiles: bootstrap.intakeFiles || null,
875
+ reviewFiles: bootstrap.reviewFiles || null,
876
+ contractVersion: operaState.contractVersion,
877
+ contractReadiness: operaState.contractReadiness,
878
+ legacyStatus: operaState.legacyStatus,
879
+ qualityReport: operaState.qualityReport,
880
+ });
881
+ return;
882
+ }
883
+
884
+ if (req.method === "GET" && url.pathname === "/api/opera/handoff") {
885
+ const project = resolveProjectEntry(url.searchParams.get("project"));
886
+ const context = config.ensureContext(project.root);
887
+ const operaBootstrap = require("./opera-bootstrap");
888
+ const files = operaBootstrap.bootstrapFilePaths(context);
889
+ let handoffJson = null;
890
+ if (fs.existsSync(files.json)) {
891
+ try {
892
+ handoffJson = JSON.parse(fs.readFileSync(files.json, "utf8"));
893
+ } catch (_error) {
894
+ handoffJson = null;
895
+ }
896
+ }
897
+ sendJson(res, 200, {
898
+ ok: true,
899
+ markdownFile: files.markdown,
900
+ jsonFile: files.json,
901
+ openQuestionsFile: files.openQuestions,
902
+ qualityReportFile: files.qualityReport,
903
+ markdown: fs.existsSync(files.markdown) ? fs.readFileSync(files.markdown, "utf8") : "",
904
+ json: handoffJson,
905
+ openQuestions: fs.existsSync(files.openQuestions) ? fs.readFileSync(files.openQuestions, "utf8") : "",
906
+ qualityReport: readJsonFileSafe(files.qualityReport),
907
+ });
908
+ return;
909
+ }
910
+
911
+ if (req.method === "POST" && url.pathname === "/api/opera/bootstrap/intake") {
912
+ const body = await parseBody(req);
913
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
914
+ const opera = require("./opera");
915
+ const profile = await opera.runBootstrap(project.root, {
916
+ interactive: false,
917
+ bootstrapMode: body.bootstrapMode || "auto",
918
+ technicalLevel: body.technicalLevel || null,
919
+ projectState: body.projectState || null,
920
+ docsState: body.documentationState || body.docsState || null,
921
+ decisionOwnership: body.decisionOwnership || null,
922
+ answers: body.answers || {},
923
+ });
924
+ sendJson(res, 200, { ok: true, profile, state: getStatePayload(project.id) });
925
+ return;
926
+ }
927
+
928
+ if (req.method === "GET" && url.pathname === "/api/opera/status") {
929
+ const project = resolveProjectEntry(url.searchParams.get("project"));
930
+ const api = loadControlApi(project.root);
931
+ const controlState = api.loadControl();
932
+ sendJson(res, 200, { ok: true, ...buildOperaState(project.root, controlState) });
933
+ return;
934
+ }
935
+
936
+ if (req.method === "POST" && url.pathname === "/api/opera/bootstrap/resume") {
937
+ const body = await parseBody(req);
938
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
939
+ const opera = require("./opera");
940
+ const profile = await opera.runBootstrap(project.root, { interactive: false, resume: true });
941
+ sendJson(res, 200, { ok: true, profile, state: getStatePayload(project.id) });
942
+ return;
943
+ }
944
+
945
+ if (req.method === "POST" && url.pathname === "/api/commands") {
841
946
  const body = await parseBody(req);
842
947
  const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
843
948
  const commandText = String(body.command || "").trim();
@@ -921,10 +1026,10 @@ async function handleApi(req, res, url) {
921
1026
 
922
1027
  /* ── Skills Hub ── */
923
1028
 
924
- if (req.method === "GET" && url.pathname === "/api/skills/local") {
925
- const project = resolveProjectEntry(url.searchParams.get("project"));
926
- const context = config.ensureContext(project.root);
927
- const skillsDir = fs.existsSync(context.paths.skillsDir) ? context.paths.skillsDir : null;
1029
+ if (req.method === "GET" && url.pathname === "/api/skills/local") {
1030
+ const project = resolveProjectEntry(url.searchParams.get("project"));
1031
+ const context = config.ensureContext(project.root);
1032
+ const skillsDir = fs.existsSync(context.paths.skillsDir) ? context.paths.skillsDir : null;
928
1033
 
929
1034
  const skills = [];
930
1035
  if (skillsDir) {
@@ -961,7 +1066,7 @@ async function handleApi(req, res, url) {
961
1066
  const catalog = [
962
1067
  { id: "changelog-updater", title: "Changelog Updater", description: "Mantiene automatizado el CHANGELOG basado en commits.", url: "https://skills.sh/changelog-updater.md" },
963
1068
  { id: "commiter", title: "Git Commiter", description: "Genera mensajes de commit strictos siguiendo Conventional Commits y Emojis.", url: "https://skills.sh/commiter.md" },
964
- { id: "project-starter-skill", title: "Project Starter", description: "Skill para discovery y estructuracion inicial guiada con TrackOps y OPERA.", url: "https://skills.sh/project-starter.md" },
1069
+ { id: "project-starter-skill", title: "Project Starter", description: "Skill para discovery y estructuracion inicial guiada con TrackOps y OPERA.", url: "https://skills.sh/project-starter.md" },
965
1070
  { id: "tdd-master", title: "TDD Master", description: "Fuerza el ciclo Red-Green-Refactor en las implementaciones.", url: "https://skills.sh/tdd-master.md" },
966
1071
  { id: "e2e-tester", title: "E2E Tester", description: "Plantillas y comandos para frameworks de Test End-to-End.", url: "https://skills.sh/e2e-tester.md" }
967
1072
  ];
@@ -972,16 +1077,16 @@ async function handleApi(req, res, url) {
972
1077
  return;
973
1078
  }
974
1079
 
975
- if (req.method === "POST" && url.pathname === "/api/skills/install") {
1080
+ if (req.method === "POST" && url.pathname === "/api/skills/install") {
976
1081
  const body = await parseBody(req);
977
1082
  const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
978
1083
  const skillId = body.skillId;
979
1084
 
980
1085
  if (!skillId) { sendJson(res, 400, { ok: false, error: "Missing skillId parameter" }); return; }
981
1086
 
982
- const context = config.ensureContext(project.root);
983
- const skillsDir = context.paths.skillsDir;
984
- if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });
1087
+ const context = config.ensureContext(project.root);
1088
+ const skillsDir = context.paths.skillsDir;
1089
+ if (!fs.existsSync(skillsDir)) fs.mkdirSync(skillsDir, { recursive: true });
985
1090
 
986
1091
  const targetSkillDir = path.join(skillsDir, skillId);
987
1092
  if (!fs.existsSync(targetSkillDir)) fs.mkdirSync(targetSkillDir, { recursive: true });
@@ -1012,7 +1117,7 @@ Instructions para el agente relativas a esta skill...
1012
1117
  /* ── server start ── */
1013
1118
 
1014
1119
  async function run(args = []) {
1015
- startupRoot = config.resolveProjectRoot() || process.cwd();
1120
+ startupRoot = config.resolveProjectRoot() || process.cwd();
1016
1121
 
1017
1122
  try {
1018
1123
  const ctrl = config.loadControl(startupRoot);
package/locales/en.json CHANGED
@@ -229,6 +229,7 @@
229
229
 
230
230
  "bootstrap.header": "OPERA bootstrap",
231
231
  "bootstrap.subtitle": "Classify the user and the project first. TrackOps will decide whether to continue in the terminal or route discovery to the agent.",
232
+ "bootstrap.instructions": "Answer with low|medium|high|senior, or press Enter to keep the default value when you are unsure.",
232
233
  "bootstrap.question.technicalLevel": "User technical level",
233
234
  "bootstrap.question.projectState": "Current project state",
234
235
  "bootstrap.question.docsState": "Available documentation",
@@ -250,6 +251,9 @@
250
251
  "bootstrap.pending": "OPERA bootstrap saved as pending. Resume with 'trackops opera bootstrap --resume'.",
251
252
  "bootstrap.awaitingAgent": "OPERA bootstrap has been routed to the agent. Complete the handoff and resume with 'trackops opera bootstrap --resume'.",
252
253
  "bootstrap.handoffFile": "Handoff file",
254
+ "bootstrap.next.handoff": "Next step: run 'trackops opera handoff --print', paste the context into your agent, and come back when ops/bootstrap/intake.json and ops/bootstrap/spec-dossier.md exist.",
255
+ "bootstrap.next.directCompleted": "Next step: review 'trackops opera status' and continue normal work with 'trackops next' and 'trackops sync'.",
256
+ "bootstrap.next.directPending": "Next step: complete the missing data and resume with 'trackops opera bootstrap --resume'.",
253
257
  "bootstrap.infer.envSourceHint": "Environment files detected",
254
258
  "bootstrap.noneDefined": "Not defined yet.",
255
259
  "bootstrap.pendingValue": "Pending definition.",
package/locales/es.json CHANGED
@@ -229,6 +229,7 @@
229
229
 
230
230
  "bootstrap.header": "Bootstrap OPERA",
231
231
  "bootstrap.subtitle": "Primero clasifica al usuario y el estado del proyecto. TrackOps decidira si continuar por terminal o derivar el arranque al agente.",
232
+ "bootstrap.instructions": "Responde con low|medium|high|senior o con sus equivalentes bajo|medio|alto. Si no sabes una respuesta, pulsa Enter para aceptar el valor por defecto.",
232
233
  "bootstrap.question.technicalLevel": "Nivel tecnico del usuario",
233
234
  "bootstrap.question.projectState": "Estado actual del proyecto",
234
235
  "bootstrap.question.docsState": "Documentacion disponible",
@@ -250,6 +251,9 @@
250
251
  "bootstrap.pending": "Bootstrap OPERA guardado como pendiente. Reanuda con 'trackops opera bootstrap --resume'.",
251
252
  "bootstrap.awaitingAgent": "Bootstrap OPERA derivado al agente. Completa el handoff y reanuda con 'trackops opera bootstrap --resume'.",
252
253
  "bootstrap.handoffFile": "Archivo de handoff",
254
+ "bootstrap.next.handoff": "Siguiente paso: ejecuta 'trackops opera handoff --print', pega el contexto en tu agente y vuelve cuando existan ops/bootstrap/intake.json y ops/bootstrap/spec-dossier.md.",
255
+ "bootstrap.next.directCompleted": "Siguiente paso: revisa 'trackops opera status' y continua el trabajo normal con 'trackops next' y 'trackops sync'.",
256
+ "bootstrap.next.directPending": "Siguiente paso: completa los datos pendientes y reanuda con 'trackops opera bootstrap --resume'.",
253
257
  "bootstrap.infer.envSourceHint": "Detectados archivos de entorno",
254
258
  "bootstrap.noneDefined": "Aun no definido.",
255
259
  "bootstrap.pendingValue": "Pendiente de definir.",