trackops 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +695 -402
  3. package/bin/trackops.js +116 -116
  4. package/lib/config.js +326 -326
  5. package/lib/control.js +208 -208
  6. package/lib/env.js +244 -244
  7. package/lib/init.js +325 -325
  8. package/lib/locale.js +24 -0
  9. package/lib/opera-bootstrap.js +941 -874
  10. package/lib/opera.js +494 -477
  11. package/lib/preferences.js +74 -74
  12. package/lib/registry.js +214 -196
  13. package/lib/release.js +56 -56
  14. package/lib/runtime-state.js +144 -144
  15. package/lib/server.js +312 -207
  16. package/lib/skills.js +74 -57
  17. package/lib/workspace.js +260 -260
  18. package/locales/en.json +192 -166
  19. package/locales/es.json +192 -166
  20. package/package.json +61 -58
  21. package/scripts/postinstall-locale.js +21 -21
  22. package/scripts/skills-marketplace-smoke.js +124 -124
  23. package/scripts/smoke-tests.js +558 -554
  24. package/scripts/sync-skill-version.js +21 -21
  25. package/scripts/validate-skill.js +103 -103
  26. package/skills/trackops/SKILL.md +126 -122
  27. package/skills/trackops/agents/openai.yaml +7 -7
  28. package/skills/trackops/locales/en/SKILL.md +126 -122
  29. package/skills/trackops/locales/en/references/activation.md +94 -75
  30. package/skills/trackops/locales/en/references/troubleshooting.md +73 -55
  31. package/skills/trackops/locales/en/references/workflow.md +55 -32
  32. package/skills/trackops/references/activation.md +94 -75
  33. package/skills/trackops/references/troubleshooting.md +73 -55
  34. package/skills/trackops/references/workflow.md +55 -32
  35. package/skills/trackops/skill.json +29 -29
  36. package/templates/hooks/post-checkout +2 -2
  37. package/templates/hooks/post-commit +2 -2
  38. package/templates/hooks/post-merge +2 -2
  39. package/templates/opera/agent.md +28 -27
  40. package/templates/opera/architecture/dependency-graph.md +24 -24
  41. package/templates/opera/architecture/runtime-automation.md +24 -24
  42. package/templates/opera/architecture/runtime-operations.md +34 -34
  43. package/templates/opera/en/agent.md +22 -21
  44. package/templates/opera/en/architecture/dependency-graph.md +24 -24
  45. package/templates/opera/en/architecture/runtime-automation.md +24 -24
  46. package/templates/opera/en/architecture/runtime-operations.md +34 -34
  47. package/templates/opera/en/reviews/delivery-audit.md +18 -18
  48. package/templates/opera/en/reviews/integration-audit.md +18 -18
  49. package/templates/opera/en/router.md +24 -19
  50. package/templates/opera/references/autonomy-and-recovery.md +117 -117
  51. package/templates/opera/references/opera-cycle.md +193 -193
  52. package/templates/opera/registry.md +28 -28
  53. package/templates/opera/reviews/delivery-audit.md +18 -18
  54. package/templates/opera/reviews/integration-audit.md +18 -18
  55. package/templates/opera/router.md +54 -49
  56. package/templates/skills/changelog-updater/SKILL.md +69 -69
  57. package/templates/skills/commiter/SKILL.md +99 -99
  58. package/templates/skills/opera-contract-auditor/SKILL.md +38 -38
  59. package/templates/skills/opera-contract-auditor/locales/en/SKILL.md +38 -38
  60. package/templates/skills/opera-policy-guard/SKILL.md +26 -26
  61. package/templates/skills/opera-policy-guard/locales/en/SKILL.md +26 -26
  62. package/templates/skills/opera-skill/SKILL.md +279 -0
  63. package/templates/skills/opera-skill/locales/en/SKILL.md +279 -0
  64. package/templates/skills/opera-skill/locales/en/references/phase-dod.md +138 -0
  65. package/templates/skills/opera-skill/references/phase-dod.md +138 -0
  66. package/templates/skills/project-starter-skill/SKILL.md +150 -131
  67. package/templates/skills/project-starter-skill/locales/en/SKILL.md +143 -105
  68. package/templates/skills/project-starter-skill/references/opera-cycle.md +195 -193
  69. package/ui/css/base.css +284 -266
  70. package/ui/css/charts.css +425 -327
  71. package/ui/css/components.css +1107 -570
  72. package/ui/css/onboarding.css +133 -0
  73. package/ui/css/panels.css +345 -406
  74. package/ui/css/terminal.css +125 -0
  75. package/ui/css/timeline.css +58 -0
  76. package/ui/css/tokens.css +284 -227
  77. package/ui/favicon.svg +5 -5
  78. package/ui/index.html +99 -96
  79. package/ui/js/api.js +49 -13
  80. package/ui/js/app.js +28 -32
  81. package/ui/js/charts.js +526 -0
  82. package/ui/js/console-logger.js +172 -172
  83. package/ui/js/filters.js +247 -0
  84. package/ui/js/icons.js +129 -104
  85. package/ui/js/keyboard.js +229 -0
  86. package/ui/js/onboarding.js +33 -42
  87. package/ui/js/router.js +142 -125
  88. package/ui/js/theme.js +100 -100
  89. package/ui/js/time-tracker.js +248 -248
  90. package/ui/js/views/board.js +84 -114
  91. package/ui/js/views/dashboard.js +870 -0
  92. package/ui/js/views/flash.js +47 -47
  93. package/ui/js/views/projects.js +745 -0
  94. package/ui/js/views/scrum.js +476 -0
  95. package/ui/js/views/settings.js +153 -203
  96. package/ui/js/views/sidebar.js +37 -31
  97. package/ui/js/views/tasks.js +218 -101
  98. package/ui/js/views/timeline.js +265 -0
  99. package/ui/js/views/topbar.js +94 -107
  100. package/ui/app.js +0 -950
  101. package/ui/js/views/insights.js +0 -340
  102. package/ui/js/views/overview.js +0 -369
  103. package/ui/styles.css +0 -688
@@ -1,554 +1,558 @@
1
- #!/usr/bin/env node
2
-
3
- const assert = require("assert");
4
- const fs = require("fs");
5
- const http = require("http");
6
- const net = require("net");
7
- const os = require("os");
8
- const path = require("path");
9
- const { spawn, spawnSync } = require("child_process");
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");
14
-
15
- function getNpmCommand() {
16
- return process.platform === "win32" ? "npm.cmd" : "npm";
17
- }
18
-
19
- function runNode(args, cwd, envOverrides = {}) {
20
- const result = spawnSync(process.execPath, args, {
21
- cwd,
22
- encoding: "utf8",
23
- env: { ...process.env, ...envOverrides },
24
- });
25
- assert.strictEqual(result.status, 0, result.stderr || result.stdout || `fallo ejecutando ${args.join(" ")}`);
26
- return result.stdout;
27
- }
28
-
29
- function runNodeResult(args, cwd, envOverrides = {}) {
30
- return spawnSync(process.execPath, args, {
31
- cwd,
32
- encoding: "utf8",
33
- env: { ...process.env, ...envOverrides },
34
- });
35
- }
36
-
37
- function runCommand(command, args, cwd, envOverrides = {}) {
38
- return spawnSync(command, args, {
39
- cwd,
40
- encoding: "utf8",
41
- env: { ...process.env, ...envOverrides },
42
- });
43
- }
44
-
45
- function runNpm(args, cwd, envOverrides = {}) {
46
- return spawnSync(getNpmCommand(), args, {
47
- cwd,
48
- encoding: "utf8",
49
- env: { ...process.env, ...envOverrides },
50
- shell: process.platform === "win32",
51
- });
52
- }
53
-
54
- function git(args, cwd) {
55
- const result = spawnSync("git", args, { cwd, encoding: "utf8" });
56
- assert.strictEqual(result.status, 0, result.stderr || result.stdout || `git ${args.join(" ")} fallo`);
57
- return result.stdout.trim();
58
- }
59
-
60
- function wait(ms) {
61
- return new Promise((resolve) => setTimeout(resolve, ms));
62
- }
63
-
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");
69
- res.on("data", (chunk) => { body += chunk; });
70
- res.on("end", () => resolve({ status: res.statusCode, body }));
71
- });
72
- req.on("error", reject);
73
- });
74
- }
75
-
76
- function extractLocalPort(output) {
77
- const match = String(output || "").match(/- Local:\s+http:\/\/[^\s:]+:(\d+)/);
78
- return match ? Number(match[1]) : null;
79
- }
80
-
81
- function hasExternalIpv4() {
82
- return Object.values(os.networkInterfaces()).some((entries) => (
83
- (entries || []).some((entry) => entry && entry.family === "IPv4" && !entry.internal)
84
- ));
85
- }
86
-
87
- function isPortFree(port, host = "127.0.0.1") {
88
- return new Promise((resolve) => {
89
- const server = net.createServer();
90
- server.once("error", () => resolve(false));
91
- server.once("listening", () => {
92
- server.close(() => resolve(true));
93
- });
94
- server.listen(port, host);
95
- });
96
- }
97
-
98
- function findFreePort(host = "127.0.0.1") {
99
- return new Promise((resolve, reject) => {
100
- const server = net.createServer();
101
- server.once("error", reject);
102
- server.listen(0, host, () => {
103
- const address = server.address();
104
- const port = typeof address === "object" && address ? address.port : null;
105
- server.close((closeError) => {
106
- if (closeError) reject(closeError);
107
- else resolve(port);
108
- });
109
- });
110
- });
111
- }
112
-
113
- function occupyPort(port, host = "127.0.0.1") {
114
- return new Promise((resolve, reject) => {
115
- const server = http.createServer((_req, res) => {
116
- res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
117
- res.end("busy");
118
- });
119
- server.once("error", reject);
120
- server.listen(port, host, () => resolve(server));
121
- });
122
- }
123
-
124
- function startDashboard(cwd, args = [], envOverrides = {}) {
125
- const env = { ...process.env, ...envOverrides };
126
- const child = spawn(process.execPath, [BIN, "dashboard", ...args], {
127
- cwd,
128
- env,
129
- stdio: ["ignore", "pipe", "pipe"],
130
- });
131
-
132
- let output = "";
133
- child.stdout.on("data", (chunk) => { output += chunk.toString("utf8"); });
134
- child.stderr.on("data", (chunk) => { output += chunk.toString("utf8"); });
135
-
136
- return {
137
- child,
138
- output: () => output,
139
- };
140
- }
141
-
142
- async function waitForDashboard(session) {
143
- const startedAt = Date.now();
144
-
145
- while (Date.now() - startedAt < 15000) {
146
- if (session.child.exitCode != null) {
147
- throw new Error(`el dashboard termino antes de iniciar:\n${session.output()}`);
148
- }
149
-
150
- const port = extractLocalPort(session.output());
151
- if (port) {
152
- try {
153
- const response = await get(port, "/api/state");
154
- if (response.status === 200) {
155
- return { port, output: session.output() };
156
- }
157
- } catch (_error) {
158
- // sigue esperando
159
- }
160
- }
161
-
162
- await wait(250);
163
- }
164
-
165
- throw new Error(`el dashboard no respondio a tiempo:\n${session.output()}`);
166
- }
167
-
168
- async function stopDashboard(session) {
169
- if (!session || !session.child || session.child.exitCode != null) return;
170
- session.child.kill("SIGTERM");
171
- await wait(500);
172
- if (session.child.exitCode == null) {
173
- session.child.kill("SIGKILL");
174
- await wait(250);
175
- }
176
- }
177
-
178
- function writeJson(filePath, data) {
179
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
180
- fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
181
- }
182
-
183
- function packCurrentPackage(tempRoot) {
184
- const result = runNpm(["pack", ROOT], tempRoot);
185
- assert.strictEqual(result.status, 0, result.stderr || result.stdout || "npm pack fallo");
186
- const tarballName = String(result.stdout || "").trim().split(/\r?\n/).pop();
187
- const tarballPath = path.join(tempRoot, tarballName);
188
- assert.ok(fs.existsSync(tarballPath), `no se encontro el tarball ${tarballPath}`);
189
- return tarballPath;
190
- }
191
-
192
- function readJson(filePath) {
193
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
194
- }
195
-
196
- function initGitRepo(repo) {
197
- git(["init"], repo);
198
- git(["config", "user.email", "smoke@example.com"], repo);
199
- git(["config", "user.name", "Smoke Runner"], repo);
200
- }
201
-
202
- function commitAll(repo, message) {
203
- git(["add", "."], repo);
204
- git(["commit", "-m", message], repo);
205
- }
206
-
207
- async function main() {
208
- runNode([SKILL_VALIDATE], ROOT);
209
-
210
- const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trackops-smoke-"));
211
- const tarballPath = packCurrentPackage(tempRoot);
212
- const packageVersion = readJson(path.join(ROOT, "package.json")).version;
213
-
214
- const bootstrapHome = path.join(tempRoot, "bootstrap-home");
215
- const bootstrapPrefix = path.join(tempRoot, "bootstrap-prefix");
216
- fs.mkdirSync(bootstrapHome, { recursive: true });
217
- fs.mkdirSync(bootstrapPrefix, { recursive: true });
218
-
219
- const bootstrapEnv = {
220
- TRACKOPS_BOOTSTRAP_HOME: bootstrapHome,
221
- TRACKOPS_BOOTSTRAP_PREFIX: bootstrapPrefix,
222
- };
223
-
224
- const explicitInstall = runNpm(["install", "-g", "--prefix", bootstrapPrefix, tarballPath], tempRoot, bootstrapEnv);
225
- assert.strictEqual(explicitInstall.status, 0, explicitInstall.stderr || explicitInstall.stdout || "instalacion global explicita fallo");
226
-
227
- const runtimeStamp = readJson(path.join(bootstrapHome, ".trackops", "runtime.json"));
228
- assert.ok(["es", "en"].includes(runtimeStamp.locale), "el bootstrap global debe fijar un idioma");
229
-
230
- const installedCli = path.join(bootstrapPrefix, "node_modules", "trackops", "bin", "trackops.js");
231
- assert.ok(fs.existsSync(installedCli), "el runtime instalado debe existir dentro del prefijo aislado");
232
- const installedVersion = runNode([installedCli, "--version"], tempRoot);
233
- assert.strictEqual(installedVersion.trim(), packageVersion);
234
-
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);
240
-
241
- const versionOutput = runNode([BIN, "--version"], ROOT);
242
- assert.strictEqual(versionOutput.trim(), packageVersion);
243
-
244
- runNode([BIN, "locale", "set", "en"], tempRoot, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
245
- const localeGet = runNode([BIN, "locale", "get"], tempRoot, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
246
- assert.match(localeGet, /Effective language: en|Idioma efectivo: en/);
247
- const localeDoctor = runNode([BIN, "doctor", "locale"], tempRoot, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
248
- assert.match(localeDoctor, /Source: global|Origen: global/);
249
-
250
- const splitProject = path.join(tempRoot, "split-demo");
251
- fs.mkdirSync(splitProject, { recursive: true });
252
- runNode([BIN, "init", "--locale", "es"], splitProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
253
-
254
- assert.ok(fs.existsSync(path.join(splitProject, ".trackops-workspace.json")));
255
- assert.ok(fs.existsSync(path.join(splitProject, ".env")));
256
- assert.ok(fs.existsSync(path.join(splitProject, ".env.example")));
257
- assert.ok(fs.existsSync(path.join(splitProject, "app")));
258
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "project_control.json")));
259
- assert.ok(fs.existsSync(path.join(splitProject, "app", ".env")));
260
- assert.ok(fs.existsSync(path.join(splitProject, "app", ".env.example")));
261
- assert.ok(!fs.existsSync(path.join(splitProject, "project_control.json")));
262
- assert.ok(!fs.existsSync(path.join(splitProject, "task_plan.md")));
263
-
264
- const splitControl = readJson(path.join(splitProject, "ops", "project_control.json"));
265
- assert.strictEqual(splitControl.meta.workspace.layout, "split");
266
- assert.strictEqual(splitControl.meta.environment.rootEnvFile, ".env");
267
- assert.strictEqual(splitControl.meta.locale, "es");
268
-
269
- const projectLocaleDoctor = runNode([BIN, "doctor", "locale"], splitProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
270
- assert.match(projectLocaleDoctor, /Project language: es|Idioma del proyecto: es/);
271
- assert.match(projectLocaleDoctor, /Source: project|Origen: proyecto/);
272
-
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
- const nextOutput = runNode([BIN, "next"], splitProject);
281
- assert.match(nextOutput, /ops-bootstrap/);
282
-
283
- runNode([BIN, "sync"], splitProject);
284
- for (const file of ["task_plan.md", "progress.md", "findings.md"]) {
285
- assert.ok(fs.existsSync(path.join(splitProject, "ops", file)), `${file} no fue generado en ops/`);
286
- }
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 = runNodeResult([BIN, "init"], nonEmptyProject);
296
- assert.notStrictEqual(initNonEmpty.status, 0, "init debe abortar en directorios no vacios");
297
- assert.match(`${initNonEmpty.stdout}\n${initNonEmpty.stderr}`, /workspace migrate/i);
298
-
299
- writeJson(path.join(splitProject, "app", "package.json"), {
300
- name: "split-demo",
301
- version: "1.0.0",
302
- dependencies: { openai: "^4.0.0" },
303
- scripts: { test: "echo ok" },
304
- });
305
- runNode([BIN, "opera", "install", "--locale", "en", "--non-interactive"], splitProject);
306
-
307
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "genesis.md")));
308
- assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agent", "hub", "agent.md")));
309
- assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agents", "skills", "_registry.md")));
310
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "bootstrap", "agent-handoff.md")));
311
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "bootstrap", "agent-handoff.json")));
312
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "bootstrap", "open-questions.md")));
313
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "architecture", "runtime-operations.md")));
314
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "architecture", "dependency-graph.md")));
315
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "architecture", "runtime-automation.md")));
316
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "policy", "autonomy.json")));
317
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "reviews", "integration-audit.md")));
318
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "reviews", "delivery-audit.md")));
319
- assert.ok(!fs.existsSync(path.join(splitProject, "genesis.md")));
320
-
321
- const operaControl = readJson(path.join(splitProject, "ops", "project_control.json"));
322
- assert.strictEqual(operaControl.meta.locale, "en");
323
- assert.strictEqual(operaControl.meta.opera.installed, true);
324
- assert.strictEqual(operaControl.meta.opera.bootstrap.mode, "agent_handoff");
325
- assert.strictEqual(operaControl.meta.opera.bootstrap.status, "awaiting_agent");
326
- assert.ok(operaControl.meta.opera.skills.includes("project-starter-skill"));
327
- assert.ok(operaControl.meta.opera.skills.includes("opera-contract-auditor"));
328
- assert.ok(operaControl.meta.opera.skills.includes("opera-policy-guard"));
329
- assert.ok(operaControl.meta.environment.requiredKeys.includes("OPENAI_API_KEY"));
330
- const envRootText = fs.readFileSync(path.join(splitProject, ".env"), "utf8");
331
- assert.match(envRootText, /OPENAI_API_KEY=/);
332
-
333
- const handoffPrint = runNode([BIN, "opera", "handoff", "--print"], splitProject);
334
- assert.match(handoffPrint, /project-starter-skill/);
335
- const handoffJson = JSON.parse(runNode([BIN, "opera", "handoff", "--json"], splitProject));
336
- assert.strictEqual(handoffJson.skill, "project-starter-skill");
337
-
338
- const directProject = path.join(tempRoot, "direct-demo");
339
- fs.mkdirSync(directProject, { recursive: true });
340
- runNode([BIN, "init", "--locale", "en"], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
341
- writeJson(path.join(directProject, "app", "package.json"), { name: "direct-demo", version: "1.0.0" });
342
- runNode([
343
- BIN,
344
- "opera",
345
- "install",
346
- "--locale",
347
- "en",
348
- "--non-interactive",
349
- "--bootstrap-mode",
350
- "direct",
351
- "--technical-level",
352
- "senior",
353
- "--project-state",
354
- "existing_repo",
355
- "--docs-state",
356
- "spec_dossier",
357
- "--decision-ownership",
358
- "user",
359
- ], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
360
- const directControl = readJson(path.join(directProject, "ops", "project_control.json"));
361
- assert.strictEqual(directControl.meta.opera.bootstrap.mode, "direct_cli");
362
- assert.strictEqual(directControl.meta.opera.bootstrap.status, "awaiting_intake");
363
-
364
- writeJson(path.join(splitProject, "ops", "bootstrap", "intake.json"), {
365
- version: 1,
366
- technicalLevel: "low",
367
- projectState: "idea",
368
- documentationState: "none",
369
- decisionOwnership: "agent",
370
- problemStatement: "users need a simple way to book and pay online",
371
- targetUser: "small studio owners",
372
- singularDesiredOutcome: "let users create and pay for bookings",
373
- userLanguage: "en",
374
- needsPlainLanguage: true,
375
- recommendedStack: ["nextjs"],
376
- externalServices: ["OpenAI", "Stripe"],
377
- sourceOfTruth: "primary bookings database",
378
- payload: "bookings dashboard",
379
- behaviorRules: ["keep explanations simple"],
380
- architecturalInvariants: ["keep app and ops separated"],
381
- inputSchema: { booking: { email: "string" } },
382
- outputSchema: { confirmation: { id: "string" } },
383
- pipeline: ["create booking", "confirm payment"],
384
- templates: ["booking-confirmation"],
385
- });
386
- fs.writeFileSync(
387
- path.join(splitProject, "ops", "bootstrap", "spec-dossier.md"),
388
- "# Spec dossier\n\n## Problem statement\nusers need a simple way to book and pay online\n\n## Target user\nsmall studio owners\n\n## Singular desired outcome\nlet users create and pay for bookings\n\n## Delivery target\nbookings dashboard\n\n## Source of truth\nprimary bookings database\n",
389
- "utf8",
390
- );
391
- runNode([BIN, "opera", "bootstrap", "--resume"], splitProject);
392
- const resumedControl = readJson(path.join(splitProject, "ops", "project_control.json"));
393
- assert.strictEqual(resumedControl.meta.opera.bootstrap.status, "completed");
394
- assert.strictEqual(resumedControl.meta.currentFocus, "let users create and pay for bookings");
395
- assert.ok(resumedControl.meta.environment.requiredKeys.includes("STRIPE_SECRET_KEY"));
396
- assert.ok(fs.existsSync(path.join(splitProject, "ops", "contract", "operating-contract.json")));
397
- const operatingContract = readJson(path.join(splitProject, "ops", "contract", "operating-contract.json"));
398
- assert.strictEqual(operatingContract.version, 3);
399
- assert.strictEqual(operatingContract.userModel.language, "en");
400
- assert.strictEqual(operatingContract.userModel.decisionOwnership, "agent");
401
-
402
- const defaultDashboard = startDashboard(splitProject);
403
-
404
- try {
405
- const ready = await waitForDashboard(defaultDashboard);
406
- const state = await get(ready.port, "/api/state");
407
- const envPayload = await get(ready.port, "/api/env");
408
- const operaBootstrapPayload = await get(ready.port, "/api/opera/bootstrap");
409
- const operaHandoffPayload = await get(ready.port, "/api/opera/handoff");
410
- const localSkills = await get(ready.port, "/api/skills/local");
411
- const discoverSkills = await get(ready.port, "/api/skills/discover");
412
-
413
- assert.strictEqual(state.status, 200);
414
- assert.strictEqual(envPayload.status, 200);
415
- assert.strictEqual(operaBootstrapPayload.status, 200);
416
- assert.strictEqual(operaHandoffPayload.status, 200);
417
- assert.strictEqual(localSkills.status, 200);
418
- assert.strictEqual(discoverSkills.status, 200);
419
-
420
- const statePayload = JSON.parse(state.body);
421
- assert.strictEqual(statePayload.project.layout, "split");
422
- assert.strictEqual(statePayload.project.workspaceRoot, splitProject);
423
- assert.strictEqual(statePayload.project.appRoot, path.join(splitProject, "app"));
424
- assert.strictEqual(statePayload.project.opsRoot, path.join(splitProject, "ops"));
425
- assert.ok(Array.isArray(statePayload.env.requiredKeys));
426
- assert.ok(!Object.prototype.hasOwnProperty.call(statePayload.env, "values"));
427
-
428
- const envState = JSON.parse(envPayload.body);
429
- assert.ok(envState.requiredKeys.includes("OPENAI_API_KEY"));
430
- assert.ok(envState.missingKeys.includes("OPENAI_API_KEY"));
431
-
432
- const bootstrapState = JSON.parse(operaBootstrapPayload.body);
433
- assert.strictEqual(bootstrapState.status, "completed");
434
- assert.strictEqual(bootstrapState.contractVersion, 3);
435
- assert.strictEqual(bootstrapState.contractReadiness, "verified");
436
- const handoffState = JSON.parse(operaHandoffPayload.body);
437
- assert.ok(handoffState.markdown.includes("project-starter-skill"));
438
- assert.ok(handoffState.openQuestionsFile.endsWith("open-questions.md"));
439
-
440
- } finally {
441
- await stopDashboard(defaultDashboard);
442
- }
443
-
444
- const blocked4173 = await isPortFree(4173) ? await occupyPort(4173) : null;
445
- const fallbackDashboard = startDashboard(splitProject);
446
- try {
447
- const ready = await waitForDashboard(fallbackDashboard);
448
- assert.notStrictEqual(ready.port, 4173, `deberia haber evitado 4173 ocupado:\n${ready.output}`);
449
- assert.match(ready.output, /Local:/);
450
- } finally {
451
- await stopDashboard(fallbackDashboard);
452
- if (blocked4173) blocked4173.close();
453
- }
454
-
455
- const strictPort = await findFreePort();
456
- const occupiedStrictPort = await occupyPort(strictPort);
457
- try {
458
- const strictResult = runNodeResult([BIN, "dashboard", "--strict-port", "--port", String(strictPort)], splitProject);
459
- assert.notStrictEqual(strictResult.status, 0, "el dashboard deberia fallar con --strict-port si el puerto esta ocupado");
460
- assert.match(`${strictResult.stdout}\n${strictResult.stderr}`, /already in use|esta en uso/i);
461
- } finally {
462
- occupiedStrictPort.close();
463
- }
464
-
465
- const publicPort = await findFreePort("0.0.0.0");
466
- const publicDashboard = startDashboard(splitProject, ["--public", "--port", String(publicPort)]);
467
- try {
468
- const ready = await waitForDashboard(publicDashboard);
469
- if (hasExternalIpv4()) {
470
- assert.match(ready.output, /- Network:/);
471
- }
472
- } finally {
473
- await stopDashboard(publicDashboard);
474
- }
475
-
476
- const noClipboardPort = await findFreePort();
477
- const noClipboardDashboard = startDashboard(splitProject, ["--port", String(noClipboardPort)], {
478
- PATH: "",
479
- Path: "",
480
- });
481
- try {
482
- const ready = await waitForDashboard(noClipboardDashboard);
483
- const state = await get(ready.port, "/api/state");
484
- assert.strictEqual(state.status, 200);
485
- } finally {
486
- await stopDashboard(noClipboardDashboard);
487
- }
488
-
489
- const legacyProject = path.join(tempRoot, "legacy-demo");
490
- fs.mkdirSync(legacyProject, { recursive: true });
491
- initGitRepo(legacyProject);
492
- writeJson(path.join(legacyProject, "package.json"), { name: "legacy-demo", version: "1.0.0" });
493
- runNode([BIN, "init", "--legacy-layout"], legacyProject);
494
- runNode([BIN, "opera", "install", "--locale", "en", "--no-bootstrap"], legacyProject);
495
- fs.writeFileSync(path.join(legacyProject, ".env"), "OPENAI_API_KEY=\n", "utf8");
496
- fs.writeFileSync(path.join(legacyProject, ".env.example"), "OPENAI_API_KEY=\n", "utf8");
497
- commitAll(legacyProject, "legacy fixture");
498
-
499
- runNode([BIN, "workspace", "migrate"], legacyProject);
500
- assert.ok(fs.existsSync(path.join(legacyProject, ".trackops-workspace.json")));
501
- assert.ok(fs.existsSync(path.join(legacyProject, "app", "package.json")));
502
- assert.ok(fs.existsSync(path.join(legacyProject, "ops", "project_control.json")));
503
- assert.ok(fs.existsSync(path.join(legacyProject, ".env")));
504
- assert.ok(fs.existsSync(path.join(legacyProject, "app", ".env")));
505
- assert.ok(!fs.existsSync(path.join(legacyProject, "project_control.json")));
506
- const migratedPackage = readJson(path.join(legacyProject, "app", "package.json"));
507
- assert.ok(!migratedPackage.scripts || !migratedPackage.scripts["ops:status"]);
508
- assert.match(git(["branch", "--list"], legacyProject), /backup\/trackops-workspace-/);
509
-
510
- const legacyUnsupportedProject = path.join(tempRoot, "legacy-unsupported");
511
- fs.mkdirSync(legacyUnsupportedProject, { recursive: true });
512
- initGitRepo(legacyUnsupportedProject);
513
- runNode([BIN, "init", "--locale", "en"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
514
- const legacyUnsupportedControlPath = path.join(legacyUnsupportedProject, "ops", "project_control.json");
515
- const legacyUnsupportedControl = readJson(legacyUnsupportedControlPath);
516
- legacyUnsupportedControl.meta.opera = {
517
- installed: true,
518
- version: "0.9.0",
519
- };
520
- writeJson(legacyUnsupportedControlPath, legacyUnsupportedControl);
521
- const legacyStatusOutput = runNode([BIN, "opera", "status"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
522
- assert.match(legacyStatusOutput, /legacy_unsupported/);
523
- const upgradeWithoutReset = runNodeResult([BIN, "opera", "upgrade", "--stable"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
524
- assert.strictEqual(upgradeWithoutReset.status, 0);
525
- assert.match(`${upgradeWithoutReset.stdout}\n${upgradeWithoutReset.stderr}`, /legacy/i);
526
- runNode([BIN, "opera", "upgrade", "--stable", "--reset"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
527
- const upgradedLegacyControl = readJson(legacyUnsupportedControlPath);
528
- assert.strictEqual(upgradedLegacyControl.meta.opera.legacyStatus, "supported");
529
- assert.strictEqual(upgradedLegacyControl.meta.opera.stableTag, "stable");
530
- assert.ok(fs.existsSync(path.join(legacyUnsupportedProject, "ops", ".tmp", "upgrade-backups")));
531
-
532
- const releaseProject = path.join(tempRoot, "release-demo");
533
- fs.mkdirSync(releaseProject, { recursive: true });
534
- initGitRepo(releaseProject);
535
- runNode([BIN, "init"], releaseProject);
536
- writeJson(path.join(releaseProject, "app", "package.json"), { name: "release-demo", version: "1.0.0" });
537
- fs.writeFileSync(path.join(releaseProject, "app", "index.js"), "console.log('release');\n", "utf8");
538
- commitAll(releaseProject, "split fixture");
539
- git(["checkout", "-b", "develop"], releaseProject);
540
- runNode([BIN, "release"], releaseProject);
541
- const publishFiles = git(["ls-tree", "--name-only", "master"], releaseProject).split(/\r?\n/).filter(Boolean);
542
- assert.ok(publishFiles.includes("package.json"));
543
- assert.ok(publishFiles.includes(".env.example"));
544
- assert.ok(!publishFiles.includes("ops"));
545
- assert.ok(!publishFiles.includes(".trackops-workspace.json"));
546
-
547
- fs.rmSync(tempRoot, { recursive: true, force: true });
548
- console.log("Smoke tests OK");
549
- }
550
-
551
- main().catch((error) => {
552
- console.error(error.message);
553
- process.exit(1);
554
- });
1
+ #!/usr/bin/env node
2
+
3
+ const assert = require("assert");
4
+ const fs = require("fs");
5
+ const http = require("http");
6
+ const net = require("net");
7
+ const os = require("os");
8
+ const path = require("path");
9
+ const { spawn, spawnSync } = require("child_process");
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");
14
+
15
+ function getNpmCommand() {
16
+ return process.platform === "win32" ? "npm.cmd" : "npm";
17
+ }
18
+
19
+ function runNode(args, cwd, envOverrides = {}) {
20
+ const result = spawnSync(process.execPath, args, {
21
+ cwd,
22
+ encoding: "utf8",
23
+ env: { ...process.env, ...envOverrides },
24
+ });
25
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout || `fallo ejecutando ${args.join(" ")}`);
26
+ return result.stdout;
27
+ }
28
+
29
+ function runNodeResult(args, cwd, envOverrides = {}) {
30
+ return spawnSync(process.execPath, args, {
31
+ cwd,
32
+ encoding: "utf8",
33
+ env: { ...process.env, ...envOverrides },
34
+ });
35
+ }
36
+
37
+ function runCommand(command, args, cwd, envOverrides = {}) {
38
+ return spawnSync(command, args, {
39
+ cwd,
40
+ encoding: "utf8",
41
+ env: { ...process.env, ...envOverrides },
42
+ });
43
+ }
44
+
45
+ function runNpm(args, cwd, envOverrides = {}) {
46
+ return spawnSync(getNpmCommand(), args, {
47
+ cwd,
48
+ encoding: "utf8",
49
+ env: { ...process.env, ...envOverrides },
50
+ shell: process.platform === "win32",
51
+ });
52
+ }
53
+
54
+ function git(args, cwd) {
55
+ const result = spawnSync("git", args, { cwd, encoding: "utf8" });
56
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout || `git ${args.join(" ")} fallo`);
57
+ return result.stdout.trim();
58
+ }
59
+
60
+ function wait(ms) {
61
+ return new Promise((resolve) => setTimeout(resolve, ms));
62
+ }
63
+
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");
69
+ res.on("data", (chunk) => { body += chunk; });
70
+ res.on("end", () => resolve({ status: res.statusCode, body }));
71
+ });
72
+ req.on("error", reject);
73
+ });
74
+ }
75
+
76
+ function extractLocalPort(output) {
77
+ const match = String(output || "").match(/- Local:\s+http:\/\/[^\s:]+:(\d+)/);
78
+ return match ? Number(match[1]) : null;
79
+ }
80
+
81
+ function hasExternalIpv4() {
82
+ return Object.values(os.networkInterfaces()).some((entries) => (
83
+ (entries || []).some((entry) => entry && entry.family === "IPv4" && !entry.internal)
84
+ ));
85
+ }
86
+
87
+ function isPortFree(port, host = "127.0.0.1") {
88
+ return new Promise((resolve) => {
89
+ const server = net.createServer();
90
+ server.once("error", () => resolve(false));
91
+ server.once("listening", () => {
92
+ server.close(() => resolve(true));
93
+ });
94
+ server.listen(port, host);
95
+ });
96
+ }
97
+
98
+ function findFreePort(host = "127.0.0.1") {
99
+ return new Promise((resolve, reject) => {
100
+ const server = net.createServer();
101
+ server.once("error", reject);
102
+ server.listen(0, host, () => {
103
+ const address = server.address();
104
+ const port = typeof address === "object" && address ? address.port : null;
105
+ server.close((closeError) => {
106
+ if (closeError) reject(closeError);
107
+ else resolve(port);
108
+ });
109
+ });
110
+ });
111
+ }
112
+
113
+ function occupyPort(port, host = "127.0.0.1") {
114
+ return new Promise((resolve, reject) => {
115
+ const server = http.createServer((_req, res) => {
116
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
117
+ res.end("busy");
118
+ });
119
+ server.once("error", reject);
120
+ server.listen(port, host, () => resolve(server));
121
+ });
122
+ }
123
+
124
+ function startDashboard(cwd, args = [], envOverrides = {}) {
125
+ const env = { ...process.env, ...envOverrides };
126
+ const child = spawn(process.execPath, [BIN, "dashboard", ...args], {
127
+ cwd,
128
+ env,
129
+ stdio: ["ignore", "pipe", "pipe"],
130
+ });
131
+
132
+ let output = "";
133
+ child.stdout.on("data", (chunk) => { output += chunk.toString("utf8"); });
134
+ child.stderr.on("data", (chunk) => { output += chunk.toString("utf8"); });
135
+
136
+ return {
137
+ child,
138
+ output: () => output,
139
+ };
140
+ }
141
+
142
+ async function waitForDashboard(session) {
143
+ const startedAt = Date.now();
144
+
145
+ while (Date.now() - startedAt < 15000) {
146
+ if (session.child.exitCode != null) {
147
+ throw new Error(`el dashboard termino antes de iniciar:\n${session.output()}`);
148
+ }
149
+
150
+ const port = extractLocalPort(session.output());
151
+ if (port) {
152
+ try {
153
+ const response = await get(port, "/api/state");
154
+ if (response.status === 200) {
155
+ return { port, output: session.output() };
156
+ }
157
+ } catch (_error) {
158
+ // sigue esperando
159
+ }
160
+ }
161
+
162
+ await wait(250);
163
+ }
164
+
165
+ throw new Error(`el dashboard no respondio a tiempo:\n${session.output()}`);
166
+ }
167
+
168
+ async function stopDashboard(session) {
169
+ if (!session || !session.child || session.child.exitCode != null) return;
170
+ session.child.kill("SIGTERM");
171
+ await wait(500);
172
+ if (session.child.exitCode == null) {
173
+ session.child.kill("SIGKILL");
174
+ await wait(250);
175
+ }
176
+ }
177
+
178
+ function writeJson(filePath, data) {
179
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
180
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, "utf8");
181
+ }
182
+
183
+ function packCurrentPackage(tempRoot) {
184
+ const result = runNpm(["pack", ROOT], tempRoot);
185
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout || "npm pack fallo");
186
+ const tarballName = String(result.stdout || "").trim().split(/\r?\n/).pop();
187
+ const tarballPath = path.join(tempRoot, tarballName);
188
+ assert.ok(fs.existsSync(tarballPath), `no se encontro el tarball ${tarballPath}`);
189
+ return tarballPath;
190
+ }
191
+
192
+ function readJson(filePath) {
193
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
194
+ }
195
+
196
+ function initGitRepo(repo) {
197
+ git(["init"], repo);
198
+ git(["config", "user.email", "smoke@example.com"], repo);
199
+ git(["config", "user.name", "Smoke Runner"], repo);
200
+ }
201
+
202
+ function commitAll(repo, message) {
203
+ git(["add", "."], repo);
204
+ git(["commit", "-m", message], repo);
205
+ }
206
+
207
+ async function main() {
208
+ runNode([SKILL_VALIDATE], ROOT);
209
+
210
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "trackops-smoke-"));
211
+ const tarballPath = packCurrentPackage(tempRoot);
212
+ const packageVersion = readJson(path.join(ROOT, "package.json")).version;
213
+
214
+ const bootstrapHome = path.join(tempRoot, "bootstrap-home");
215
+ const bootstrapPrefix = path.join(tempRoot, "bootstrap-prefix");
216
+ fs.mkdirSync(bootstrapHome, { recursive: true });
217
+ fs.mkdirSync(bootstrapPrefix, { recursive: true });
218
+
219
+ const bootstrapEnv = {
220
+ TRACKOPS_BOOTSTRAP_HOME: bootstrapHome,
221
+ TRACKOPS_BOOTSTRAP_PREFIX: bootstrapPrefix,
222
+ };
223
+
224
+ const explicitInstall = runNpm(["install", "-g", "--prefix", bootstrapPrefix, tarballPath], tempRoot, bootstrapEnv);
225
+ assert.strictEqual(explicitInstall.status, 0, explicitInstall.stderr || explicitInstall.stdout || "instalacion global explicita fallo");
226
+
227
+ const runtimeStamp = readJson(path.join(bootstrapHome, ".trackops", "runtime.json"));
228
+ assert.ok(["es", "en"].includes(runtimeStamp.locale), "el bootstrap global debe fijar un idioma");
229
+
230
+ const installedCli = path.join(bootstrapPrefix, "node_modules", "trackops", "bin", "trackops.js");
231
+ assert.ok(fs.existsSync(installedCli), "el runtime instalado debe existir dentro del prefijo aislado");
232
+ const installedVersion = runNode([installedCli, "--version"], tempRoot);
233
+ assert.strictEqual(installedVersion.trim(), packageVersion);
234
+
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);
240
+
241
+ const versionOutput = runNode([BIN, "--version"], ROOT);
242
+ assert.strictEqual(versionOutput.trim(), packageVersion);
243
+
244
+ runNode([BIN, "locale", "set", "en"], tempRoot, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
245
+ const localeGet = runNode([BIN, "locale", "get"], tempRoot, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
246
+ assert.match(localeGet, /Effective language: en|Idioma efectivo: en/);
247
+ const localeDoctor = runNode([BIN, "doctor", "locale"], tempRoot, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
248
+ assert.match(localeDoctor, /Source: global|Origen: global/);
249
+
250
+ const splitProject = path.join(tempRoot, "split-demo");
251
+ fs.mkdirSync(splitProject, { recursive: true });
252
+ runNode([BIN, "init", "--locale", "es"], splitProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
253
+
254
+ assert.ok(fs.existsSync(path.join(splitProject, ".trackops-workspace.json")));
255
+ assert.ok(fs.existsSync(path.join(splitProject, ".env")));
256
+ assert.ok(fs.existsSync(path.join(splitProject, ".env.example")));
257
+ assert.ok(fs.existsSync(path.join(splitProject, "app")));
258
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "project_control.json")));
259
+ assert.ok(fs.existsSync(path.join(splitProject, "app", ".env")));
260
+ assert.ok(fs.existsSync(path.join(splitProject, "app", ".env.example")));
261
+ assert.ok(!fs.existsSync(path.join(splitProject, "project_control.json")));
262
+ assert.ok(!fs.existsSync(path.join(splitProject, "task_plan.md")));
263
+
264
+ const splitControl = readJson(path.join(splitProject, "ops", "project_control.json"));
265
+ assert.strictEqual(splitControl.meta.workspace.layout, "split");
266
+ assert.strictEqual(splitControl.meta.environment.rootEnvFile, ".env");
267
+ assert.strictEqual(splitControl.meta.locale, "es");
268
+
269
+ const projectLocaleDoctor = runNode([BIN, "doctor", "locale"], splitProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
270
+ assert.match(projectLocaleDoctor, /Project language: es|Idioma del proyecto: es/);
271
+ assert.match(projectLocaleDoctor, /Source: project|Origen: proyecto/);
272
+
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
+ const nextOutput = runNode([BIN, "next"], splitProject);
281
+ assert.match(nextOutput, /ops-bootstrap/);
282
+
283
+ runNode([BIN, "sync"], splitProject);
284
+ for (const file of ["task_plan.md", "progress.md", "findings.md"]) {
285
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", file)), `${file} no fue generado en ops/`);
286
+ }
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 = runNodeResult([BIN, "init"], nonEmptyProject);
296
+ assert.notStrictEqual(initNonEmpty.status, 0, "init debe abortar en directorios no vacios");
297
+ assert.match(`${initNonEmpty.stdout}\n${initNonEmpty.stderr}`, /workspace migrate/i);
298
+
299
+ writeJson(path.join(splitProject, "app", "package.json"), {
300
+ name: "split-demo",
301
+ version: "1.0.0",
302
+ dependencies: { openai: "^4.0.0" },
303
+ scripts: { test: "echo ok" },
304
+ });
305
+ runNode([BIN, "opera", "install", "--locale", "en", "--non-interactive"], splitProject);
306
+
307
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "genesis.md")));
308
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agent", "hub", "agent.md")));
309
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agents", "skills", "_registry.md")));
310
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "bootstrap", "agent-handoff.md")));
311
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "bootstrap", "agent-handoff.json")));
312
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "bootstrap", "open-questions.md")));
313
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "architecture", "runtime-operations.md")));
314
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "architecture", "dependency-graph.md")));
315
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "architecture", "runtime-automation.md")));
316
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "policy", "autonomy.json")));
317
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "reviews", "integration-audit.md")));
318
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "reviews", "delivery-audit.md")));
319
+ assert.ok(!fs.existsSync(path.join(splitProject, "genesis.md")));
320
+
321
+ const operaControl = readJson(path.join(splitProject, "ops", "project_control.json"));
322
+ assert.strictEqual(operaControl.meta.locale, "en");
323
+ assert.strictEqual(operaControl.meta.opera.installed, true);
324
+ assert.strictEqual(operaControl.meta.opera.bootstrap.mode, "agent_handoff");
325
+ assert.strictEqual(operaControl.meta.opera.bootstrap.status, "awaiting_agent");
326
+ assert.ok(operaControl.meta.opera.skills.includes("opera-skill"));
327
+ assert.ok(operaControl.meta.opera.skills.includes("project-starter-skill"));
328
+ assert.ok(operaControl.meta.opera.skills.includes("opera-contract-auditor"));
329
+ assert.ok(operaControl.meta.opera.skills.includes("opera-policy-guard"));
330
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agents", "skills", "opera-skill", "SKILL.md")));
331
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agents", "skills", "opera-skill", "references", "phase-dod.md")));
332
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", ".agents", "skills", "project-starter-skill", "references", "opera-cycle.md")));
333
+ assert.ok(operaControl.meta.environment.requiredKeys.includes("OPENAI_API_KEY"));
334
+ const envRootText = fs.readFileSync(path.join(splitProject, ".env"), "utf8");
335
+ assert.match(envRootText, /OPENAI_API_KEY=/);
336
+
337
+ const handoffPrint = runNode([BIN, "opera", "handoff", "--print"], splitProject);
338
+ assert.match(handoffPrint, /project-starter-skill/);
339
+ const handoffJson = JSON.parse(runNode([BIN, "opera", "handoff", "--json"], splitProject));
340
+ assert.strictEqual(handoffJson.skill, "project-starter-skill");
341
+
342
+ const directProject = path.join(tempRoot, "direct-demo");
343
+ fs.mkdirSync(directProject, { recursive: true });
344
+ runNode([BIN, "init", "--locale", "en"], directProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
345
+ writeJson(path.join(directProject, "app", "package.json"), { name: "direct-demo", version: "1.0.0" });
346
+ runNode([
347
+ BIN,
348
+ "opera",
349
+ "install",
350
+ "--locale",
351
+ "en",
352
+ "--non-interactive",
353
+ "--bootstrap-mode",
354
+ "direct",
355
+ "--technical-level",
356
+ "senior",
357
+ "--project-state",
358
+ "existing_repo",
359
+ "--docs-state",
360
+ "spec_dossier",
361
+ "--decision-ownership",
362
+ "user",
363
+ ], 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");
367
+
368
+ writeJson(path.join(splitProject, "ops", "bootstrap", "intake.json"), {
369
+ version: 1,
370
+ technicalLevel: "low",
371
+ projectState: "idea",
372
+ documentationState: "none",
373
+ decisionOwnership: "agent",
374
+ problemStatement: "users need a simple way to book and pay online",
375
+ targetUser: "small studio owners",
376
+ singularDesiredOutcome: "let users create and pay for bookings",
377
+ userLanguage: "en",
378
+ needsPlainLanguage: true,
379
+ recommendedStack: ["nextjs"],
380
+ externalServices: ["OpenAI", "Stripe"],
381
+ sourceOfTruth: "primary bookings database",
382
+ payload: "bookings dashboard",
383
+ behaviorRules: ["keep explanations simple"],
384
+ architecturalInvariants: ["keep app and ops separated"],
385
+ inputSchema: { booking: { email: "string" } },
386
+ outputSchema: { confirmation: { id: "string" } },
387
+ pipeline: ["create booking", "confirm payment"],
388
+ templates: ["booking-confirmation"],
389
+ });
390
+ fs.writeFileSync(
391
+ path.join(splitProject, "ops", "bootstrap", "spec-dossier.md"),
392
+ "# Spec dossier\n\n## Problem statement\nusers need a simple way to book and pay online\n\n## Target user\nsmall studio owners\n\n## Singular desired outcome\nlet users create and pay for bookings\n\n## Delivery target\nbookings dashboard\n\n## Source of truth\nprimary bookings database\n",
393
+ "utf8",
394
+ );
395
+ runNode([BIN, "opera", "bootstrap", "--resume"], splitProject);
396
+ const resumedControl = readJson(path.join(splitProject, "ops", "project_control.json"));
397
+ assert.strictEqual(resumedControl.meta.opera.bootstrap.status, "completed");
398
+ assert.strictEqual(resumedControl.meta.currentFocus, "let users create and pay for bookings");
399
+ assert.ok(resumedControl.meta.environment.requiredKeys.includes("STRIPE_SECRET_KEY"));
400
+ assert.ok(fs.existsSync(path.join(splitProject, "ops", "contract", "operating-contract.json")));
401
+ 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 defaultDashboard = startDashboard(splitProject);
407
+
408
+ try {
409
+ const ready = await waitForDashboard(defaultDashboard);
410
+ const state = await get(ready.port, "/api/state");
411
+ 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
+ assert.strictEqual(state.status, 200);
418
+ assert.strictEqual(envPayload.status, 200);
419
+ assert.strictEqual(operaBootstrapPayload.status, 200);
420
+ assert.strictEqual(operaHandoffPayload.status, 200);
421
+ assert.strictEqual(localSkills.status, 200);
422
+ assert.strictEqual(discoverSkills.status, 200);
423
+
424
+ const statePayload = JSON.parse(state.body);
425
+ assert.strictEqual(statePayload.project.layout, "split");
426
+ assert.strictEqual(statePayload.project.workspaceRoot, splitProject);
427
+ assert.strictEqual(statePayload.project.appRoot, path.join(splitProject, "app"));
428
+ assert.strictEqual(statePayload.project.opsRoot, path.join(splitProject, "ops"));
429
+ assert.ok(Array.isArray(statePayload.env.requiredKeys));
430
+ assert.ok(!Object.prototype.hasOwnProperty.call(statePayload.env, "values"));
431
+
432
+ const envState = JSON.parse(envPayload.body);
433
+ assert.ok(envState.requiredKeys.includes("OPENAI_API_KEY"));
434
+ assert.ok(envState.missingKeys.includes("OPENAI_API_KEY"));
435
+
436
+ const bootstrapState = JSON.parse(operaBootstrapPayload.body);
437
+ assert.strictEqual(bootstrapState.status, "completed");
438
+ assert.strictEqual(bootstrapState.contractVersion, 3);
439
+ 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
+ } finally {
445
+ await stopDashboard(defaultDashboard);
446
+ }
447
+
448
+ const blocked4173 = await isPortFree(4173) ? await occupyPort(4173) : null;
449
+ const fallbackDashboard = startDashboard(splitProject);
450
+ try {
451
+ const ready = await waitForDashboard(fallbackDashboard);
452
+ assert.notStrictEqual(ready.port, 4173, `deberia haber evitado 4173 ocupado:\n${ready.output}`);
453
+ assert.match(ready.output, /Local:/);
454
+ } finally {
455
+ await stopDashboard(fallbackDashboard);
456
+ if (blocked4173) blocked4173.close();
457
+ }
458
+
459
+ const strictPort = await findFreePort();
460
+ const occupiedStrictPort = await occupyPort(strictPort);
461
+ try {
462
+ const strictResult = runNodeResult([BIN, "dashboard", "--strict-port", "--port", String(strictPort)], splitProject);
463
+ assert.notStrictEqual(strictResult.status, 0, "el dashboard deberia fallar con --strict-port si el puerto esta ocupado");
464
+ assert.match(`${strictResult.stdout}\n${strictResult.stderr}`, /already in use|esta en uso/i);
465
+ } finally {
466
+ occupiedStrictPort.close();
467
+ }
468
+
469
+ const publicPort = await findFreePort("0.0.0.0");
470
+ const publicDashboard = startDashboard(splitProject, ["--public", "--port", String(publicPort)]);
471
+ try {
472
+ const ready = await waitForDashboard(publicDashboard);
473
+ if (hasExternalIpv4()) {
474
+ assert.match(ready.output, /- Network:/);
475
+ }
476
+ } finally {
477
+ await stopDashboard(publicDashboard);
478
+ }
479
+
480
+ const noClipboardPort = await findFreePort();
481
+ const noClipboardDashboard = startDashboard(splitProject, ["--port", String(noClipboardPort)], {
482
+ PATH: "",
483
+ Path: "",
484
+ });
485
+ try {
486
+ const ready = await waitForDashboard(noClipboardDashboard);
487
+ const state = await get(ready.port, "/api/state");
488
+ assert.strictEqual(state.status, 200);
489
+ } finally {
490
+ await stopDashboard(noClipboardDashboard);
491
+ }
492
+
493
+ const legacyProject = path.join(tempRoot, "legacy-demo");
494
+ fs.mkdirSync(legacyProject, { recursive: true });
495
+ initGitRepo(legacyProject);
496
+ writeJson(path.join(legacyProject, "package.json"), { name: "legacy-demo", version: "1.0.0" });
497
+ runNode([BIN, "init", "--legacy-layout"], legacyProject);
498
+ runNode([BIN, "opera", "install", "--locale", "en", "--no-bootstrap"], legacyProject);
499
+ fs.writeFileSync(path.join(legacyProject, ".env"), "OPENAI_API_KEY=\n", "utf8");
500
+ fs.writeFileSync(path.join(legacyProject, ".env.example"), "OPENAI_API_KEY=\n", "utf8");
501
+ commitAll(legacyProject, "legacy fixture");
502
+
503
+ runNode([BIN, "workspace", "migrate"], legacyProject);
504
+ assert.ok(fs.existsSync(path.join(legacyProject, ".trackops-workspace.json")));
505
+ assert.ok(fs.existsSync(path.join(legacyProject, "app", "package.json")));
506
+ assert.ok(fs.existsSync(path.join(legacyProject, "ops", "project_control.json")));
507
+ assert.ok(fs.existsSync(path.join(legacyProject, ".env")));
508
+ assert.ok(fs.existsSync(path.join(legacyProject, "app", ".env")));
509
+ assert.ok(!fs.existsSync(path.join(legacyProject, "project_control.json")));
510
+ const migratedPackage = readJson(path.join(legacyProject, "app", "package.json"));
511
+ assert.ok(!migratedPackage.scripts || !migratedPackage.scripts["ops:status"]);
512
+ assert.match(git(["branch", "--list"], legacyProject), /backup\/trackops-workspace-/);
513
+
514
+ const legacyUnsupportedProject = path.join(tempRoot, "legacy-unsupported");
515
+ fs.mkdirSync(legacyUnsupportedProject, { recursive: true });
516
+ initGitRepo(legacyUnsupportedProject);
517
+ runNode([BIN, "init", "--locale", "en"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
518
+ const legacyUnsupportedControlPath = path.join(legacyUnsupportedProject, "ops", "project_control.json");
519
+ const legacyUnsupportedControl = readJson(legacyUnsupportedControlPath);
520
+ legacyUnsupportedControl.meta.opera = {
521
+ installed: true,
522
+ version: "0.9.0",
523
+ };
524
+ writeJson(legacyUnsupportedControlPath, legacyUnsupportedControl);
525
+ const legacyStatusOutput = runNode([BIN, "opera", "status"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
526
+ assert.match(legacyStatusOutput, /legacy_unsupported/);
527
+ const upgradeWithoutReset = runNodeResult([BIN, "opera", "upgrade", "--stable"], legacyUnsupportedProject, { TRACKOPS_BOOTSTRAP_HOME: bootstrapHome });
528
+ assert.strictEqual(upgradeWithoutReset.status, 0);
529
+ 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 releaseProject = path.join(tempRoot, "release-demo");
537
+ fs.mkdirSync(releaseProject, { recursive: true });
538
+ initGitRepo(releaseProject);
539
+ runNode([BIN, "init"], releaseProject);
540
+ writeJson(path.join(releaseProject, "app", "package.json"), { name: "release-demo", version: "1.0.0" });
541
+ fs.writeFileSync(path.join(releaseProject, "app", "index.js"), "console.log('release');\n", "utf8");
542
+ commitAll(releaseProject, "split fixture");
543
+ git(["checkout", "-b", "develop"], releaseProject);
544
+ runNode([BIN, "release"], releaseProject);
545
+ const publishFiles = git(["ls-tree", "--name-only", "master"], releaseProject).split(/\r?\n/).filter(Boolean);
546
+ assert.ok(publishFiles.includes("package.json"));
547
+ assert.ok(publishFiles.includes(".env.example"));
548
+ assert.ok(!publishFiles.includes("ops"));
549
+ assert.ok(!publishFiles.includes(".trackops-workspace.json"));
550
+
551
+ fs.rmSync(tempRoot, { recursive: true, force: true });
552
+ console.log("Smoke tests OK");
553
+ }
554
+
555
+ main().catch((error) => {
556
+ console.error(error.message);
557
+ process.exit(1);
558
+ });