trackops 2.0.5 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -61
- package/bin/trackops.js +28 -7
- package/lib/cli-format.js +118 -0
- package/lib/config.js +29 -3
- package/lib/control.js +278 -116
- package/lib/env.js +40 -28
- package/lib/i18n.js +5 -4
- package/lib/init.js +149 -41
- package/lib/opera-bootstrap.js +251 -71
- package/lib/opera.js +235 -73
- package/lib/skills.js +43 -35
- package/lib/workspace.js +32 -21
- package/locales/en.json +183 -61
- package/locales/es.json +184 -62
- package/package.json +1 -1
- package/scripts/smoke-tests.js +81 -39
- package/skills/trackops/skill.json +2 -2
package/lib/init.js
CHANGED
|
@@ -12,15 +12,36 @@ const { t, setLocale } = require("./i18n");
|
|
|
12
12
|
const { detectSystemLocale, promptForLocale, maybePromptForLocale, resolveLocale } = require("./locale");
|
|
13
13
|
const runtimeState = require("./runtime-state");
|
|
14
14
|
|
|
15
|
-
const GENERATED_SCRIPT_COMMANDS = {
|
|
15
|
+
const GENERATED_SCRIPT_COMMANDS = {
|
|
16
16
|
ops: "npx --yes trackops",
|
|
17
17
|
"ops:help": "npx --yes trackops help",
|
|
18
18
|
"ops:dashboard": "npx --yes trackops dashboard",
|
|
19
19
|
"ops:status": "npx --yes trackops status",
|
|
20
20
|
"ops:next": "npx --yes trackops next",
|
|
21
21
|
"ops:sync": "npx --yes trackops sync",
|
|
22
|
-
"ops:repo": "npx --yes trackops refresh-repo",
|
|
23
|
-
};
|
|
22
|
+
"ops:repo": "npx --yes trackops refresh-repo",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const ROOT_PRESERVED_ENTRIES = new Set([
|
|
26
|
+
".git",
|
|
27
|
+
".gitignore",
|
|
28
|
+
".gitattributes",
|
|
29
|
+
".gitmodules",
|
|
30
|
+
".editorconfig",
|
|
31
|
+
".env",
|
|
32
|
+
".env.example",
|
|
33
|
+
".vscode",
|
|
34
|
+
".idea",
|
|
35
|
+
".nvmrc",
|
|
36
|
+
".node-version",
|
|
37
|
+
".tool-versions",
|
|
38
|
+
".npmrc",
|
|
39
|
+
".yarnrc.yml",
|
|
40
|
+
".pnp.cjs",
|
|
41
|
+
".pnp.loader.mjs",
|
|
42
|
+
"pnpm-workspace.yaml",
|
|
43
|
+
"turbo.json",
|
|
44
|
+
]);
|
|
24
45
|
|
|
25
46
|
function nowIso() {
|
|
26
47
|
return new Date().toISOString();
|
|
@@ -176,41 +197,117 @@ function installHooks(context) {
|
|
|
176
197
|
|
|
177
198
|
if (fs.existsSync(path.join(context.workspaceRoot, ".git"))) {
|
|
178
199
|
const hooksPath = context.layout === "split" ? "ops/.githooks" : ".githooks";
|
|
179
|
-
spawnSync("git", ["config", "core.hooksPath", hooksPath], { cwd: context.workspaceRoot, encoding: "utf8" });
|
|
200
|
+
const result = spawnSync("git", ["config", "core.hooksPath", hooksPath], { cwd: context.workspaceRoot, encoding: "utf8" });
|
|
201
|
+
if (result.status !== 0) {
|
|
202
|
+
console.log(t("cli.hooksError"));
|
|
203
|
+
}
|
|
180
204
|
}
|
|
181
205
|
}
|
|
182
206
|
|
|
183
|
-
function ensureTmpDir(context) {
|
|
207
|
+
function ensureTmpDir(context) {
|
|
184
208
|
fs.mkdirSync(context.paths.tmpDir, { recursive: true });
|
|
185
209
|
const gitkeep = path.join(context.paths.tmpDir, ".gitkeep");
|
|
186
210
|
if (!fs.existsSync(gitkeep)) {
|
|
187
211
|
fs.writeFileSync(gitkeep, "", "utf8");
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function isRetryableMoveError(error) {
|
|
216
|
+
return ["EPERM", "EXDEV", "EBUSY", "ENOTEMPTY"].includes(error?.code);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function moveEntry(fromPath, toPath) {
|
|
220
|
+
if (!fs.existsSync(fromPath)) return;
|
|
221
|
+
fs.mkdirSync(path.dirname(toPath), { recursive: true });
|
|
222
|
+
try {
|
|
223
|
+
fs.renameSync(fromPath, toPath);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!isRetryableMoveError(error)) throw error;
|
|
226
|
+
const stat = fs.statSync(fromPath);
|
|
227
|
+
if (stat.isDirectory()) {
|
|
228
|
+
fs.cpSync(fromPath, toPath, { recursive: true, force: true });
|
|
229
|
+
fs.rmSync(fromPath, { recursive: true, force: true });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
fs.copyFileSync(fromPath, toPath);
|
|
233
|
+
fs.rmSync(fromPath, { force: true });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function collectRootEntries(targetRoot) {
|
|
238
|
+
return fs.readdirSync(targetRoot, { withFileTypes: true })
|
|
239
|
+
.filter((entry) => ![".", ".."].includes(entry.name));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function analyzeSplitInit(targetRoot) {
|
|
243
|
+
const entries = collectRootEntries(targetRoot);
|
|
244
|
+
const manifestPath = path.join(targetRoot, config.WORKSPACE_MANIFEST);
|
|
245
|
+
const legacyControlPath = path.join(targetRoot, "project_control.json");
|
|
246
|
+
const hasManifest = fs.existsSync(manifestPath);
|
|
247
|
+
|
|
248
|
+
if (hasManifest) {
|
|
249
|
+
return { mode: "upgrade", entries };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (fs.existsSync(legacyControlPath)) {
|
|
253
|
+
throw new Error(t("init.error.legacyDetected"));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const conflicts = entries
|
|
257
|
+
.map((entry) => entry.name)
|
|
258
|
+
.filter((name) => [config.DEFAULT_APP_DIR, config.DEFAULT_OPS_DIR, config.WORKSPACE_MANIFEST].includes(name));
|
|
259
|
+
|
|
260
|
+
if (conflicts.length) {
|
|
261
|
+
throw new Error(t("init.error.reservedConflict", { entries: conflicts.join(", ") }));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const movableEntries = entries.filter((entry) => !ROOT_PRESERVED_ENTRIES.has(entry.name));
|
|
265
|
+
return {
|
|
266
|
+
mode: movableEntries.length ? "adopt" : "new",
|
|
267
|
+
entries,
|
|
268
|
+
movableEntries,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function adoptExistingProject(targetRoot, appRoot, movableEntries) {
|
|
273
|
+
for (const entry of movableEntries) {
|
|
274
|
+
moveEntry(path.join(targetRoot, entry.name), path.join(appRoot, entry.name));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function initSplitProject(root, options) {
|
|
279
|
+
const targetRoot = path.resolve(root);
|
|
280
|
+
fs.mkdirSync(targetRoot, { recursive: true });
|
|
281
|
+
const projectName = options.name || detectProjectName(targetRoot);
|
|
282
|
+
const initMode = analyzeSplitInit(targetRoot);
|
|
283
|
+
const manifest = workspace.buildManifest();
|
|
284
|
+
const context = config.createSplitContext(targetRoot, manifest);
|
|
285
|
+
|
|
286
|
+
if (initMode.mode === "adopt") {
|
|
287
|
+
fs.mkdirSync(context.appRoot, { recursive: true });
|
|
288
|
+
adoptExistingProject(targetRoot, context.appRoot, initMode.movableEntries);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
fs.mkdirSync(context.appRoot, { recursive: true });
|
|
292
|
+
fs.mkdirSync(context.opsRoot, { recursive: true });
|
|
293
|
+
config.saveWorkspaceManifest(context, manifest);
|
|
294
|
+
workspace.ensureRootGitignore(targetRoot);
|
|
295
|
+
|
|
296
|
+
const controlFile = context.controlFile || path.join(context.opsRoot, "project_control.json");
|
|
297
|
+
const isUpgrade = initMode.mode === "upgrade" && fs.existsSync(controlFile);
|
|
298
|
+
let control;
|
|
299
|
+
if (isUpgrade) {
|
|
300
|
+
control = JSON.parse(fs.readFileSync(controlFile, "utf8"));
|
|
301
|
+
if (!control.meta.phases) control.meta.phases = options.phases || config.buildDefaultPhases(options.locale);
|
|
302
|
+
if (!control.meta.locale) control.meta.locale = options.locale || config.DEFAULT_LOCALE;
|
|
303
|
+
if (!control.meta.controlVersion || control.meta.controlVersion < 2) control.meta.controlVersion = 2;
|
|
304
|
+
control.meta.updatedAt = nowIso();
|
|
305
|
+
config.saveControl(context, control);
|
|
306
|
+
} else {
|
|
307
|
+
control = buildDefaultControl(context, options);
|
|
308
|
+
control.meta.projectName = projectName;
|
|
309
|
+
config.saveControl(context, control);
|
|
310
|
+
}
|
|
214
311
|
|
|
215
312
|
installHooks(context);
|
|
216
313
|
ensureTmpDir(context);
|
|
@@ -224,16 +321,27 @@ function initSplitProject(root, options) {
|
|
|
224
321
|
// ignore
|
|
225
322
|
}
|
|
226
323
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
324
|
+
if (isUpgrade) {
|
|
325
|
+
console.log(t("init.updated", { file: ".trackops-workspace.json" }));
|
|
326
|
+
console.log(t("init.updated", { file: "ops/project_control.json" }));
|
|
327
|
+
console.log(t("init.updated", { file: "ops/.githooks/" }));
|
|
328
|
+
console.log(t("init.updated", { file: ".env" }));
|
|
329
|
+
console.log(t("init.updated", { file: ".env.example" }));
|
|
330
|
+
} else {
|
|
331
|
+
console.log(t("init.created", { file: ".trackops-workspace.json" }));
|
|
332
|
+
console.log(t("init.created", { file: "ops/project_control.json" }));
|
|
333
|
+
console.log(t("init.created", { file: "ops/.githooks/" }));
|
|
334
|
+
console.log(t("init.created", { file: ".env" }));
|
|
335
|
+
console.log(t("init.created", { file: ".env.example" }));
|
|
336
|
+
if (initMode.mode === "adopt") {
|
|
337
|
+
console.log(t("init.adoptedExistingRepo", { dir: path.basename(context.appRoot) }));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
console.log("");
|
|
341
|
+
console.log(t("init.welcome"));
|
|
342
|
+
|
|
343
|
+
return { root: context.workspaceRoot, context, isUpgrade, operaDetected: false };
|
|
344
|
+
}
|
|
237
345
|
|
|
238
346
|
function initLegacyProject(root, options) {
|
|
239
347
|
const targetRoot = path.resolve(root);
|
package/lib/opera-bootstrap.js
CHANGED
|
@@ -9,6 +9,7 @@ const config = require("./config");
|
|
|
9
9
|
const { t, setLocale } = require("./i18n");
|
|
10
10
|
const { isInteractive } = require("./locale");
|
|
11
11
|
const { resolveLocalizedFile } = require("./resources");
|
|
12
|
+
const fmt = require("./cli-format");
|
|
12
13
|
|
|
13
14
|
const TEMPLATES_DIR = path.join(__dirname, "..", "templates", "opera");
|
|
14
15
|
|
|
@@ -372,6 +373,7 @@ function directMissingFields(discovery) {
|
|
|
372
373
|
if (!discovery.payload) missing.push("payload");
|
|
373
374
|
if (!Object.keys(discovery.inputSchema || {}).length) missing.push("inputSchema");
|
|
374
375
|
if (!Object.keys(discovery.outputSchema || {}).length) missing.push("outputSchema");
|
|
376
|
+
if (!discovery.decisionOwnership) missing.push("decisionOwnership");
|
|
375
377
|
return missing;
|
|
376
378
|
}
|
|
377
379
|
|
|
@@ -426,9 +428,33 @@ function buildHandoffPrompt(control, profile) {
|
|
|
426
428
|
`- ${t("handoff.instruction.writeSpec")}`,
|
|
427
429
|
`- ${t("handoff.instruction.writeQuestions")}`,
|
|
428
430
|
`- ${t("handoff.instruction.includeFields")}`,
|
|
431
|
+
`- ${t("handoff.instruction.schemaFormat")}`,
|
|
432
|
+
`- ${t("handoff.instruction.decisionOwnership")}`,
|
|
429
433
|
`- ${t("handoff.instruction.respondInLanguage", { language: languageName })}`,
|
|
434
|
+
`- **${t("handoff.instruction.neverRename")}**`,
|
|
435
|
+
`- **${t("handoff.instruction.closure")}**`,
|
|
430
436
|
];
|
|
431
437
|
|
|
438
|
+
lines.push(
|
|
439
|
+
"",
|
|
440
|
+
`## ${t("handoff.section.intakeSchema")}`,
|
|
441
|
+
"",
|
|
442
|
+
t("handoff.instruction.intakeRequired"),
|
|
443
|
+
"",
|
|
444
|
+
"```json",
|
|
445
|
+
JSON.stringify({
|
|
446
|
+
problemStatement: "string — core problem the project solves",
|
|
447
|
+
targetUser: "string — primary user persona",
|
|
448
|
+
singularDesiredOutcome: "string — single most important outcome",
|
|
449
|
+
sourceOfTruth: "string — authoritative data source",
|
|
450
|
+
payload: "string — what gets delivered (NOT 'deliveryTarget')",
|
|
451
|
+
decisionOwnership: "user | shared | agent",
|
|
452
|
+
inputSchema: { "fieldName": "type — at least one key required" },
|
|
453
|
+
outputSchema: { "fieldName": "type — at least one key required" },
|
|
454
|
+
}, null, 2),
|
|
455
|
+
"```",
|
|
456
|
+
);
|
|
457
|
+
|
|
432
458
|
if (profile.discovery?.singularDesiredOutcome) {
|
|
433
459
|
lines.push("", `${t("handoff.label.knownIntention")}: ${profile.discovery.singularDesiredOutcome}`);
|
|
434
460
|
}
|
|
@@ -493,10 +519,12 @@ async function collectBootstrapProfile(root, control, options = {}) {
|
|
|
493
519
|
const answers = { ...defaults };
|
|
494
520
|
|
|
495
521
|
if (interactive) {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
522
|
+
fmt.header(t("bootstrap.header"));
|
|
523
|
+
fmt.blank();
|
|
524
|
+
fmt.info(t("bootstrap.subtitle"));
|
|
525
|
+
fmt.blank();
|
|
526
|
+
fmt.info(t("bootstrap.instructions"));
|
|
527
|
+
fmt.blank();
|
|
500
528
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
501
529
|
try {
|
|
502
530
|
answers.technicalLevel = await askEnumQuestion(rl, t("bootstrap.question.technicalLevel"), defaults.technicalLevel || "medium", TECHNICAL_LEVELS) || "medium";
|
|
@@ -593,25 +621,115 @@ function parseSpecSections(specText) {
|
|
|
593
621
|
);
|
|
594
622
|
}
|
|
595
623
|
|
|
596
|
-
function buildOpenQuestions(missingFields, contradictions) {
|
|
597
|
-
const lines = [
|
|
598
|
-
if (missingFields.length) {
|
|
599
|
-
lines.push(
|
|
600
|
-
missingFields.forEach((field) => lines.push(`- ${field}`));
|
|
601
|
-
lines.push("");
|
|
602
|
-
}
|
|
603
|
-
if (contradictions.length) {
|
|
604
|
-
lines.push(
|
|
605
|
-
contradictions.forEach((item) => lines.push(`- ${item}`));
|
|
606
|
-
lines.push("");
|
|
607
|
-
}
|
|
608
|
-
if (!missingFields.length && !contradictions.length) {
|
|
609
|
-
lines.push("
|
|
610
|
-
}
|
|
611
|
-
return `${lines.join("\n")}\n`;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function
|
|
624
|
+
function buildOpenQuestions(missingFields, contradictions) {
|
|
625
|
+
const lines = [`# ${t("bootstrap.scaffold.openQuestionsTitle")}`, ""];
|
|
626
|
+
if (missingFields.length) {
|
|
627
|
+
lines.push(`## ${t("bootstrap.scaffold.missingFields")}`, "");
|
|
628
|
+
missingFields.forEach((field) => lines.push(`- ${field}`));
|
|
629
|
+
lines.push("");
|
|
630
|
+
}
|
|
631
|
+
if (contradictions.length) {
|
|
632
|
+
lines.push(`## ${t("bootstrap.scaffold.contradictions")}`, "");
|
|
633
|
+
contradictions.forEach((item) => lines.push(`- ${item}`));
|
|
634
|
+
lines.push("");
|
|
635
|
+
}
|
|
636
|
+
if (!missingFields.length && !contradictions.length) {
|
|
637
|
+
lines.push(`- ${t("bootstrap.scaffold.none")}`);
|
|
638
|
+
}
|
|
639
|
+
return `${lines.join("\n")}\n`;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function buildDirectIntakeTemplate(profile) {
|
|
643
|
+
return {
|
|
644
|
+
version: 1,
|
|
645
|
+
technicalLevel: profile.technicalLevel || null,
|
|
646
|
+
projectState: profile.projectState || null,
|
|
647
|
+
documentationState: profile.documentationState || null,
|
|
648
|
+
decisionOwnership: profile.discovery?.decisionOwnership || profile.decisionOwnership || null,
|
|
649
|
+
problemStatement: profile.discovery?.problemStatement || "",
|
|
650
|
+
targetUser: profile.discovery?.targetUser || "",
|
|
651
|
+
singularDesiredOutcome: profile.discovery?.singularDesiredOutcome || "",
|
|
652
|
+
userLanguage: profile.discovery?.userLanguage || null,
|
|
653
|
+
needsPlainLanguage: Boolean(profile.discovery?.needsPlainLanguage),
|
|
654
|
+
recommendedStack: normalizeList(profile.discovery?.recommendedStack || []),
|
|
655
|
+
externalServices: normalizeList(profile.discovery?.externalServices || []),
|
|
656
|
+
sourceOfTruth: profile.discovery?.sourceOfTruth || "",
|
|
657
|
+
payload: profile.discovery?.payload || "",
|
|
658
|
+
behaviorRules: normalizeList(profile.discovery?.behaviorRules || []),
|
|
659
|
+
inputSchema: profile.discovery?.inputSchema || {},
|
|
660
|
+
outputSchema: profile.discovery?.outputSchema || {},
|
|
661
|
+
architecturalInvariants: normalizeList(profile.discovery?.architecturalInvariants || []),
|
|
662
|
+
pipeline: normalizeList(profile.discovery?.pipeline || []),
|
|
663
|
+
templates: normalizeList(profile.discovery?.templates || []),
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function buildSpecDossierScaffold(profile) {
|
|
668
|
+
return [
|
|
669
|
+
`# ${t("bootstrap.scaffold.specTitle")}`,
|
|
670
|
+
"",
|
|
671
|
+
`## ${t("bootstrap.scaffold.problemStatement")}`,
|
|
672
|
+
profile.discovery?.problemStatement || "",
|
|
673
|
+
"",
|
|
674
|
+
`## ${t("bootstrap.scaffold.targetUser")}`,
|
|
675
|
+
profile.discovery?.targetUser || "",
|
|
676
|
+
"",
|
|
677
|
+
`## ${t("bootstrap.scaffold.singularDesiredOutcome")}`,
|
|
678
|
+
profile.discovery?.singularDesiredOutcome || "",
|
|
679
|
+
"",
|
|
680
|
+
`## ${t("bootstrap.scaffold.deliveryTarget")}`,
|
|
681
|
+
profile.discovery?.payload || "",
|
|
682
|
+
"",
|
|
683
|
+
`## ${t("bootstrap.scaffold.sourceOfTruth")}`,
|
|
684
|
+
profile.discovery?.sourceOfTruth || "",
|
|
685
|
+
"",
|
|
686
|
+
].join("\n");
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function ensureDirectBootstrapArtifacts(context, profile) {
|
|
690
|
+
const files = bootstrapFilePaths(context);
|
|
691
|
+
const existingIntake = readJson(files.intakeJson);
|
|
692
|
+
const intake = existingIntake && typeof existingIntake === "object"
|
|
693
|
+
? existingIntake
|
|
694
|
+
: buildDirectIntakeTemplate(profile);
|
|
695
|
+
if (!existingIntake) {
|
|
696
|
+
writeJson(files.intakeJson, intake);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
let specText = readText(files.specDossier);
|
|
700
|
+
if (!specText.trim()) {
|
|
701
|
+
specText = buildSpecDossierScaffold({ ...profile, discovery: { ...profile.discovery, ...intake } });
|
|
702
|
+
writeText(files.specDossier, specText);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const mergedProfile = {
|
|
706
|
+
...profile,
|
|
707
|
+
discovery: {
|
|
708
|
+
...(profile.discovery || {}),
|
|
709
|
+
...intake,
|
|
710
|
+
externalServices: normalizeList(intake.externalServices || profile.discovery?.externalServices || []),
|
|
711
|
+
behaviorRules: normalizeList(intake.behaviorRules || profile.discovery?.behaviorRules || []),
|
|
712
|
+
architecturalInvariants: normalizeList(intake.architecturalInvariants || profile.discovery?.architecturalInvariants || []),
|
|
713
|
+
pipeline: normalizeList(intake.pipeline || profile.discovery?.pipeline || []),
|
|
714
|
+
templates: normalizeList(intake.templates || profile.discovery?.templates || []),
|
|
715
|
+
inputSchema: intake.inputSchema || profile.discovery?.inputSchema || {},
|
|
716
|
+
outputSchema: intake.outputSchema || profile.discovery?.outputSchema || {},
|
|
717
|
+
decisionOwnership: intake.decisionOwnership || profile.discovery?.decisionOwnership || profile.decisionOwnership || null,
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
const qualityReport = buildQualityReport(context, mergedProfile, specText);
|
|
721
|
+
writeText(files.openQuestions, buildOpenQuestions(qualityReport.missingFields, qualityReport.contradictions));
|
|
722
|
+
writeJson(files.qualityReport, qualityReport);
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
intake,
|
|
726
|
+
specText,
|
|
727
|
+
qualityReport,
|
|
728
|
+
profile: mergedProfile,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function qualityStatusFor(missingFields, contradictions) {
|
|
615
733
|
if (missingFields.length >= 2) return "blocked";
|
|
616
734
|
if (missingFields.length || contradictions.length) return "needs_review";
|
|
617
735
|
return "ready";
|
|
@@ -630,23 +748,21 @@ function contractReadinessFor(profile, qualityStatus) {
|
|
|
630
748
|
return "verified";
|
|
631
749
|
}
|
|
632
750
|
|
|
633
|
-
function buildQualityReport(context, profile, specText) {
|
|
634
|
-
const sections = parseSpecSections(specText);
|
|
635
|
-
const missingFields = directMissingFields(profile.discovery);
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
["
|
|
643
|
-
["
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
[
|
|
647
|
-
|
|
648
|
-
for (const [sectionName, fieldName, expected] of mappings) {
|
|
649
|
-
const actual = sections[sectionName];
|
|
751
|
+
function buildQualityReport(context, profile, specText) {
|
|
752
|
+
const sections = parseSpecSections(specText);
|
|
753
|
+
const missingFields = directMissingFields(profile.discovery);
|
|
754
|
+
|
|
755
|
+
const contradictions = [];
|
|
756
|
+
const mappings = [
|
|
757
|
+
["problem statement", "problemStatement", profile.discovery.problemStatement, "problema principal"],
|
|
758
|
+
["target user", "targetUser", profile.discovery.targetUser, "usuario objetivo", "usuario objetivo principal"],
|
|
759
|
+
["singular desired outcome", "singularDesiredOutcome", profile.discovery.singularDesiredOutcome, "resultado singular deseado", "resultado deseado"],
|
|
760
|
+
["payload", "payload", profile.discovery.payload, "delivery target", "objetivo de entrega"],
|
|
761
|
+
["source of truth", "sourceOfTruth", profile.discovery.sourceOfTruth, "fuente de la verdad"],
|
|
762
|
+
];
|
|
763
|
+
for (const mapping of mappings) {
|
|
764
|
+
const [sectionName, fieldName, expected, ...aliases] = mapping;
|
|
765
|
+
const actual = sections[sectionName] || aliases.reduce((found, alias) => found || sections[alias], null);
|
|
650
766
|
if (!actual) {
|
|
651
767
|
missingFields.push(fieldName);
|
|
652
768
|
continue;
|
|
@@ -795,7 +911,10 @@ function renderGenesis(control, contract) {
|
|
|
795
911
|
}
|
|
796
912
|
|
|
797
913
|
function buildSeedTasks(control, profile) {
|
|
798
|
-
|
|
914
|
+
let phaseOrder = config.getPhases(control);
|
|
915
|
+
if (!phaseOrder || !phaseOrder.length) {
|
|
916
|
+
phaseOrder = config.buildDefaultPhases ? config.buildDefaultPhases(config.getLocale(control)) : config.DEFAULT_PHASES;
|
|
917
|
+
}
|
|
799
918
|
const firstTaskStatus =
|
|
800
919
|
profile.status === "completed"
|
|
801
920
|
? "completed"
|
|
@@ -986,8 +1105,21 @@ function applyBootstrap(root, control, profile) {
|
|
|
986
1105
|
control.meta.phases = config.getPhases(control);
|
|
987
1106
|
control.meta.opera.legacyStatus = "supported";
|
|
988
1107
|
|
|
1108
|
+
const existingBootstrap = new Map(
|
|
1109
|
+
(control.tasks || []).filter((task) => task.origin === "bootstrap").map((task) => [task.id, task])
|
|
1110
|
+
);
|
|
989
1111
|
const remainingTasks = (control.tasks || []).filter((task) => task.id !== "ops-bootstrap" && task.origin !== "bootstrap");
|
|
990
|
-
|
|
1112
|
+
const newSeeds = buildSeedTasks(control, profile).map((seed) => {
|
|
1113
|
+
const existing = existingBootstrap.get(seed.id);
|
|
1114
|
+
if (!existing) return seed;
|
|
1115
|
+
return {
|
|
1116
|
+
...seed,
|
|
1117
|
+
status: existing.status === "completed" ? "completed" : seed.status,
|
|
1118
|
+
history: existing.history || seed.history,
|
|
1119
|
+
blocker: existing.status === "blocked" ? existing.blocker : seed.blocker,
|
|
1120
|
+
};
|
|
1121
|
+
});
|
|
1122
|
+
control.tasks = [...remainingTasks, ...newSeeds];
|
|
991
1123
|
|
|
992
1124
|
control.decisionsPending = (profile.missingFields || [])
|
|
993
1125
|
.filter((field) => !["intakeJson", "specDossier"].includes(field))
|
|
@@ -1005,37 +1137,54 @@ function applyBootstrap(root, control, profile) {
|
|
|
1005
1137
|
}
|
|
1006
1138
|
|
|
1007
1139
|
control.findings = control.findings || [];
|
|
1008
|
-
const files = bootstrapFilePaths(context);
|
|
1009
|
-
if (profile.mode === "agent_handoff") {
|
|
1010
|
-
writeJson(files.json, buildHandoffPayload(control, profile, context));
|
|
1011
|
-
writeText(files.markdown, buildHandoffPrompt(control, profile));
|
|
1012
|
-
if (!fs.existsSync(files.specDossier)) {
|
|
1013
|
-
writeText(
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1140
|
+
const files = bootstrapFilePaths(context);
|
|
1141
|
+
if (profile.mode === "agent_handoff") {
|
|
1142
|
+
writeJson(files.json, buildHandoffPayload(control, profile, context));
|
|
1143
|
+
writeText(files.markdown, buildHandoffPrompt(control, profile));
|
|
1144
|
+
if (!fs.existsSync(files.specDossier)) {
|
|
1145
|
+
writeText(
|
|
1146
|
+
files.specDossier,
|
|
1147
|
+
`# ${t("bootstrap.scaffold.specTitle")}\n\n${t("bootstrap.scaffold.specPlaceholder")}\n`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
writeText(files.openQuestions, buildOpenQuestions(["intakeJson", "specDossier"], []));
|
|
1151
|
+
}
|
|
1152
|
+
let directArtifacts = null;
|
|
1153
|
+
if (profile.mode === "direct_cli") {
|
|
1154
|
+
directArtifacts = ensureDirectBootstrapArtifacts(context, profile);
|
|
1155
|
+
profile = directArtifacts.profile;
|
|
1156
|
+
control.meta.opera.bootstrap = {
|
|
1157
|
+
...control.meta.opera.bootstrap,
|
|
1158
|
+
...profile,
|
|
1159
|
+
handoffFiles: null,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
const genesisPath = context.paths.genesisFile;
|
|
1163
|
+
if (profile.status === "completed") {
|
|
1164
|
+
const specText = directArtifacts?.specText || (
|
|
1165
|
+
fs.existsSync(files.specDossier) && readText(files.specDossier).trim()
|
|
1166
|
+
? readText(files.specDossier)
|
|
1167
|
+
: `## Problem statement\n${profile.discovery.problemStatement || ""}\n\n## Target user\n${profile.discovery.targetUser || ""}\n\n## Singular desired outcome\n${profile.discovery.singularDesiredOutcome || ""}\n\n## Delivery target\n${profile.discovery.payload || ""}\n\n## Source of truth\n${profile.discovery.sourceOfTruth || ""}\n`
|
|
1168
|
+
);
|
|
1169
|
+
const qualityReport = directArtifacts?.qualityReport || profile.qualityReport || buildQualityReport(context, profile, specText);
|
|
1170
|
+
const contract = buildOperatingContract(control, profile, qualityReport, context);
|
|
1171
|
+
writeJson(context.paths.contractFile, contract);
|
|
1172
|
+
fs.writeFileSync(genesisPath, `${renderGenesis(control, contract)}\n`, "utf8");
|
|
1173
|
+
control.meta.opera.contractVersion = CONTRACT_VERSION;
|
|
1027
1174
|
control.meta.opera.contractReadiness = qualityReport.contractReadiness;
|
|
1028
1175
|
control.meta.opera.contractFile = contractRelativePath(context);
|
|
1029
1176
|
control.meta.opera.qualityStatus = qualityReport.status;
|
|
1030
|
-
} else if (["needs_review", "blocked"].includes(profile.status)) {
|
|
1031
|
-
|
|
1032
|
-
control.meta.opera.
|
|
1033
|
-
control.meta.opera.
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
control.meta.opera.
|
|
1038
|
-
|
|
1177
|
+
} else if (["needs_review", "blocked"].includes(profile.status)) {
|
|
1178
|
+
const qualityReport = directArtifacts?.qualityReport || profile.qualityReport || null;
|
|
1179
|
+
control.meta.opera.contractVersion = null;
|
|
1180
|
+
control.meta.opera.contractReadiness = qualityReport?.contractReadiness || "hypothesis";
|
|
1181
|
+
control.meta.opera.qualityStatus = qualityReport?.status || profile.status;
|
|
1182
|
+
} else {
|
|
1183
|
+
const qualityReport = directArtifacts?.qualityReport || profile.qualityReport || null;
|
|
1184
|
+
control.meta.opera.contractVersion = null;
|
|
1185
|
+
control.meta.opera.contractReadiness = qualityReport?.contractReadiness || "hypothesis";
|
|
1186
|
+
control.meta.opera.qualityStatus = qualityReport?.status || profile.status;
|
|
1187
|
+
}
|
|
1039
1188
|
|
|
1040
1189
|
config.saveControl(context, control);
|
|
1041
1190
|
return control;
|
|
@@ -1051,9 +1200,14 @@ function resumeBootstrap(root, control) {
|
|
|
1051
1200
|
const files = bootstrapFilePaths(context);
|
|
1052
1201
|
const intake = readJson(files.intakeJson);
|
|
1053
1202
|
const specDossier = readText(files.specDossier);
|
|
1054
|
-
if (!intake ||
|
|
1203
|
+
if (!intake || typeof intake !== "object") {
|
|
1055
1204
|
return { resumed: false, status: "awaiting_agent", reason: "missing_agent_artifacts" };
|
|
1056
1205
|
}
|
|
1206
|
+
const meaningfulKeys = ["problemStatement", "targetUser", "singularDesiredOutcome", "payload", "sourceOfTruth"];
|
|
1207
|
+
const hasContent = meaningfulKeys.some((k) => intake[k] && String(intake[k]).trim());
|
|
1208
|
+
if (!hasContent && !specDossier.trim()) {
|
|
1209
|
+
return { resumed: false, status: "awaiting_agent", reason: "empty_intake_and_spec" };
|
|
1210
|
+
}
|
|
1057
1211
|
|
|
1058
1212
|
const discovery = {
|
|
1059
1213
|
...bootstrap.discovery,
|
|
@@ -1115,6 +1269,31 @@ function detectLegacyBootstrap(root, control) {
|
|
|
1115
1269
|
return null;
|
|
1116
1270
|
}
|
|
1117
1271
|
|
|
1272
|
+
function revalidateContract(root, control) {
|
|
1273
|
+
const context = config.ensureContext(root);
|
|
1274
|
+
if (!config.isOperaInstalled(control)) return { changed: false };
|
|
1275
|
+
if (!fs.existsSync(context.paths.contractFile)) return { changed: false };
|
|
1276
|
+
const files = bootstrapFilePaths(context);
|
|
1277
|
+
const intake = readJson(files.intakeJson);
|
|
1278
|
+
const specDossier = readText(files.specDossier);
|
|
1279
|
+
if (!intake) return { changed: false };
|
|
1280
|
+
const bootstrapState = getBootstrapState(control, context);
|
|
1281
|
+
if (!bootstrapState || !bootstrapState.discovery) return { changed: false };
|
|
1282
|
+
const discovery = { ...bootstrapState.discovery, ...intake };
|
|
1283
|
+
const profile = { ...bootstrapState, discovery };
|
|
1284
|
+
const qualityReport = buildQualityReport(context, profile, specDossier);
|
|
1285
|
+
const newContract = buildOperatingContract(control, profile, qualityReport, context);
|
|
1286
|
+
const existing = readJson(context.paths.contractFile);
|
|
1287
|
+
const changed = JSON.stringify(existing) !== JSON.stringify(newContract);
|
|
1288
|
+
if (changed) {
|
|
1289
|
+
writeJson(context.paths.contractFile, newContract);
|
|
1290
|
+
control.meta.opera.contractReadiness = qualityReport.contractReadiness;
|
|
1291
|
+
control.meta.opera.qualityStatus = qualityReport.status === "ready" ? "completed" : qualityReport.status;
|
|
1292
|
+
config.saveControl(context, control);
|
|
1293
|
+
}
|
|
1294
|
+
return { changed, contractReadiness: qualityReport.contractReadiness };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1118
1297
|
module.exports = {
|
|
1119
1298
|
TECHNICAL_LEVELS,
|
|
1120
1299
|
PROJECT_STATES,
|
|
@@ -1132,6 +1311,7 @@ module.exports = {
|
|
|
1132
1311
|
collectBootstrapProfile,
|
|
1133
1312
|
applyBootstrap,
|
|
1134
1313
|
resumeBootstrap,
|
|
1314
|
+
revalidateContract,
|
|
1135
1315
|
detectLegacyBootstrap,
|
|
1136
1316
|
getBootstrapState,
|
|
1137
1317
|
buildQualityReport,
|