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