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/README.md +295 -701
- package/bin/trackops.js +24 -16
- package/lib/config.js +265 -58
- package/lib/control.js +830 -292
- package/lib/init.js +46 -16
- package/lib/opera-bootstrap.js +85 -45
- package/lib/opera-phase-dod.js +485 -0
- package/lib/opera.js +8 -5
- package/lib/plans.js +1329 -0
- package/lib/quality-assert.js +49 -0
- package/lib/quality.js +1759 -0
- package/lib/release.js +18 -11
- package/lib/server.js +504 -192
- package/locales/en.json +249 -15
- package/locales/es.json +249 -15
- package/package.json +6 -5
- package/scripts/quality-unit-tests.js +130 -0
- package/scripts/smoke-tests.js +357 -57
- package/skills/trackops/skill.json +29 -29
- package/templates/skills/opera-quality-guard/SKILL.md +26 -0
- package/templates/skills/opera-quality-guard/locales/en/SKILL.md +26 -0
- package/templates/skills/opera-skill/SKILL.md +8 -0
- package/templates/skills/opera-skill/locales/en/SKILL.md +8 -0
- package/ui/js/api.js +93 -26
- package/ui/js/app.js +13 -7
- package/ui/js/filters.js +49 -29
- package/ui/js/time-tracker.js +41 -28
- package/ui/js/views/board.js +22 -14
- package/ui/js/views/dashboard.js +206 -49
- package/ui/js/views/execution.js +7 -3
- package/ui/js/views/plans.js +284 -0
- package/ui/js/views/scrum.js +25 -13
- package/ui/js/views/sidebar.js +9 -8
- package/ui/js/views/tasks.js +238 -134
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
if (typeof payload.
|
|
508
|
-
if (typeof payload.
|
|
509
|
-
if (typeof payload.
|
|
510
|
-
if (typeof payload.
|
|
511
|
-
if (payload.
|
|
512
|
-
if (payload.
|
|
513
|
-
if (payload.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
|
545
|
-
|
|
546
|
-
res.
|
|
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
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
|
589
|
-
|
|
590
|
-
res.
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
session
|
|
597
|
-
session.
|
|
598
|
-
session.
|
|
599
|
-
session.
|
|
600
|
-
|
|
601
|
-
|
|
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 === "
|
|
703
|
-
const
|
|
704
|
-
const
|
|
705
|
-
|
|
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
|
-
|
|
741
|
-
|
|
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
|
-
|
|
952
|
-
|
|
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
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
|