trackops 2.0.6 → 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/lib/server.js CHANGED
@@ -402,26 +402,31 @@ function buildOperaState(projectRoot, controlState) {
402
402
  };
403
403
  }
404
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
- };
424
- }
405
+ function getStatePayload(projectRef) {
406
+ const project = resolveProjectEntry(projectRef);
407
+ const api = loadControlApi(project.root);
408
+ const controlState = api.loadControl();
409
+ if (controlState.__trackopsMigrated) {
410
+ api.saveControl(controlState);
411
+ }
412
+ const runtime = api.refreshRepoRuntime({ quiet: true });
413
+ const envState = env.auditEnvironment(project.root, controlState);
414
+ const operaState = buildOperaState(project.root, controlState);
415
+ const quality = require("./quality");
416
+
417
+ return {
418
+ project,
419
+ control: controlState,
420
+ derived: api.derive(controlState),
421
+ runtime,
422
+ env: envState,
423
+ opera: operaState,
424
+ quality: quality.buildQualitySnapshot(project.root, controlState),
425
+ docsDirty: api.getDocDrift(controlState),
426
+ i18n: buildI18nPayload(controlState),
427
+ generatedAt: new Date().toISOString(),
428
+ };
429
+ }
425
430
 
426
431
  function persist(projectRoot) {
427
432
  const api = loadControlApi(projectRoot);
@@ -458,72 +463,152 @@ function updateProjectLocale(projectRoot, locale) {
458
463
 
459
464
  /* ── task operations ── */
460
465
 
461
- function makeTaskId(controlState, seed) {
462
- const base = slugify(seed) || `task-${Date.now()}`;
463
- const existing = new Set(controlState.tasks.map((t) => t.id));
464
- if (!existing.has(base)) return base;
465
- let idx = 2;
466
- while (existing.has(`${base}-${idx}`)) idx += 1;
467
- return `${base}-${idx}`;
468
- }
469
-
470
- function createTask(projectRoot, payload) {
471
- const api = loadControlApi(projectRoot);
472
- const controlState = api.loadControl();
473
- const title = String(payload.title || "").trim();
474
- if (!title) throw new Error(t("server.titleRequired"));
475
-
476
- const task = {
477
- id: makeTaskId(controlState, payload.id || title),
478
- title,
479
- phase: payload.phase || config.getPhases(controlState)[0]?.id || "E",
480
- stream: String(payload.stream || "Operations").trim(),
481
- priority: payload.priority || "P1",
466
+ function makeTaskId(controlState, seed) {
467
+ const base = slugify(seed) || `task-${Date.now()}`;
468
+ const existing = new Set(controlState.tasks.map((t) => t.id));
469
+ if (!existing.has(base)) return base;
470
+ let idx = 2;
471
+ while (existing.has(`${base}-${idx}`)) idx += 1;
472
+ return `${base}-${idx}`;
473
+ }
474
+
475
+ function normalizeSequenceValue(value) {
476
+ if (value === undefined || value === null || value === "") return null;
477
+ const numeric = Number(value);
478
+ return Number.isFinite(numeric) ? numeric : null;
479
+ }
480
+
481
+ function taskHasChildren(controlState, taskId) {
482
+ return (controlState.tasks || []).some((task) => String(task.parentId || "").trim() === taskId);
483
+ }
484
+
485
+ function assertValidParent(controlState, taskId, parentId) {
486
+ if (!parentId) return;
487
+ if (taskId && parentId === taskId) {
488
+ throw new Error("A task cannot be its own parent.");
489
+ }
490
+ const parent = (controlState.tasks || []).find((task) => task.id === parentId);
491
+ if (!parent) {
492
+ throw new Error(`Parent task not found: ${parentId}`);
493
+ }
494
+ if (!taskId) return;
495
+ const childrenByParent = new Map();
496
+ (controlState.tasks || []).forEach((task) => {
497
+ const key = String(task.parentId || "").trim();
498
+ if (!key) return;
499
+ if (!childrenByParent.has(key)) childrenByParent.set(key, []);
500
+ childrenByParent.get(key).push(task.id);
501
+ });
502
+ const stack = [...(childrenByParent.get(taskId) || [])];
503
+ const visited = new Set();
504
+ while (stack.length) {
505
+ const current = stack.pop();
506
+ if (visited.has(current)) continue;
507
+ if (current === parentId) {
508
+ throw new Error(`Parent cycle detected: '${taskId}' cannot move under '${parentId}'.`);
509
+ }
510
+ visited.add(current);
511
+ stack.push(...(childrenByParent.get(current) || []));
512
+ }
513
+ }
514
+
515
+ function createTask(projectRoot, payload) {
516
+ const api = loadControlApi(projectRoot);
517
+ const controlState = api.loadControl();
518
+ const title = String(payload.title || "").trim();
519
+ if (!title) throw new Error(t("server.titleRequired"));
520
+ const parentId = String(payload.parentId || "").trim() || null;
521
+ assertValidParent(controlState, null, parentId);
522
+
523
+ const task = config.normalizeTaskShape({
524
+ id: makeTaskId(controlState, payload.id || title),
525
+ title,
526
+ phase: payload.phase || config.getPhases(controlState)[0]?.id || "E",
527
+ stream: String(payload.stream || "Operations").trim(),
528
+ priority: payload.priority || "P1",
482
529
  status: payload.status || "pending",
483
530
  required: payload.required !== false,
484
- dependsOn: toList(payload.dependsOn),
485
- summary: String(payload.summary || "").trim(),
486
- acceptance: toList(payload.acceptance),
487
- history: [{ at: new Date().toISOString(), action: "create", note: t("server.taskCreatedNote") }],
488
- };
489
-
490
- const blocker = String(payload.blocker || "").trim();
491
- if (blocker) task.blocker = blocker;
492
- controlState.tasks.push(task);
493
- api.saveControl(controlState);
531
+ dependsOn: toList(payload.dependsOn),
532
+ summary: String(payload.summary || "").trim(),
533
+ acceptance: toList(payload.acceptance),
534
+ parentId,
535
+ sequence: normalizeSequenceValue(payload.sequence),
536
+ origin: { kind: "manual" },
537
+ execution: {
538
+ owner: config.normalizeExecutionOwner(payload.executionOwner || payload.execution?.owner),
539
+ lastActor: String(payload.actor || "user").trim() || "user",
540
+ lastSource: String(payload.source || "dashboard").trim() || "dashboard",
541
+ currentSessionId: null,
542
+ lastSessionId: null,
543
+ lastSessionStatus: null,
544
+ awaitingUserConfirmation: false,
545
+ verificationPending: false,
546
+ updatedAt: new Date().toISOString(),
547
+ },
548
+ history: [{ at: new Date().toISOString(), action: "create", note: t("server.taskCreatedNote") }],
549
+ });
550
+
551
+ const blocker = String(payload.blocker || "").trim();
552
+ if (blocker) task.blocker = blocker;
553
+ controlState.tasks.push(task);
554
+ api.saveControl(controlState);
494
555
  api.syncDocs(controlState);
495
556
  api.refreshRepoRuntime({ quiet: true });
496
557
  return task;
497
558
  }
498
559
 
499
- function patchTask(projectRoot, taskId, payload) {
500
- const api = loadControlApi(projectRoot);
501
- const controlState = api.loadControl();
502
- const task = controlState.tasks.find((t) => t.id === taskId);
503
- if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
504
-
505
- if (typeof payload.title === "string") task.title = payload.title.trim() || task.title;
506
- if (typeof payload.phase === "string") task.phase = payload.phase;
507
- if (typeof payload.stream === "string") task.stream = payload.stream.trim() || task.stream;
508
- if (typeof payload.priority === "string") task.priority = payload.priority;
509
- if (typeof payload.status === "string") task.status = payload.status;
510
- if (typeof payload.required === "boolean") task.required = payload.required;
511
- if (payload.summary !== undefined) task.summary = String(payload.summary || "").trim();
512
- if (payload.dependsOn !== undefined) task.dependsOn = toList(payload.dependsOn);
513
- if (payload.acceptance !== undefined) task.acceptance = toList(payload.acceptance);
514
-
515
- const blocker = String(payload.blocker || "").trim();
516
- if (blocker) task.blocker = blocker;
517
- else delete task.blocker;
518
-
519
- task.history = task.history || [];
520
- task.history.push({ at: new Date().toISOString(), action: "edit", note: String(payload.note || t("server.taskEditedNote")).trim() });
521
-
522
- api.saveControl(controlState);
523
- api.syncDocs(controlState);
524
- api.refreshRepoRuntime({ quiet: true });
525
- return task;
526
- }
560
+ function patchTask(projectRoot, taskId, payload) {
561
+ const api = loadControlApi(projectRoot);
562
+ const controlState = api.loadControl();
563
+ const task = controlState.tasks.find((t) => t.id === taskId);
564
+ if (!task) throw new Error(t("cli.taskNotFound", { taskId }));
565
+ const actor = String(payload.actor || "user").trim() || "user";
566
+ const source = String(payload.source || "dashboard").trim() || "dashboard";
567
+
568
+ if (typeof payload.title === "string") task.title = payload.title.trim() || task.title;
569
+ if (typeof payload.phase === "string") task.phase = payload.phase;
570
+ if (typeof payload.stream === "string") task.stream = payload.stream.trim() || task.stream;
571
+ if (typeof payload.priority === "string") task.priority = payload.priority;
572
+ if (typeof payload.required === "boolean") task.required = payload.required;
573
+ if (payload.summary !== undefined) task.summary = String(payload.summary || "").trim();
574
+ if (payload.dependsOn !== undefined) task.dependsOn = toList(payload.dependsOn);
575
+ if (payload.acceptance !== undefined) task.acceptance = toList(payload.acceptance);
576
+ if (payload.parentId !== undefined) {
577
+ const parentId = String(payload.parentId || "").trim() || null;
578
+ assertValidParent(controlState, taskId, parentId);
579
+ task.parentId = parentId;
580
+ }
581
+ if (payload.sequence !== undefined) task.sequence = normalizeSequenceValue(payload.sequence);
582
+ if (payload.executionOwner !== undefined || payload.execution?.owner !== undefined) {
583
+ task.execution = config.normalizeTaskExecution({
584
+ ...(task.execution || {}),
585
+ owner: payload.executionOwner || payload.execution?.owner,
586
+ lastActor: actor,
587
+ lastSource: source,
588
+ updatedAt: new Date().toISOString(),
589
+ });
590
+ }
591
+
592
+ const blocker = String(payload.blocker || "").trim();
593
+ if (blocker) task.blocker = blocker;
594
+ else delete task.blocker;
595
+
596
+ if (typeof payload.status === "string") {
597
+ return api.setTaskStatus(controlState, taskId, payload.status, String(payload.note || t("server.taskEditedNote")).trim(), {
598
+ actor,
599
+ source,
600
+ owner: payload.executionOwner || payload.execution?.owner || task.execution?.owner,
601
+ historyAction: "edit",
602
+ });
603
+ }
604
+
605
+ task.history = task.history || [];
606
+ task.history.push({ at: new Date().toISOString(), action: "edit", note: String(payload.note || t("server.taskEditedNote")).trim() });
607
+ api.saveControl(controlState);
608
+ api.syncDocs(controlState);
609
+ api.refreshRepoRuntime({ quiet: true });
610
+ return task;
611
+ }
527
612
 
528
613
  /* ── sessions (command execution) ── */
529
614
 
@@ -534,36 +619,90 @@ function cleanupSession(session) {
534
619
  if (session.maxRuntimeTimer) { clearTimeout(session.maxRuntimeTimer); session.maxRuntimeTimer = null; }
535
620
  }
536
621
 
537
- function terminateSession(session, reason) {
538
- if (!session || session.status !== "running" || !session.process) return;
539
- cleanupSession(session);
540
- session.status = "terminated";
541
- session.exitCode = 1;
542
- session.output += `\n[ops] ${reason}\n`;
543
- try { session.process.kill(); } catch (_e) { /* noop */ }
544
- session.listeners.forEach((res) => {
545
- emitSession(res, { type: "done", status: session.status, exitCode: session.exitCode, output: session.output, projectId: session.projectId });
546
- res.end();
547
- });
622
+ function terminateSession(session, reason) {
623
+ if (!session || session.status !== "running" || !session.process) return;
624
+ cleanupSession(session);
625
+ session.status = "terminated";
626
+ session.exitCode = 1;
627
+ session.output += `\n[ops] ${reason}\n`;
628
+ try { session.process.kill(); } catch (_e) { /* noop */ }
629
+ updateTaskSessionContext(session, "terminated");
630
+ session.listeners.forEach((res) => {
631
+ emitSession(res, { type: "done", status: session.status, exitCode: session.exitCode, output: session.output, projectId: session.projectId });
632
+ res.end();
633
+ });
548
634
  session.listeners.clear();
549
635
  }
550
636
 
551
- function scheduleOrphanTermination(session) {
552
- if (!session || session.status !== "running" || session.listeners.size > 0) return;
553
- cleanupSession(session);
554
- session.killTimer = setTimeout(() => terminateSession(session, "orphan timeout"), ORPHAN_TIMEOUT_MS);
555
- }
556
-
557
- function createSession(commandText, project) {
558
- const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
559
- const session = {
560
- id, projectId: project.id, projectName: project.name, projectRoot: project.root,
561
- command: commandText, startedAt: new Date().toISOString(),
562
- status: "running", exitCode: null, output: "", listeners: new Set(),
563
- };
564
-
565
- const shell = process.platform === "win32" ? "powershell.exe" : process.env.SHELL || "/bin/sh";
566
- const shellArgs = process.platform === "win32"
637
+ function scheduleOrphanTermination(session) {
638
+ if (!session || session.status !== "running" || session.listeners.size > 0) return;
639
+ cleanupSession(session);
640
+ session.killTimer = setTimeout(() => terminateSession(session, "orphan timeout"), ORPHAN_TIMEOUT_MS);
641
+ }
642
+
643
+ function updateTaskSessionContext(session, sessionStatus) {
644
+ if (!session?.taskId) return;
645
+ const api = loadControlApi(session.projectRoot);
646
+ const controlState = api.loadControl();
647
+ const task = (controlState.tasks || []).find((item) => item.id === session.taskId);
648
+ if (!task) return;
649
+
650
+ task.execution = config.normalizeTaskExecution({
651
+ ...(task.execution || {}),
652
+ lastActor: "agent",
653
+ lastSource: session.source || "execution_console",
654
+ lastSessionId: session.id,
655
+ lastSessionStatus: sessionStatus || null,
656
+ currentSessionId: sessionStatus === "running" ? session.id : null,
657
+ updatedAt: new Date().toISOString(),
658
+ });
659
+
660
+ api.saveControl(controlState);
661
+ api.syncDocs(controlState);
662
+ api.refreshRepoRuntime({ quiet: true });
663
+ }
664
+
665
+ function createSession(commandText, project, options = {}) {
666
+ const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
667
+ const session = {
668
+ id, projectId: project.id, projectName: project.name, projectRoot: project.root,
669
+ command: commandText, startedAt: new Date().toISOString(),
670
+ status: "running", exitCode: null, output: "", listeners: new Set(),
671
+ taskId: options.taskId ? String(options.taskId).trim() : null,
672
+ source: options.source ? String(options.source).trim() : "execution_console",
673
+ };
674
+
675
+ if (session.taskId) {
676
+ const api = loadControlApi(project.root);
677
+ const controlState = api.loadControl();
678
+ const task = (controlState.tasks || []).find((item) => item.id === session.taskId);
679
+ if (!task) throw new Error(t("server.timeTaskNotFound", { taskId: session.taskId }));
680
+ if (taskHasChildren(controlState, session.taskId)) {
681
+ throw new Error(t("server.timeParentTask"));
682
+ }
683
+ if (task.status === "pending" || task.status === "blocked") {
684
+ api.updateTask(controlState, "start", session.taskId, t("server.agentExecutionStarted"), {
685
+ actor: "agent",
686
+ source: session.source,
687
+ sessionId: id,
688
+ });
689
+ } else {
690
+ task.execution = config.normalizeTaskExecution({
691
+ ...(task.execution || {}),
692
+ lastActor: "agent",
693
+ lastSource: session.source,
694
+ currentSessionId: id,
695
+ lastSessionId: id,
696
+ updatedAt: new Date().toISOString(),
697
+ });
698
+ api.saveControl(controlState);
699
+ api.syncDocs(controlState);
700
+ api.refreshRepoRuntime({ quiet: true });
701
+ }
702
+ }
703
+
704
+ const shell = process.platform === "win32" ? "powershell.exe" : process.env.SHELL || "/bin/sh";
705
+ const shellArgs = process.platform === "win32"
567
706
  ? ["-NoLogo", "-NoProfile", "-Command", commandText]
568
707
  : ["-lc", commandText];
569
708
 
@@ -579,27 +718,29 @@ function createSession(commandText, project) {
579
718
  session.listeners.forEach((res) => emitSession(res, { type, chunk: text, status: session.status, projectId: session.projectId }));
580
719
  }
581
720
 
582
- child.stdout.on("data", (c) => pushChunk("stdout", c));
583
- child.stderr.on("data", (c) => pushChunk("stderr", c));
584
- child.on("close", (code) => {
585
- cleanupSession(session);
586
- session.status = "completed";
587
- session.exitCode = code;
588
- session.listeners.forEach((res) => {
589
- emitSession(res, { type: "done", status: session.status, exitCode: code, output: session.output, projectId: session.projectId });
590
- res.end();
591
- });
592
- session.listeners.clear();
593
- });
594
- child.on("error", (err) => {
595
- cleanupSession(session);
596
- session.status = "failed";
597
- session.exitCode = 1;
598
- session.output += `${err.message}\n`;
599
- session.listeners.forEach((res) => {
600
- emitSession(res, { type: "done", status: session.status, exitCode: 1, output: session.output, projectId: session.projectId });
601
- res.end();
602
- });
721
+ child.stdout.on("data", (c) => pushChunk("stdout", c));
722
+ child.stderr.on("data", (c) => pushChunk("stderr", c));
723
+ child.on("close", (code) => {
724
+ cleanupSession(session);
725
+ session.status = "completed";
726
+ session.exitCode = code;
727
+ updateTaskSessionContext(session, code === 0 ? "completed" : "failed");
728
+ session.listeners.forEach((res) => {
729
+ emitSession(res, { type: "done", status: session.status, exitCode: code, output: session.output, projectId: session.projectId });
730
+ res.end();
731
+ });
732
+ session.listeners.clear();
733
+ });
734
+ child.on("error", (err) => {
735
+ cleanupSession(session);
736
+ session.status = "failed";
737
+ session.exitCode = 1;
738
+ session.output += `${err.message}\n`;
739
+ updateTaskSessionContext(session, "failed");
740
+ session.listeners.forEach((res) => {
741
+ emitSession(res, { type: "done", status: session.status, exitCode: 1, output: session.output, projectId: session.projectId });
742
+ res.end();
743
+ });
603
744
  session.listeners.clear();
604
745
  });
605
746
 
@@ -694,15 +835,158 @@ async function handleApi(req, res, url) {
694
835
  return;
695
836
  }
696
837
 
697
- if (req.method === "GET" && url.pathname === "/api/state") {
698
- sendJson(res, 200, getStatePayload(url.searchParams.get("project")));
699
- return;
700
- }
701
-
702
- if (req.method === "POST" && url.pathname === "/api/projects/locale") {
703
- const body = await parseBody(req);
704
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
705
- const controlState = updateProjectLocale(project.root, body.locale);
838
+ if (req.method === "GET" && url.pathname === "/api/state") {
839
+ sendJson(res, 200, getStatePayload(url.searchParams.get("project")));
840
+ return;
841
+ }
842
+
843
+ if (req.method === "GET" && url.pathname === "/api/plans") {
844
+ const project = resolveProjectEntry(url.searchParams.get("project"));
845
+ const plans = require("./plans");
846
+ sendJson(res, 200, { ok: true, sources: plans.listSources(project.root) });
847
+ return;
848
+ }
849
+
850
+ const planMatch = url.pathname.match(/^\/api\/plans\/([^/]+)$/);
851
+ if (req.method === "GET" && planMatch) {
852
+ const project = resolveProjectEntry(url.searchParams.get("project"));
853
+ const plans = require("./plans");
854
+ sendJson(res, 200, { ok: true, ...plans.loadSource(project.root, decodeURIComponent(planMatch[1])) });
855
+ return;
856
+ }
857
+
858
+ if (req.method === "POST" && url.pathname === "/api/plans/scan") {
859
+ const body = await parseBody(req);
860
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
861
+ const plans = require("./plans");
862
+ sendJson(res, 200, { ok: true, candidates: plans.scan(project.root, { path: body.path || null }) });
863
+ return;
864
+ }
865
+
866
+ if (req.method === "POST" && url.pathname === "/api/plans/import") {
867
+ const body = await parseBody(req);
868
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
869
+ const plans = require("./plans");
870
+ const result = plans.importPlan(project.root, {
871
+ file: body.file,
872
+ adapter: body.adapter || "auto",
873
+ sourceId: body.sourceId || null,
874
+ });
875
+ const api = loadControlApi(project.root);
876
+ api.syncDocs(result.control);
877
+ api.refreshRepoRuntime({ quiet: true });
878
+ sendJson(res, 201, { ok: true, ...result, state: getStatePayload(project.id) });
879
+ return;
880
+ }
881
+
882
+ const planApplyMatch = url.pathname.match(/^\/api\/plans\/([^/]+)\/apply$/);
883
+ if (req.method === "POST" && planApplyMatch) {
884
+ const body = await parseBody(req);
885
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
886
+ const plans = require("./plans");
887
+ const result = plans.applyPlan(project.root, decodeURIComponent(planApplyMatch[1]), {
888
+ importId: body.importId || "latest",
889
+ conflicts: body.conflicts || "abort",
890
+ removed: body.removed || "detach",
891
+ });
892
+ const api = loadControlApi(project.root);
893
+ api.syncDocs(result.control);
894
+ api.refreshRepoRuntime({ quiet: true });
895
+ sendJson(res, 200, { ok: true, ...result, state: getStatePayload(project.id) });
896
+ return;
897
+ }
898
+
899
+ const planUnlinkMatch = url.pathname.match(/^\/api\/plans\/([^/]+)\/unlink$/);
900
+ if (req.method === "POST" && planUnlinkMatch) {
901
+ const body = await parseBody(req);
902
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
903
+ const plans = require("./plans");
904
+ const result = plans.unlinkSource(project.root, decodeURIComponent(planUnlinkMatch[1]), {
905
+ keepTasks: body.keepTasks === true,
906
+ });
907
+ const api = loadControlApi(project.root);
908
+ api.syncDocs(result.control);
909
+ api.refreshRepoRuntime({ quiet: true });
910
+ sendJson(res, 200, { ok: true, ...result, state: getStatePayload(project.id) });
911
+ return;
912
+ }
913
+
914
+ if (req.method === "GET" && url.pathname === "/api/quality") {
915
+ const project = resolveProjectEntry(url.searchParams.get("project"));
916
+ const quality = require("./quality");
917
+ sendJson(res, 200, { ok: true, ...quality.buildQualitySnapshot(project.root) });
918
+ return;
919
+ }
920
+
921
+ if (req.method === "POST" && url.pathname === "/api/quality/verify") {
922
+ const body = await parseBody(req);
923
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
924
+ const quality = require("./quality");
925
+ const run = quality.verify(project.root, {
926
+ scope: body.scope || "all",
927
+ note: body.note || "",
928
+ });
929
+ sendJson(res, 200, { ok: true, run, state: getStatePayload(project.id) });
930
+ return;
931
+ }
932
+
933
+ if (req.method === "GET" && url.pathname === "/api/quality/phase-readiness") {
934
+ const project = resolveProjectEntry(url.searchParams.get("project"));
935
+ const quality = require("./quality");
936
+ sendJson(res, 200, {
937
+ ok: true,
938
+ ...quality.buildPhaseReadiness(project.root, null, {
939
+ phase: url.searchParams.get("phase") || "current",
940
+ }),
941
+ });
942
+ return;
943
+ }
944
+
945
+ if (req.method === "GET" && url.pathname === "/api/quality/release-readiness") {
946
+ const project = resolveProjectEntry(url.searchParams.get("project"));
947
+ const quality = require("./quality");
948
+ sendJson(res, 200, { ok: true, ...quality.buildReleaseReadiness(project.root) });
949
+ return;
950
+ }
951
+
952
+ if (req.method === "GET" && url.pathname === "/api/quality/promotion-readiness") {
953
+ const project = resolveProjectEntry(url.searchParams.get("project"));
954
+ const quality = require("./quality");
955
+ sendJson(res, 200, {
956
+ ok: true,
957
+ ...quality.buildPromotionReadiness(project.root, null, {
958
+ target: url.searchParams.get("target") || "staging",
959
+ }),
960
+ });
961
+ return;
962
+ }
963
+
964
+ if (req.method === "GET" && url.pathname === "/api/quality/waivers") {
965
+ const project = resolveProjectEntry(url.searchParams.get("project"));
966
+ const quality = require("./quality");
967
+ sendJson(res, 200, { ok: true, waivers: quality.listWaivers(project.root) });
968
+ return;
969
+ }
970
+
971
+ if (req.method === "POST" && url.pathname === "/api/quality/waivers") {
972
+ const body = await parseBody(req);
973
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
974
+ const quality = require("./quality");
975
+ const waiver = quality.addWaiver(project.root, {
976
+ probeId: body.probeId,
977
+ scope: body.scope,
978
+ reason: body.reason,
979
+ approvedBy: body.approvedBy,
980
+ expiresAt: body.expiresAt,
981
+ });
982
+ sendJson(res, 201, { ok: true, waiver, state: getStatePayload(project.id) });
983
+ return;
984
+ }
985
+
986
+ if (req.method === "POST" && url.pathname === "/api/projects/locale") {
987
+ const body = await parseBody(req);
988
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
989
+ const controlState = updateProjectLocale(project.root, body.locale);
706
990
  sendJson(res, 200, {
707
991
  ok: true,
708
992
  locale: config.getLocale(controlState),
@@ -728,18 +1012,21 @@ async function handleApi(req, res, url) {
728
1012
  return;
729
1013
  }
730
1014
 
731
- const actionMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/action$/);
732
- if (req.method === "POST" && actionMatch) {
733
- const body = await parseBody(req);
734
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
735
- const action = String(body.action || "").trim();
736
- if (!action) { sendJson(res, 400, { ok: false, error: "Action required." }); return; }
737
- const api = loadControlApi(project.root);
738
- const controlState = api.loadControl();
739
- api.updateTask(controlState, action, decodeURIComponent(actionMatch[1]), body.note || "");
740
- sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
741
- return;
742
- }
1015
+ const actionMatch = url.pathname.match(/^\/api\/tasks\/([^/]+)\/action$/);
1016
+ if (req.method === "POST" && actionMatch) {
1017
+ const body = await parseBody(req);
1018
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
1019
+ const action = String(body.action || "").trim();
1020
+ if (!action) { sendJson(res, 400, { ok: false, error: "Action required." }); return; }
1021
+ const api = loadControlApi(project.root);
1022
+ const controlState = api.loadControl();
1023
+ api.updateTask(controlState, action, decodeURIComponent(actionMatch[1]), body.note || "", {
1024
+ actor: String(body.actor || "user").trim() || "user",
1025
+ source: String(body.source || "dashboard").trim() || "dashboard",
1026
+ });
1027
+ sendJson(res, 200, { ok: true, state: getStatePayload(project.id) });
1028
+ return;
1029
+ }
743
1030
 
744
1031
  if (req.method === "POST" && url.pathname === "/api/sync") {
745
1032
  const body = await parseBody(req);
@@ -942,15 +1229,18 @@ async function handleApi(req, res, url) {
942
1229
  return;
943
1230
  }
944
1231
 
945
- if (req.method === "POST" && url.pathname === "/api/commands") {
946
- const body = await parseBody(req);
947
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
948
- const commandText = String(body.command || "").trim();
949
- if (!commandText) { sendJson(res, 400, { ok: false, error: t("server.commandRequired") }); return; }
950
- const session = createSession(commandText, project);
951
- sendJson(res, 201, { ok: true, session: { id: session.id, command: session.command, startedAt: session.startedAt, status: session.status, projectId: session.projectId, projectName: session.projectName } });
952
- return;
953
- }
1232
+ if (req.method === "POST" && url.pathname === "/api/commands") {
1233
+ const body = await parseBody(req);
1234
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
1235
+ const commandText = String(body.command || "").trim();
1236
+ if (!commandText) { sendJson(res, 400, { ok: false, error: t("server.commandRequired") }); return; }
1237
+ const session = createSession(commandText, project, {
1238
+ taskId: body.taskId || null,
1239
+ source: body.source || "execution_console",
1240
+ });
1241
+ sendJson(res, 201, { ok: true, session: { id: session.id, command: session.command, startedAt: session.startedAt, status: session.status, projectId: session.projectId, projectName: session.projectName } });
1242
+ return;
1243
+ }
954
1244
 
955
1245
  const sessionInfoMatch = url.pathname.match(/^\/api\/commands\/([^/]+)$/);
956
1246
  if (req.method === "GET" && sessionInfoMatch) {
@@ -988,41 +1278,63 @@ async function handleApi(req, res, url) {
988
1278
  return;
989
1279
  }
990
1280
 
991
- if (req.method === "POST" && url.pathname === "/api/time/start") {
992
- const body = await parseBody(req);
993
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
994
- const api = loadControlApi(project.root);
995
- const controlState = api.loadControl();
996
- const entryId = `te-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
997
- const entry = {
998
- id: entryId,
999
- taskId: String(body.taskId || "").trim(),
1000
- taskTitle: String(body.taskTitle || "").trim(),
1001
- startedAt: new Date().toISOString(),
1002
- stoppedAt: null,
1003
- durationMs: 0,
1004
- };
1005
- if (!controlState.timeEntries) controlState.timeEntries = [];
1006
- controlState.timeEntries.push(entry);
1007
- api.saveControl(controlState);
1008
- sendJson(res, 201, { ok: true, entry });
1009
- return;
1010
- }
1011
-
1012
- if (req.method === "POST" && url.pathname === "/api/time/stop") {
1013
- const body = await parseBody(req);
1014
- const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
1015
- const api = loadControlApi(project.root);
1016
- const controlState = api.loadControl();
1017
- const entries = controlState.timeEntries || [];
1018
- const entry = entries.find(e => e.id === body.entryId);
1019
- if (!entry) { sendJson(res, 404, { ok: false, error: "Entry not found." }); return; }
1020
- entry.stoppedAt = new Date().toISOString();
1021
- entry.durationMs = new Date(entry.stoppedAt) - new Date(entry.startedAt);
1022
- api.saveControl(controlState);
1023
- sendJson(res, 200, { ok: true, entry });
1024
- return;
1025
- }
1281
+ if (req.method === "POST" && url.pathname === "/api/time/start") {
1282
+ const body = await parseBody(req);
1283
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
1284
+ const api = loadControlApi(project.root);
1285
+ const controlState = api.loadControl();
1286
+ const taskId = String(body.taskId || "").trim();
1287
+ if (!taskId) { sendJson(res, 400, { ok: false, error: t("server.timeTaskRequired") }); return; }
1288
+ const task = (controlState.tasks || []).find((item) => item.id === taskId);
1289
+ if (!task) { sendJson(res, 404, { ok: false, error: t("server.timeTaskNotFound", { taskId }) }); return; }
1290
+ if (taskHasChildren(controlState, taskId)) {
1291
+ sendJson(res, 400, { ok: false, error: t("server.timeParentTask") });
1292
+ return;
1293
+ }
1294
+ const entryId = `te-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
1295
+ const entry = {
1296
+ id: entryId,
1297
+ taskId,
1298
+ taskTitle: String(body.taskTitle || "").trim() || task.title,
1299
+ startedAt: new Date().toISOString(),
1300
+ stoppedAt: null,
1301
+ durationMs: 0,
1302
+ };
1303
+ if (!controlState.timeEntries) controlState.timeEntries = [];
1304
+ controlState.timeEntries.push(entry);
1305
+ const shouldAutoStart = task.status === "pending" || task.status === "blocked";
1306
+ if (shouldAutoStart) {
1307
+ api.updateTask(controlState, "start", taskId, t("server.timeStartedNote"), {
1308
+ actor: String(body.actor || "user").trim() || "user",
1309
+ source: String(body.source || "time_tracker").trim() || "time_tracker",
1310
+ });
1311
+ } else {
1312
+ task.execution = config.normalizeTaskExecution({
1313
+ ...(task.execution || {}),
1314
+ lastActor: String(body.actor || "user").trim() || "user",
1315
+ lastSource: String(body.source || "time_tracker").trim() || "time_tracker",
1316
+ updatedAt: new Date().toISOString(),
1317
+ });
1318
+ api.saveControl(controlState);
1319
+ }
1320
+ sendJson(res, 201, { ok: true, entry, autoTaskStarted: shouldAutoStart, state: getStatePayload(project.id) });
1321
+ return;
1322
+ }
1323
+
1324
+ if (req.method === "POST" && url.pathname === "/api/time/stop") {
1325
+ const body = await parseBody(req);
1326
+ const project = resolveProjectEntry(body.projectId || body.project || url.searchParams.get("project"));
1327
+ const api = loadControlApi(project.root);
1328
+ const controlState = api.loadControl();
1329
+ const entries = controlState.timeEntries || [];
1330
+ const entry = entries.find(e => e.id === body.entryId);
1331
+ if (!entry) { sendJson(res, 404, { ok: false, error: t("server.timeEntryNotFound") }); return; }
1332
+ entry.stoppedAt = new Date().toISOString();
1333
+ entry.durationMs = new Date(entry.stoppedAt) - new Date(entry.startedAt);
1334
+ api.saveControl(controlState);
1335
+ sendJson(res, 200, { ok: true, entry, state: getStatePayload(project.id) });
1336
+ return;
1337
+ }
1026
1338
 
1027
1339
  /* ── Skills Hub ── */
1028
1340