trackops 2.0.5 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +319 -695
- package/bin/trackops.js +52 -23
- package/lib/cli-format.js +118 -0
- package/lib/config.js +277 -44
- package/lib/control.js +1052 -352
- package/lib/env.js +40 -28
- package/lib/i18n.js +5 -4
- package/lib/init.js +194 -56
- package/lib/opera-bootstrap.js +326 -106
- package/lib/opera-phase-dod.js +485 -0
- package/lib/opera.js +243 -78
- 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/lib/skills.js +43 -35
- package/lib/workspace.js +32 -21
- package/locales/en.json +431 -75
- package/locales/es.json +432 -76
- package/package.json +6 -5
- package/scripts/quality-unit-tests.js +130 -0
- package/scripts/smoke-tests.js +438 -96
- 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/scripts/smoke-tests.js
CHANGED
|
@@ -8,9 +8,10 @@ const os = require("os");
|
|
|
8
8
|
const path = require("path");
|
|
9
9
|
const { spawn, spawnSync } = require("child_process");
|
|
10
10
|
|
|
11
|
-
const ROOT = path.resolve(__dirname, "..");
|
|
12
|
-
const BIN = path.join(ROOT, "bin", "trackops.js");
|
|
13
|
-
const SKILL_VALIDATE = path.join(ROOT, "scripts", "validate-skill.js");
|
|
11
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
12
|
+
const BIN = path.join(ROOT, "bin", "trackops.js");
|
|
13
|
+
const SKILL_VALIDATE = path.join(ROOT, "scripts", "validate-skill.js");
|
|
14
|
+
const controlLib = require(path.join(ROOT, "lib", "control.js"));
|
|
14
15
|
|
|
15
16
|
function getNpmCommand() {
|
|
16
17
|
return process.platform === "win32" ? "npm.cmd" : "npm";
|
|
@@ -26,13 +27,24 @@ function runNode(args, cwd, envOverrides = {}) {
|
|
|
26
27
|
return result.stdout;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
function runNodeResult(args, cwd, envOverrides = {}) {
|
|
30
|
-
return spawnSync(process.execPath, args, {
|
|
31
|
-
cwd,
|
|
32
|
-
encoding: "utf8",
|
|
33
|
-
env: { ...process.env, ...envOverrides },
|
|
34
|
-
});
|
|
35
|
-
}
|
|
30
|
+
function runNodeResult(args, cwd, envOverrides = {}) {
|
|
31
|
+
return spawnSync(process.execPath, args, {
|
|
32
|
+
cwd,
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
env: { ...process.env, ...envOverrides },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function runNodeWithInput(args, cwd, input, envOverrides = {}) {
|
|
39
|
+
const result = spawnSync(process.execPath, args, {
|
|
40
|
+
cwd,
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
input,
|
|
43
|
+
env: { ...process.env, ...envOverrides },
|
|
44
|
+
});
|
|
45
|
+
assert.strictEqual(result.status, 0, result.stderr || result.stdout || `fallo ejecutando ${args.join(" ")}`);
|
|
46
|
+
return result.stdout;
|
|
47
|
+
}
|
|
36
48
|
|
|
37
49
|
function runCommand(command, args, cwd, envOverrides = {}) {
|
|
38
50
|
return spawnSync(command, args, {
|
|
@@ -61,17 +73,41 @@ function wait(ms) {
|
|
|
61
73
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
62
74
|
}
|
|
63
75
|
|
|
64
|
-
function get(port, pathname, host = "127.0.0.1") {
|
|
65
|
-
return new Promise((resolve, reject) => {
|
|
66
|
-
const req = http.get({ host, port, path: pathname }, (res) => {
|
|
67
|
-
let body = "";
|
|
68
|
-
res.setEncoding("utf8");
|
|
76
|
+
function get(port, pathname, host = "127.0.0.1") {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const req = http.get({ host, port, path: pathname }, (res) => {
|
|
79
|
+
let body = "";
|
|
80
|
+
res.setEncoding("utf8");
|
|
69
81
|
res.on("data", (chunk) => { body += chunk; });
|
|
70
82
|
res.on("end", () => resolve({ status: res.statusCode, body }));
|
|
71
83
|
});
|
|
72
|
-
req.on("error", reject);
|
|
73
|
-
});
|
|
74
|
-
}
|
|
84
|
+
req.on("error", reject);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function requestJson(method, port, pathname, payload = null, host = "127.0.0.1") {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const body = payload == null ? "" : JSON.stringify(payload);
|
|
91
|
+
const req = http.request({
|
|
92
|
+
host,
|
|
93
|
+
port,
|
|
94
|
+
path: pathname,
|
|
95
|
+
method,
|
|
96
|
+
headers: {
|
|
97
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
98
|
+
"Content-Length": Buffer.byteLength(body),
|
|
99
|
+
},
|
|
100
|
+
}, (res) => {
|
|
101
|
+
let responseBody = "";
|
|
102
|
+
res.setEncoding("utf8");
|
|
103
|
+
res.on("data", (chunk) => { responseBody += chunk; });
|
|
104
|
+
res.on("end", () => resolve({ status: res.statusCode, body: responseBody }));
|
|
105
|
+
});
|
|
106
|
+
req.on("error", reject);
|
|
107
|
+
if (body) req.write(body);
|
|
108
|
+
req.end();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
75
111
|
|
|
76
112
|
function extractLocalPort(output) {
|
|
77
113
|
const match = String(output || "").match(/- Local:\s+http:\/\/[^\s:]+:(\d+)/);
|
|
@@ -232,11 +268,12 @@ async function main() {
|
|
|
232
268
|
const installedVersion = runNode([installedCli, "--version"], tempRoot);
|
|
233
269
|
assert.strictEqual(installedVersion.trim(), packageVersion);
|
|
234
270
|
|
|
235
|
-
const helpOutput = runNode([BIN, "help"], ROOT);
|
|
236
|
-
assert.doesNotMatch(helpOutput, /\btrackops agent\b/i);
|
|
237
|
-
assert.match(helpOutput, /workspace status\|migrate/i);
|
|
238
|
-
assert.match(helpOutput, /env status\|sync/i);
|
|
239
|
-
assert.match(helpOutput, /release \[--push\]/i);
|
|
271
|
+
const helpOutput = runNode([BIN, "help"], ROOT);
|
|
272
|
+
assert.doesNotMatch(helpOutput, /\btrackops agent\b/i);
|
|
273
|
+
assert.match(helpOutput, /workspace status\|migrate/i);
|
|
274
|
+
assert.match(helpOutput, /env status\|sync/i);
|
|
275
|
+
assert.match(helpOutput, /release \[--push\]/i);
|
|
276
|
+
assert.match(helpOutput, /--plain|--a11y/i);
|
|
240
277
|
|
|
241
278
|
const versionOutput = runNode([BIN, "--version"], ROOT);
|
|
242
279
|
assert.strictEqual(versionOutput.trim(), packageVersion);
|
|
@@ -270,31 +307,38 @@ async function main() {
|
|
|
270
307
|
assert.match(projectLocaleDoctor, /Project language: es|Idioma del proyecto: es/);
|
|
271
308
|
assert.match(projectLocaleDoctor, /Source: project|Origen: proyecto/);
|
|
272
309
|
|
|
273
|
-
const statusFromWorkspace = runNode([BIN, "status"], splitProject);
|
|
274
|
-
const statusFromApp = runNode([BIN, "status"], path.join(splitProject, "app"));
|
|
275
|
-
const statusFromOps = runNode([BIN, "status"], path.join(splitProject, "ops"));
|
|
276
|
-
assert.match(statusFromWorkspace, /Layout: split/);
|
|
277
|
-
assert.match(statusFromApp, /Layout: split/);
|
|
278
|
-
assert.match(statusFromOps, /Layout: split/);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
310
|
+
const statusFromWorkspace = runNode([BIN, "status"], splitProject);
|
|
311
|
+
const statusFromApp = runNode([BIN, "status"], path.join(splitProject, "app"));
|
|
312
|
+
const statusFromOps = runNode([BIN, "status"], path.join(splitProject, "ops"));
|
|
313
|
+
assert.match(statusFromWorkspace, /Layout: split|Estructura: split/);
|
|
314
|
+
assert.match(statusFromApp, /Layout: split|Estructura: split/);
|
|
315
|
+
assert.match(statusFromOps, /Layout: split|Estructura: split/);
|
|
316
|
+
assert.match(statusFromWorkspace, /Git: not initialized|Git: no inicializado/i);
|
|
317
|
+
assert.doesNotMatch(statusFromWorkspace, /Branch: detached|Rama: detached/i);
|
|
318
|
+
|
|
319
|
+
const nextOutput = runNode([BIN, "next"], splitProject);
|
|
320
|
+
assert.match(nextOutput, /ops-bootstrap/);
|
|
321
|
+
|
|
322
|
+
const rerunInit = runNode([BIN, "init", "--locale", "es"], splitProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
323
|
+
assert.match(rerunInit, /Updated \.trackops-workspace\.json|Actualizado \.trackops-workspace\.json/);
|
|
324
|
+
|
|
325
|
+
runNode([BIN, "sync"], splitProject);
|
|
284
326
|
for (const file of ["task_plan.md", "progress.md", "findings.md"]) {
|
|
285
327
|
assert.ok(fs.existsSync(path.join(splitProject, "ops", file)), `${file} no fue generado en ops/`);
|
|
286
328
|
}
|
|
287
|
-
|
|
288
|
-
const envStatus = runNode([BIN, "env", "status"], splitProject);
|
|
289
|
-
assert.match(envStatus, /Root \.env:/);
|
|
290
|
-
assert.match(envStatus, /App bridge:/);
|
|
291
|
-
|
|
292
|
-
const nonEmptyProject = path.join(tempRoot, "non-empty");
|
|
293
|
-
fs.mkdirSync(nonEmptyProject, { recursive: true });
|
|
294
|
-
writeJson(path.join(nonEmptyProject, "package.json"), { name: "existing-app", version: "1.0.0" });
|
|
295
|
-
const initNonEmpty =
|
|
296
|
-
assert.
|
|
297
|
-
assert.
|
|
329
|
+
|
|
330
|
+
const envStatus = runNode([BIN, "env", "status"], splitProject);
|
|
331
|
+
assert.match(envStatus, /Root \.env:|\.env raiz:/);
|
|
332
|
+
assert.match(envStatus, /App bridge:|Puente app:/);
|
|
333
|
+
|
|
334
|
+
const nonEmptyProject = path.join(tempRoot, "non-empty");
|
|
335
|
+
fs.mkdirSync(nonEmptyProject, { recursive: true });
|
|
336
|
+
writeJson(path.join(nonEmptyProject, "package.json"), { name: "existing-app", version: "1.0.0" });
|
|
337
|
+
const initNonEmpty = runNode([BIN, "init", "--locale", "en"], nonEmptyProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
338
|
+
assert.match(initNonEmpty, /adopted into app\/|movio a app\//i);
|
|
339
|
+
assert.ok(fs.existsSync(path.join(nonEmptyProject, ".trackops-workspace.json")));
|
|
340
|
+
assert.ok(fs.existsSync(path.join(nonEmptyProject, "app", "package.json")));
|
|
341
|
+
assert.ok(fs.existsSync(path.join(nonEmptyProject, "ops", "project_control.json")));
|
|
298
342
|
|
|
299
343
|
writeJson(path.join(splitProject, "app", "package.json"), {
|
|
300
344
|
name: "split-demo",
|
|
@@ -361,9 +405,36 @@ async function main() {
|
|
|
361
405
|
"--decision-ownership",
|
|
362
406
|
"user",
|
|
363
407
|
], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
364
|
-
const directControl = readJson(path.join(directProject, "ops", "project_control.json"));
|
|
365
|
-
assert.strictEqual(directControl.meta.opera.bootstrap.mode, "direct_cli");
|
|
366
|
-
assert.strictEqual(directControl.meta.opera.bootstrap.status, "awaiting_intake");
|
|
408
|
+
const directControl = readJson(path.join(directProject, "ops", "project_control.json"));
|
|
409
|
+
assert.strictEqual(directControl.meta.opera.bootstrap.mode, "direct_cli");
|
|
410
|
+
assert.strictEqual(directControl.meta.opera.bootstrap.status, "awaiting_intake");
|
|
411
|
+
assert.ok(fs.existsSync(path.join(directProject, "ops", "bootstrap", "intake.json")));
|
|
412
|
+
assert.ok(fs.existsSync(path.join(directProject, "ops", "bootstrap", "spec-dossier.md")));
|
|
413
|
+
assert.ok(fs.existsSync(path.join(directProject, "ops", "bootstrap", "open-questions.md")));
|
|
414
|
+
assert.ok(fs.existsSync(path.join(directProject, "ops", "bootstrap", "quality-report.json")));
|
|
415
|
+
const directOperaStatus = runNode([BIN, "opera", "status"], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
416
|
+
assert.match(directOperaStatus, /Intake:|Intake:/);
|
|
417
|
+
assert.match(directOperaStatus, /Spec dossier:|Specification brief:|Dossier de especificacion:/i);
|
|
418
|
+
assert.doesNotMatch(directOperaStatus, /The agent did not include|El agente no incluyo/i);
|
|
419
|
+
assert.doesNotMatch(directOperaStatus, /Handoff: ops[\\/]+bootstrap[\\/]+agent-handoff\.md/i);
|
|
420
|
+
const directHandoffSummary = runNode([BIN, "opera", "handoff"], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
421
|
+
assert.match(directHandoffSummary, /Guided bootstrap summary|Resumen del bootstrap guiado/i);
|
|
422
|
+
assert.doesNotMatch(directHandoffSummary, /Markdown handoff/i);
|
|
423
|
+
|
|
424
|
+
const plainStatus = runNode([BIN, "--plain", "status"], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
425
|
+
assert.match(plainStatus, /\[BLOCKED\]|\[PENDING\]/);
|
|
426
|
+
assert.doesNotMatch(plainStatus, /\u2500|\u23F3|\u26D4|\u2705/);
|
|
427
|
+
const blockedResume = runNode([BIN, "opera", "bootstrap", "--resume"], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
428
|
+
assert.match(blockedResume, /quality is BLOCKED|calidad esta BLOQUEADA/i);
|
|
429
|
+
assert.match(blockedResume, /Problem statement|Problema principal/i);
|
|
430
|
+
assert.match(blockedResume, /Target user|Usuario objetivo/i);
|
|
431
|
+
|
|
432
|
+
const promptProject = path.join(tempRoot, "prompt-demo");
|
|
433
|
+
fs.mkdirSync(promptProject, { recursive: true });
|
|
434
|
+
runNode([BIN, "init", "--locale", "en"], promptProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
435
|
+
writeJson(path.join(promptProject, "app", "package.json"), { name: "prompt-demo", version: "1.0.0" });
|
|
436
|
+
runNodeWithInput([BIN, "opera", "install", "--locale", "en"], promptProject, "\n", { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
437
|
+
assert.ok(!fs.existsSync(path.join(promptProject, "ops", "bootstrap", "agent-handoff.md")));
|
|
367
438
|
|
|
368
439
|
writeJson(path.join(splitProject, "ops", "bootstrap", "intake.json"), {
|
|
369
440
|
version: 1,
|
|
@@ -399,30 +470,164 @@ async function main() {
|
|
|
399
470
|
assert.ok(resumedControl.meta.environment.requiredKeys.includes("STRIPE_SECRET_KEY"));
|
|
400
471
|
assert.ok(fs.existsSync(path.join(splitProject, "ops", "contract", "operating-contract.json")));
|
|
401
472
|
const operatingContract = readJson(path.join(splitProject, "ops", "contract", "operating-contract.json"));
|
|
402
|
-
assert.strictEqual(operatingContract.version, 3);
|
|
403
|
-
assert.strictEqual(operatingContract.userModel.language, "en");
|
|
404
|
-
assert.strictEqual(operatingContract.userModel.decisionOwnership, "agent");
|
|
405
|
-
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
473
|
+
assert.strictEqual(operatingContract.version, 3);
|
|
474
|
+
assert.strictEqual(operatingContract.userModel.language, "en");
|
|
475
|
+
assert.strictEqual(operatingContract.userModel.decisionOwnership, "agent");
|
|
476
|
+
|
|
477
|
+
const planFile = path.join(splitProject, "docs", "implementation-plan.md");
|
|
478
|
+
fs.mkdirSync(path.dirname(planFile), { recursive: true });
|
|
479
|
+
fs.writeFileSync(
|
|
480
|
+
planFile,
|
|
481
|
+
"# Implementation plan\n\n## Booking delivery\n- [P0] Booking orchestration [phase: E]\n - [P1] Define payment payload [phase: P]\n - [P0] Implement Stripe checkout [phase: E]\n- [P2] Release automation [phase: A]\n",
|
|
482
|
+
"utf8",
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const scanPlans = JSON.parse(runNode([BIN, "plan", "scan", "--json"], splitProject));
|
|
486
|
+
assert.ok(scanPlans.candidates.some((candidate) => candidate.path === "docs/implementation-plan.md"));
|
|
487
|
+
|
|
488
|
+
const importedPlan = JSON.parse(runNode([BIN, "plan", "import", "--file", "docs/implementation-plan.md", "--source-id", "booking-plan", "--json"], splitProject));
|
|
489
|
+
assert.strictEqual(importedPlan.source.id, "booking-plan");
|
|
490
|
+
assert.ok(importedPlan.preview.summary.create >= 3);
|
|
491
|
+
const controlAfterImport = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
492
|
+
assert.strictEqual(controlAfterImport.meta.controlVersion, 3);
|
|
493
|
+
assert.ok(controlAfterImport.meta.plans.sources.some((source) => source.id === "booking-plan"));
|
|
494
|
+
assert.ok(!controlAfterImport.tasks.some((task) => task.origin?.sourceId === "booking-plan"));
|
|
495
|
+
|
|
496
|
+
const appliedPlan = JSON.parse(runNode([BIN, "plan", "apply", "booking-plan", "--json"], splitProject));
|
|
497
|
+
assert.strictEqual(appliedPlan.source.id, "booking-plan");
|
|
498
|
+
assert.ok(appliedPlan.preview.summary.managedTaskCount >= 3);
|
|
499
|
+
const controlAfterPlan = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
500
|
+
const importedTasks = controlAfterPlan.tasks.filter((task) => task.origin?.sourceId === "booking-plan");
|
|
501
|
+
assert.ok(importedTasks.length >= 3);
|
|
502
|
+
assert.ok(importedTasks.some((task) => task.parentId));
|
|
503
|
+
assert.ok(importedTasks.some((task) => Number.isFinite(Number(task.sequence))));
|
|
504
|
+
const derivedAfterPlan = controlLib.derive(controlAfterPlan);
|
|
505
|
+
assert.ok(derivedAfterPlan.tasks.some((task) => task.isParent));
|
|
506
|
+
assert.ok(derivedAfterPlan.actionableTasks.every((task) => task.isLeaf));
|
|
507
|
+
assert.ok(derivedAfterPlan.readyTasks.every((task) => task.isLeaf));
|
|
508
|
+
|
|
509
|
+
controlAfterPlan.tasks.push(
|
|
510
|
+
{
|
|
511
|
+
id: "next-leaf-a",
|
|
512
|
+
title: "Prepare active work regression",
|
|
513
|
+
phase: "E",
|
|
514
|
+
stream: "Operations",
|
|
515
|
+
priority: "P1",
|
|
516
|
+
status: "pending",
|
|
517
|
+
required: true,
|
|
518
|
+
dependsOn: [],
|
|
519
|
+
acceptance: [],
|
|
520
|
+
history: [],
|
|
521
|
+
origin: { kind: "manual" },
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: "next-leaf-b",
|
|
525
|
+
title: "Follow-up ready task",
|
|
526
|
+
phase: "E",
|
|
527
|
+
stream: "Operations",
|
|
528
|
+
priority: "P1",
|
|
529
|
+
status: "pending",
|
|
530
|
+
required: true,
|
|
531
|
+
dependsOn: ["next-leaf-a"],
|
|
532
|
+
acceptance: [],
|
|
533
|
+
history: [],
|
|
534
|
+
origin: { kind: "manual" },
|
|
535
|
+
},
|
|
536
|
+
);
|
|
537
|
+
writeJson(path.join(splitProject, "ops", "project_control.json"), controlAfterPlan);
|
|
538
|
+
runNode([BIN, "task", "start", "next-leaf-a"], splitProject);
|
|
539
|
+
const nextWithActiveWork = runNode([BIN, "next"], splitProject);
|
|
540
|
+
assert.match(nextWithActiveWork, /Active work:|Trabajo activo:/);
|
|
541
|
+
assert.match(nextWithActiveWork, /next-leaf-a/);
|
|
542
|
+
runNode([BIN, "task", "complete", "next-leaf-a"], splitProject);
|
|
543
|
+
const nextAfterActiveComplete = runNode([BIN, "next"], splitProject);
|
|
544
|
+
assert.match(nextAfterActiveComplete, /next-leaf-b/);
|
|
545
|
+
|
|
546
|
+
const coordinationControl = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
547
|
+
coordinationControl.tasks.push(
|
|
548
|
+
{
|
|
549
|
+
id: "user-exec-task",
|
|
550
|
+
title: "User-owned execution flow",
|
|
551
|
+
phase: "E",
|
|
552
|
+
stream: "Operations",
|
|
553
|
+
priority: "P1",
|
|
554
|
+
status: "pending",
|
|
555
|
+
required: true,
|
|
556
|
+
dependsOn: [],
|
|
557
|
+
acceptance: [],
|
|
558
|
+
history: [],
|
|
559
|
+
origin: { kind: "manual" },
|
|
560
|
+
execution: { owner: "user" },
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: "dashboard-verify-task",
|
|
564
|
+
title: "Dashboard verification flow",
|
|
565
|
+
phase: "E",
|
|
566
|
+
stream: "Operations",
|
|
567
|
+
priority: "P1",
|
|
568
|
+
status: "pending",
|
|
569
|
+
required: true,
|
|
570
|
+
dependsOn: [],
|
|
571
|
+
acceptance: [],
|
|
572
|
+
history: [],
|
|
573
|
+
origin: { kind: "manual" },
|
|
574
|
+
execution: { owner: "shared" },
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
id: "agent-exec-task",
|
|
578
|
+
title: "Agent execution flow",
|
|
579
|
+
phase: "E",
|
|
580
|
+
stream: "Operations",
|
|
581
|
+
priority: "P1",
|
|
582
|
+
status: "pending",
|
|
583
|
+
required: true,
|
|
584
|
+
dependsOn: [],
|
|
585
|
+
acceptance: [],
|
|
586
|
+
history: [],
|
|
587
|
+
origin: { kind: "manual" },
|
|
588
|
+
execution: { owner: "agent" },
|
|
589
|
+
},
|
|
590
|
+
);
|
|
591
|
+
writeJson(path.join(splitProject, "ops", "project_control.json"), coordinationControl);
|
|
592
|
+
|
|
593
|
+
runNode([BIN, "task", "start", "user-exec-task"], splitProject);
|
|
594
|
+
const controlAfterUserExec = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
595
|
+
assert.ok(controlAfterUserExec.meta.agentInbox.pending.some((item) => item.taskId === "user-exec-task" && item.kind === "await_user_report"));
|
|
596
|
+
|
|
597
|
+
const defaultDashboard = startDashboard(splitProject);
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
const ready = await waitForDashboard(defaultDashboard);
|
|
410
601
|
const state = await get(ready.port, "/api/state");
|
|
411
602
|
const envPayload = await get(ready.port, "/api/env");
|
|
412
|
-
const operaBootstrapPayload = await get(ready.port, "/api/opera/bootstrap");
|
|
413
|
-
const operaHandoffPayload = await get(ready.port, "/api/opera/handoff");
|
|
414
|
-
const localSkills = await get(ready.port, "/api/skills/local");
|
|
415
|
-
const discoverSkills = await get(ready.port, "/api/skills/discover");
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
assert.strictEqual(
|
|
603
|
+
const operaBootstrapPayload = await get(ready.port, "/api/opera/bootstrap");
|
|
604
|
+
const operaHandoffPayload = await get(ready.port, "/api/opera/handoff");
|
|
605
|
+
const localSkills = await get(ready.port, "/api/skills/local");
|
|
606
|
+
const discoverSkills = await get(ready.port, "/api/skills/discover");
|
|
607
|
+
const plansPayload = await get(ready.port, "/api/plans");
|
|
608
|
+
const planSourcePayload = await get(ready.port, "/api/plans/booking-plan");
|
|
609
|
+
const qualityPayload = await get(ready.port, "/api/quality");
|
|
610
|
+
const phaseReadinessPayload = await get(ready.port, "/api/quality/phase-readiness");
|
|
611
|
+
const releaseReadinessPayload = await get(ready.port, "/api/quality/release-readiness");
|
|
612
|
+
const promotionReadinessPayload = await get(ready.port, "/api/quality/promotion-readiness?target=production");
|
|
613
|
+
const waiversPayload = await get(ready.port, "/api/quality/waivers");
|
|
614
|
+
|
|
615
|
+
assert.strictEqual(state.status, 200);
|
|
616
|
+
assert.strictEqual(envPayload.status, 200);
|
|
617
|
+
assert.strictEqual(operaBootstrapPayload.status, 200);
|
|
618
|
+
assert.strictEqual(operaHandoffPayload.status, 200);
|
|
619
|
+
assert.strictEqual(localSkills.status, 200);
|
|
620
|
+
assert.strictEqual(discoverSkills.status, 200);
|
|
621
|
+
assert.strictEqual(plansPayload.status, 200);
|
|
622
|
+
assert.strictEqual(planSourcePayload.status, 200);
|
|
623
|
+
assert.strictEqual(qualityPayload.status, 200);
|
|
624
|
+
assert.strictEqual(phaseReadinessPayload.status, 200);
|
|
625
|
+
assert.strictEqual(releaseReadinessPayload.status, 200);
|
|
626
|
+
assert.strictEqual(promotionReadinessPayload.status, 200);
|
|
627
|
+
assert.strictEqual(waiversPayload.status, 200);
|
|
628
|
+
|
|
629
|
+
const statePayload = JSON.parse(state.body);
|
|
630
|
+
assert.strictEqual(statePayload.project.layout, "split");
|
|
426
631
|
assert.strictEqual(statePayload.project.workspaceRoot, splitProject);
|
|
427
632
|
assert.strictEqual(statePayload.project.appRoot, path.join(splitProject, "app"));
|
|
428
633
|
assert.strictEqual(statePayload.project.opsRoot, path.join(splitProject, "ops"));
|
|
@@ -437,13 +642,80 @@ async function main() {
|
|
|
437
642
|
assert.strictEqual(bootstrapState.status, "completed");
|
|
438
643
|
assert.strictEqual(bootstrapState.contractVersion, 3);
|
|
439
644
|
assert.strictEqual(bootstrapState.contractReadiness, "verified");
|
|
440
|
-
const handoffState = JSON.parse(operaHandoffPayload.body);
|
|
441
|
-
assert.ok(handoffState.markdown.includes("project-starter-skill"));
|
|
442
|
-
assert.ok(handoffState.openQuestionsFile.endsWith("open-questions.md"));
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
645
|
+
const handoffState = JSON.parse(operaHandoffPayload.body);
|
|
646
|
+
assert.ok(handoffState.markdown.includes("project-starter-skill"));
|
|
647
|
+
assert.ok(handoffState.openQuestionsFile.endsWith("open-questions.md"));
|
|
648
|
+
const plansState = JSON.parse(plansPayload.body);
|
|
649
|
+
assert.ok(plansState.sources.some((source) => source.id === "booking-plan"));
|
|
650
|
+
const planState = JSON.parse(planSourcePayload.body);
|
|
651
|
+
assert.strictEqual(planState.source.id, "booking-plan");
|
|
652
|
+
assert.ok(planState.preview.summary.managedTaskCount >= 3);
|
|
653
|
+
const qualityState = JSON.parse(qualityPayload.body);
|
|
654
|
+
assert.ok(qualityState.report.summary);
|
|
655
|
+
assert.ok(Array.isArray(qualityState.report.probes));
|
|
656
|
+
assert.ok(qualityState.releaseReadiness.status);
|
|
657
|
+
assert.ok(statePayload.quality.releaseReadiness.status);
|
|
658
|
+
assert.ok(!fs.existsSync(path.join(splitProject, "ops", "quality")), "las rutas GET de quality no deben crear storage");
|
|
659
|
+
|
|
660
|
+
const reviewVerify = await requestJson("POST", ready.port, "/api/quality/verify", {
|
|
661
|
+
projectId: statePayload.project.id,
|
|
662
|
+
scope: "review",
|
|
663
|
+
note: "dashboard smoke review",
|
|
664
|
+
});
|
|
665
|
+
assert.strictEqual(reviewVerify.status, 200);
|
|
666
|
+
const verifyState = JSON.parse(reviewVerify.body);
|
|
667
|
+
assert.strictEqual(verifyState.run.status, "skipped");
|
|
668
|
+
assert.ok(fs.existsSync(path.join(splitProject, "ops", "quality", "latest.json")));
|
|
669
|
+
|
|
670
|
+
const timerTask = statePayload.derived.tasks.find((task) => task.isLeaf && task.status === "pending");
|
|
671
|
+
assert.ok(timerTask, "deberia existir una tarea hoja pendiente para probar time tracking");
|
|
672
|
+
const timeStart = await requestJson("POST", ready.port, "/api/time/start", {
|
|
673
|
+
projectId: statePayload.project.id,
|
|
674
|
+
taskId: timerTask.id,
|
|
675
|
+
taskTitle: timerTask.title,
|
|
676
|
+
});
|
|
677
|
+
assert.strictEqual(timeStart.status, 201);
|
|
678
|
+
const timeStartPayload = JSON.parse(timeStart.body);
|
|
679
|
+
assert.strictEqual(timeStartPayload.autoTaskStarted, true);
|
|
680
|
+
const controlAfterTimerStart = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
681
|
+
const timedTask = controlAfterTimerStart.tasks.find((task) => task.id === timerTask.id);
|
|
682
|
+
assert.strictEqual(timedTask.status, "in_progress");
|
|
683
|
+
assert.ok((timedTask.history || []).some((entry) => entry.action === "start"));
|
|
684
|
+
|
|
685
|
+
const timeStop = await requestJson("POST", ready.port, "/api/time/stop", {
|
|
686
|
+
projectId: statePayload.project.id,
|
|
687
|
+
entryId: timeStartPayload.entry.id,
|
|
688
|
+
});
|
|
689
|
+
assert.strictEqual(timeStop.status, 200);
|
|
690
|
+
|
|
691
|
+
const dashboardVerify = await requestJson("POST", ready.port, "/api/tasks/dashboard-verify-task/action", {
|
|
692
|
+
projectId: statePayload.project.id,
|
|
693
|
+
action: "complete",
|
|
694
|
+
note: "user finished work",
|
|
695
|
+
actor: "user",
|
|
696
|
+
source: "dashboard_test",
|
|
697
|
+
});
|
|
698
|
+
assert.strictEqual(dashboardVerify.status, 200);
|
|
699
|
+
const controlAfterDashboardVerify = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
700
|
+
assert.ok(controlAfterDashboardVerify.meta.agentInbox.pending.some((item) => item.taskId === "dashboard-verify-task" && item.kind === "verify_status"));
|
|
701
|
+
|
|
702
|
+
const linkedCommand = await requestJson("POST", ready.port, "/api/commands", {
|
|
703
|
+
projectId: statePayload.project.id,
|
|
704
|
+
command: "echo agent-linked-task",
|
|
705
|
+
taskId: "agent-exec-task",
|
|
706
|
+
source: "execution_console",
|
|
707
|
+
});
|
|
708
|
+
assert.strictEqual(linkedCommand.status, 201);
|
|
709
|
+
await wait(400);
|
|
710
|
+
const controlAfterLinkedCommand = readJson(path.join(splitProject, "ops", "project_control.json"));
|
|
711
|
+
const linkedTask = controlAfterLinkedCommand.tasks.find((task) => task.id === "agent-exec-task");
|
|
712
|
+
assert.strictEqual(linkedTask.status, "in_progress");
|
|
713
|
+
assert.strictEqual(linkedTask.execution.lastActor, "agent");
|
|
714
|
+
assert.ok(linkedTask.execution.lastSessionId);
|
|
715
|
+
|
|
716
|
+
} finally {
|
|
717
|
+
await stopDashboard(defaultDashboard);
|
|
718
|
+
}
|
|
447
719
|
|
|
448
720
|
const blocked4173 = await isPortFree(4173) ? await occupyPort(4173) : null;
|
|
449
721
|
const fallbackDashboard = startDashboard(splitProject);
|
|
@@ -522,28 +794,98 @@ async function main() {
|
|
|
522
794
|
version: "0.9.0",
|
|
523
795
|
};
|
|
524
796
|
writeJson(legacyUnsupportedControlPath, legacyUnsupportedControl);
|
|
525
|
-
const legacyStatusOutput = runNode([BIN, "opera", "status"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
526
|
-
assert.match(legacyStatusOutput, /legacy_unsupported/);
|
|
797
|
+
const legacyStatusOutput = runNode([BIN, "opera", "status"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
798
|
+
assert.match(legacyStatusOutput, /legacy_unsupported/);
|
|
527
799
|
const upgradeWithoutReset = runNodeResult([BIN, "opera", "upgrade", "--stable"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
528
800
|
assert.strictEqual(upgradeWithoutReset.status, 0);
|
|
529
801
|
assert.match(`${upgradeWithoutReset.stdout}\n${upgradeWithoutReset.stderr}`, /legacy/i);
|
|
530
|
-
runNode([BIN, "opera", "upgrade", "--stable", "--reset"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
531
|
-
const upgradedLegacyControl = readJson(legacyUnsupportedControlPath);
|
|
532
|
-
assert.strictEqual(upgradedLegacyControl.meta.opera.legacyStatus, "supported");
|
|
533
|
-
assert.strictEqual(upgradedLegacyControl.meta.opera.stableTag, "stable");
|
|
534
|
-
assert.ok(fs.existsSync(path.join(legacyUnsupportedProject, "ops", ".tmp", "upgrade-backups")));
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
fs.mkdirSync(
|
|
538
|
-
initGitRepo(
|
|
539
|
-
runNode([BIN, "init"],
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
802
|
+
runNode([BIN, "opera", "upgrade", "--stable", "--reset"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
|
|
803
|
+
const upgradedLegacyControl = readJson(legacyUnsupportedControlPath);
|
|
804
|
+
assert.strictEqual(upgradedLegacyControl.meta.opera.legacyStatus, "supported");
|
|
805
|
+
assert.strictEqual(upgradedLegacyControl.meta.opera.stableTag, "stable");
|
|
806
|
+
assert.ok(fs.existsSync(path.join(legacyUnsupportedProject, "ops", ".tmp", "upgrade-backups")));
|
|
807
|
+
|
|
808
|
+
const upgradeBackfillProject = path.join(tempRoot, "upgrade-backfill");
|
|
809
|
+
fs.mkdirSync(upgradeBackfillProject, { recursive: true });
|
|
810
|
+
initGitRepo(upgradeBackfillProject);
|
|
811
|
+
runNode([BIN, "init", "--locale", "en"], upgradeBackfillProject);
|
|
812
|
+
git(["checkout", "-b", "develop"], upgradeBackfillProject);
|
|
813
|
+
git(["remote", "add", "origin", "https://github.com/example/upgrade-backfill.git"], upgradeBackfillProject);
|
|
814
|
+
runNode([BIN, "opera", "install", "--locale", "en", "--no-bootstrap"], upgradeBackfillProject);
|
|
815
|
+
writeJson(path.join(upgradeBackfillProject, "ops", "bootstrap", "intake.json"), {
|
|
816
|
+
problemStatement: "Backfill test",
|
|
817
|
+
targetUser: "Developers",
|
|
818
|
+
singularDesiredOutcome: "Persist inferred metadata",
|
|
819
|
+
sourceOfTruth: "workspace",
|
|
820
|
+
payload: "release bundle",
|
|
821
|
+
});
|
|
822
|
+
writeJson(path.join(upgradeBackfillProject, "app", "package.json"), {
|
|
823
|
+
name: "upgrade-backfill",
|
|
824
|
+
version: "1.0.0",
|
|
825
|
+
scripts: {
|
|
826
|
+
test: `node -e "console.log('ok')"`,
|
|
827
|
+
"release:check": `node -e "console.log('release check ok')"`,
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
runNode([BIN, "opera", "upgrade", "--stable"], upgradeBackfillProject);
|
|
831
|
+
const backfilledIntake = readJson(path.join(upgradeBackfillProject, "ops", "bootstrap", "intake.json"));
|
|
832
|
+
const backfilledControl = readJson(path.join(upgradeBackfillProject, "ops", "project_control.json"));
|
|
833
|
+
assert.strictEqual(backfilledIntake.versionControl.provider, "github");
|
|
834
|
+
assert.strictEqual(backfilledIntake.versionControl.developmentBranch, "develop");
|
|
835
|
+
assert.strictEqual(backfilledIntake.deployment.mode, "manual");
|
|
836
|
+
assert.ok(backfilledIntake.deployment.smokeCommand);
|
|
837
|
+
assert.strictEqual(backfilledControl.meta.opera.bootstrap.discovery.versionControl.provider, "github");
|
|
838
|
+
|
|
839
|
+
const localeProjectEs = path.join(tempRoot, "locale-es");
|
|
840
|
+
fs.mkdirSync(localeProjectEs, { recursive: true });
|
|
841
|
+
runNode([BIN, "init", "--locale", "es"], localeProjectEs);
|
|
842
|
+
const localeEsQuality = runNode([BIN, "quality", "status"], localeProjectEs);
|
|
843
|
+
const localeEsStatus = runNode([BIN, "status"], localeProjectEs);
|
|
844
|
+
assert.match(localeEsQuality, /Estado de calidad/i);
|
|
845
|
+
assert.match(localeEsStatus, /Calidad/i);
|
|
846
|
+
|
|
847
|
+
const localeProjectEn = path.join(tempRoot, "locale-en");
|
|
848
|
+
fs.mkdirSync(localeProjectEn, { recursive: true });
|
|
849
|
+
runNode([BIN, "init", "--locale", "en"], localeProjectEn);
|
|
850
|
+
const localeEnQuality = runNode([BIN, "quality", "status"], localeProjectEn);
|
|
851
|
+
const localeEnStatus = runNode([BIN, "status"], localeProjectEn);
|
|
852
|
+
assert.match(localeEnQuality, /Quality status/i);
|
|
853
|
+
assert.match(localeEnStatus, /Quality/i);
|
|
854
|
+
|
|
855
|
+
const releaseProject = path.join(tempRoot, "release-demo");
|
|
856
|
+
fs.mkdirSync(releaseProject, { recursive: true });
|
|
857
|
+
initGitRepo(releaseProject);
|
|
858
|
+
runNode([BIN, "init"], releaseProject);
|
|
859
|
+
writeJson(path.join(releaseProject, "app", "package.json"), {
|
|
860
|
+
name: "release-demo",
|
|
861
|
+
version: "1.0.0",
|
|
862
|
+
scripts: {
|
|
863
|
+
test: `node -e "console.log('test ok')"`,
|
|
864
|
+
smoke: `node -e "console.log('smoke ok')"`,
|
|
865
|
+
"release:check": `node -e "console.log('release check ok')"`,
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
fs.writeFileSync(path.join(releaseProject, "app", "index.js"), "console.log('release');\n", "utf8");
|
|
869
|
+
const releaseControlPath = path.join(releaseProject, "ops", "project_control.json");
|
|
870
|
+
const releaseControl = readJson(releaseControlPath);
|
|
871
|
+
releaseControl.meta.quality = releaseControl.meta.quality || {};
|
|
872
|
+
releaseControl.meta.quality.verification = releaseControl.meta.quality.verification || {};
|
|
873
|
+
releaseControl.meta.quality.verification.smokeCommands = [`node -e "console.log('smoke ok')"`];
|
|
874
|
+
writeJson(releaseControlPath, releaseControl);
|
|
875
|
+
commitAll(releaseProject, "split fixture");
|
|
876
|
+
git(["checkout", "-b", "develop"], releaseProject);
|
|
877
|
+
const blockedRelease = runNodeResult([BIN, "release"], releaseProject);
|
|
878
|
+
assert.notStrictEqual(blockedRelease.status, 0, "release debe bloquearse si no hay evidencia de quality verify");
|
|
879
|
+
assert.match(`${blockedRelease.stdout}\n${blockedRelease.stderr}`, /quality readiness/i);
|
|
880
|
+
const missingBuildVerify = runNodeResult([BIN, "quality", "verify", "--scope", "build", "--json"], releaseProject);
|
|
881
|
+
assert.notStrictEqual(missingBuildVerify.status, 0, "verify --scope build debe fallar si no hay build configurado");
|
|
882
|
+
const missingBuildPayload = JSON.parse(missingBuildVerify.stdout);
|
|
883
|
+
assert.strictEqual(missingBuildPayload.results[0].status, "not_configured");
|
|
884
|
+
runNode([BIN, "quality", "verify", "--scope", "all"], releaseProject);
|
|
885
|
+
commitAll(releaseProject, "quality evidence");
|
|
886
|
+
runNode([BIN, "release"], releaseProject);
|
|
887
|
+
const publishFiles = git(["ls-tree", "--name-only", "master"], releaseProject).split(/\r?\n/).filter(Boolean);
|
|
888
|
+
assert.ok(publishFiles.includes("package.json"));
|
|
547
889
|
assert.ok(publishFiles.includes(".env.example"));
|
|
548
890
|
assert.ok(!publishFiles.includes("ops"));
|
|
549
891
|
assert.ok(!publishFiles.includes(".trackops-workspace.json"));
|