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/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 ensureSplitLayoutAllowed(targetRoot) {
192
- const entries = fs.readdirSync(targetRoot, { withFileTypes: true });
193
- const meaningful = entries.filter((entry) => ![".git"].includes(entry.name));
194
- if (meaningful.length > 0) {
195
- throw new Error("Directory is not empty. Run 'trackops workspace migrate' to convert an existing project.");
196
- }
197
- }
198
-
199
- function initSplitProject(root, options) {
200
- const targetRoot = path.resolve(root);
201
- fs.mkdirSync(targetRoot, { recursive: true });
202
- ensureSplitLayoutAllowed(targetRoot);
203
-
204
- const manifest = workspace.buildManifest();
205
- const context = config.createSplitContext(targetRoot, manifest);
206
- fs.mkdirSync(context.appRoot, { recursive: true });
207
- fs.mkdirSync(context.opsRoot, { recursive: true });
208
- config.saveWorkspaceManifest(context, manifest);
209
- workspace.ensureRootGitignore(targetRoot);
210
-
211
- const control = buildDefaultControl(context, options);
212
- control.meta.projectName = options.name || detectProjectName(context.workspaceRoot);
213
- config.saveControl(context, control);
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
- console.log(t("init.created", { file: ".trackops-workspace.json" }));
228
- console.log(t("init.created", { file: "ops/project_control.json" }));
229
- console.log(t("init.created", { file: "ops/.githooks/" }));
230
- console.log(t("init.created", { file: ".env" }));
231
- console.log(t("init.created", { file: ".env.example" }));
232
- console.log("");
233
- console.log(t("init.welcome"));
234
-
235
- return { root: context.workspaceRoot, context, isUpgrade: false, operaDetected: false };
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);
@@ -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
- console.log("");
497
- console.log(t("bootstrap.header"));
498
- console.log(t("bootstrap.subtitle"));
499
- console.log(t("bootstrap.instructions"));
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 = ["# Open questions", ""];
598
- if (missingFields.length) {
599
- lines.push("## Missing fields", "");
600
- missingFields.forEach((field) => lines.push(`- ${field}`));
601
- lines.push("");
602
- }
603
- if (contradictions.length) {
604
- lines.push("## Contradictions", "");
605
- contradictions.forEach((item) => lines.push(`- ${item}`));
606
- lines.push("");
607
- }
608
- if (!missingFields.length && !contradictions.length) {
609
- lines.push("- None.");
610
- }
611
- return `${lines.join("\n")}\n`;
612
- }
613
-
614
- function qualityStatusFor(missingFields, contradictions) {
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
- if (!profile.discovery.decisionOwnership) missingFields.push("decisionOwnership");
637
- if (!profile.discovery.problemStatement) missingFields.push("problemStatement");
638
- if (!profile.discovery.targetUser) missingFields.push("targetUser");
639
-
640
- const contradictions = [];
641
- const mappings = [
642
- ["problem statement", "problemStatement", profile.discovery.problemStatement],
643
- ["target user", "targetUser", profile.discovery.targetUser],
644
- ["singular desired outcome", "singularDesiredOutcome", profile.discovery.singularDesiredOutcome],
645
- ["delivery target", "payload", profile.discovery.payload],
646
- ["source of truth", "sourceOfTruth", profile.discovery.sourceOfTruth],
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
- const phaseOrder = config.getPhases(control);
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
- control.tasks = [...remainingTasks, ...buildSeedTasks(control, profile)];
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(files.specDossier, "# Spec dossier\n\nUse this file to consolidate the project specification before OPERA ingest.\n");
1014
- }
1015
- writeText(files.openQuestions, buildOpenQuestions(["intakeJson", "specDossier"], []));
1016
- }
1017
- const genesisPath = context.paths.genesisFile;
1018
- if (profile.status === "completed") {
1019
- const specText = fs.existsSync(files.specDossier) && readText(files.specDossier).trim()
1020
- ? readText(files.specDossier)
1021
- : `## 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`;
1022
- const qualityReport = profile.qualityReport || buildQualityReport(context, profile, specText);
1023
- const contract = buildOperatingContract(control, profile, qualityReport, context);
1024
- writeJson(context.paths.contractFile, contract);
1025
- fs.writeFileSync(genesisPath, `${renderGenesis(control, contract)}\n`, "utf8");
1026
- control.meta.opera.contractVersion = CONTRACT_VERSION;
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
- control.meta.opera.contractVersion = null;
1032
- control.meta.opera.contractReadiness = profile.qualityReport?.contractReadiness || "hypothesis";
1033
- control.meta.opera.qualityStatus = profile.qualityReport?.status || profile.status;
1034
- } else {
1035
- control.meta.opera.contractVersion = null;
1036
- control.meta.opera.contractReadiness = "hypothesis";
1037
- control.meta.opera.qualityStatus = profile.status;
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 || !specDossier.trim()) {
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,