trackops 2.0.5 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,44 @@ 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
+ versionControl: {
453
+ remote: "boolean — whether the project uses a remote VCS",
454
+ provider: "string — github | gitlab | bitbucket | other",
455
+ developmentBranch: "string — main development branch",
456
+ releaseBranch: "string — publish/release branch",
457
+ },
458
+ deployment: {
459
+ mode: "string — manual | ci | platform",
460
+ target: "string — staging | production | preview | custom",
461
+ smokeCommand: "string — command to validate a releasable/deployed build",
462
+ },
463
+ inputSchema: { "fieldName": "type — at least one key required" },
464
+ outputSchema: { "fieldName": "type — at least one key required" },
465
+ }, null, 2),
466
+ "```",
467
+ );
468
+
432
469
  if (profile.discovery?.singularDesiredOutcome) {
433
470
  lines.push("", `${t("handoff.label.knownIntention")}: ${profile.discovery.singularDesiredOutcome}`);
434
471
  }
@@ -482,21 +519,34 @@ async function collectBootstrapProfile(root, control, options = {}) {
482
519
  sourceOfTruth: options.answers?.sourceOfTruth || previous.sourceOfTruth || scan.sourceOfTruthHint || "",
483
520
  payload: options.answers?.payload || previous.payload || scan.payloadHint || "",
484
521
  behaviorRules: normalizeList(options.answers?.behaviorRules || previous.behaviorRules || ""),
485
- inputSchema: options.answers?.inputSchema || previous.inputSchema || {},
486
- outputSchema: options.answers?.outputSchema || previous.outputSchema || {},
487
- architecturalInvariants: normalizeList(options.answers?.architecturalInvariants || previous.architecturalInvariants || ""),
488
- pipeline: normalizeList(options.answers?.pipeline || previous.pipeline || ""),
489
- templates: normalizeList(options.answers?.templates || previous.templates || ""),
490
- availableArtifacts: Array.isArray(previousDiscovery.availableArtifacts) ? previousDiscovery.availableArtifacts : [],
491
- };
522
+ inputSchema: options.answers?.inputSchema || previous.inputSchema || {},
523
+ outputSchema: options.answers?.outputSchema || previous.outputSchema || {},
524
+ architecturalInvariants: normalizeList(options.answers?.architecturalInvariants || previous.architecturalInvariants || ""),
525
+ pipeline: normalizeList(options.answers?.pipeline || previous.pipeline || ""),
526
+ templates: normalizeList(options.answers?.templates || previous.templates || ""),
527
+ versionControl: options.answers?.versionControl || previous.versionControl || {
528
+ remote: false,
529
+ provider: "",
530
+ developmentBranch: context.branches.development || "develop",
531
+ releaseBranch: context.branches.publish || "master",
532
+ },
533
+ deployment: options.answers?.deployment || previous.deployment || {
534
+ mode: "",
535
+ target: "",
536
+ smokeCommand: "",
537
+ },
538
+ availableArtifacts: Array.isArray(previousDiscovery.availableArtifacts) ? previousDiscovery.availableArtifacts : [],
539
+ };
492
540
 
493
541
  const answers = { ...defaults };
494
542
 
495
543
  if (interactive) {
496
- console.log("");
497
- console.log(t("bootstrap.header"));
498
- console.log(t("bootstrap.subtitle"));
499
- console.log(t("bootstrap.instructions"));
544
+ fmt.header(t("bootstrap.header"));
545
+ fmt.blank();
546
+ fmt.info(t("bootstrap.subtitle"));
547
+ fmt.blank();
548
+ fmt.info(t("bootstrap.instructions"));
549
+ fmt.blank();
500
550
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
501
551
  try {
502
552
  answers.technicalLevel = await askEnumQuestion(rl, t("bootstrap.question.technicalLevel"), defaults.technicalLevel || "medium", TECHNICAL_LEVELS) || "medium";
@@ -593,25 +643,128 @@ function parseSpecSections(specText) {
593
643
  );
594
644
  }
595
645
 
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) {
646
+ function buildOpenQuestions(missingFields, contradictions) {
647
+ const lines = [`# ${t("bootstrap.scaffold.openQuestionsTitle")}`, ""];
648
+ if (missingFields.length) {
649
+ lines.push(`## ${t("bootstrap.scaffold.missingFields")}`, "");
650
+ missingFields.forEach((field) => lines.push(`- ${field}`));
651
+ lines.push("");
652
+ }
653
+ if (contradictions.length) {
654
+ lines.push(`## ${t("bootstrap.scaffold.contradictions")}`, "");
655
+ contradictions.forEach((item) => lines.push(`- ${item}`));
656
+ lines.push("");
657
+ }
658
+ if (!missingFields.length && !contradictions.length) {
659
+ lines.push(`- ${t("bootstrap.scaffold.none")}`);
660
+ }
661
+ return `${lines.join("\n")}\n`;
662
+ }
663
+
664
+ function buildDirectIntakeTemplate(profile) {
665
+ return {
666
+ version: 1,
667
+ technicalLevel: profile.technicalLevel || null,
668
+ projectState: profile.projectState || null,
669
+ documentationState: profile.documentationState || null,
670
+ decisionOwnership: profile.discovery?.decisionOwnership || profile.decisionOwnership || null,
671
+ problemStatement: profile.discovery?.problemStatement || "",
672
+ targetUser: profile.discovery?.targetUser || "",
673
+ singularDesiredOutcome: profile.discovery?.singularDesiredOutcome || "",
674
+ userLanguage: profile.discovery?.userLanguage || null,
675
+ needsPlainLanguage: Boolean(profile.discovery?.needsPlainLanguage),
676
+ recommendedStack: normalizeList(profile.discovery?.recommendedStack || []),
677
+ externalServices: normalizeList(profile.discovery?.externalServices || []),
678
+ sourceOfTruth: profile.discovery?.sourceOfTruth || "",
679
+ payload: profile.discovery?.payload || "",
680
+ behaviorRules: normalizeList(profile.discovery?.behaviorRules || []),
681
+ inputSchema: profile.discovery?.inputSchema || {},
682
+ outputSchema: profile.discovery?.outputSchema || {},
683
+ architecturalInvariants: normalizeList(profile.discovery?.architecturalInvariants || []),
684
+ pipeline: normalizeList(profile.discovery?.pipeline || []),
685
+ templates: normalizeList(profile.discovery?.templates || []),
686
+ versionControl: profile.discovery?.versionControl || {
687
+ remote: false,
688
+ provider: "",
689
+ developmentBranch: "",
690
+ releaseBranch: "",
691
+ },
692
+ deployment: profile.discovery?.deployment || {
693
+ mode: "",
694
+ target: "",
695
+ smokeCommand: "",
696
+ },
697
+ };
698
+ }
699
+
700
+ function buildSpecDossierScaffold(profile) {
701
+ return [
702
+ `# ${t("bootstrap.scaffold.specTitle")}`,
703
+ "",
704
+ `## ${t("bootstrap.scaffold.problemStatement")}`,
705
+ profile.discovery?.problemStatement || "",
706
+ "",
707
+ `## ${t("bootstrap.scaffold.targetUser")}`,
708
+ profile.discovery?.targetUser || "",
709
+ "",
710
+ `## ${t("bootstrap.scaffold.singularDesiredOutcome")}`,
711
+ profile.discovery?.singularDesiredOutcome || "",
712
+ "",
713
+ `## ${t("bootstrap.scaffold.deliveryTarget")}`,
714
+ profile.discovery?.payload || "",
715
+ "",
716
+ `## ${t("bootstrap.scaffold.sourceOfTruth")}`,
717
+ profile.discovery?.sourceOfTruth || "",
718
+ "",
719
+ ].join("\n");
720
+ }
721
+
722
+ function ensureDirectBootstrapArtifacts(context, profile) {
723
+ const files = bootstrapFilePaths(context);
724
+ const existingIntake = readJson(files.intakeJson);
725
+ const intake = existingIntake && typeof existingIntake === "object"
726
+ ? existingIntake
727
+ : buildDirectIntakeTemplate(profile);
728
+ if (!existingIntake) {
729
+ writeJson(files.intakeJson, intake);
730
+ }
731
+
732
+ let specText = readText(files.specDossier);
733
+ if (!specText.trim()) {
734
+ specText = buildSpecDossierScaffold({ ...profile, discovery: { ...profile.discovery, ...intake } });
735
+ writeText(files.specDossier, specText);
736
+ }
737
+
738
+ const mergedProfile = {
739
+ ...profile,
740
+ discovery: {
741
+ ...(profile.discovery || {}),
742
+ ...intake,
743
+ externalServices: normalizeList(intake.externalServices || profile.discovery?.externalServices || []),
744
+ behaviorRules: normalizeList(intake.behaviorRules || profile.discovery?.behaviorRules || []),
745
+ architecturalInvariants: normalizeList(intake.architecturalInvariants || profile.discovery?.architecturalInvariants || []),
746
+ pipeline: normalizeList(intake.pipeline || profile.discovery?.pipeline || []),
747
+ templates: normalizeList(intake.templates || profile.discovery?.templates || []),
748
+ inputSchema: intake.inputSchema || profile.discovery?.inputSchema || {},
749
+ outputSchema: intake.outputSchema || profile.discovery?.outputSchema || {},
750
+ decisionOwnership: intake.decisionOwnership || profile.discovery?.decisionOwnership || profile.decisionOwnership || null,
751
+ versionControl: intake.versionControl || profile.discovery?.versionControl || null,
752
+ deployment: intake.deployment || profile.discovery?.deployment || null,
753
+ },
754
+ };
755
+ const qualityReport = buildQualityReport(context, mergedProfile, specText);
756
+ writeText(files.openQuestions, buildOpenQuestions(qualityReport.missingFields, qualityReport.contradictions));
757
+ writeJson(files.qualityReport, qualityReport);
758
+
759
+ return {
760
+ intake,
761
+ specText,
762
+ qualityReport,
763
+ profile: mergedProfile,
764
+ };
765
+ }
766
+
767
+ function qualityStatusFor(missingFields, contradictions) {
615
768
  if (missingFields.length >= 2) return "blocked";
616
769
  if (missingFields.length || contradictions.length) return "needs_review";
617
770
  return "ready";
@@ -630,23 +783,21 @@ function contractReadinessFor(profile, qualityStatus) {
630
783
  return "verified";
631
784
  }
632
785
 
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];
786
+ function buildQualityReport(context, profile, specText) {
787
+ const sections = parseSpecSections(specText);
788
+ const missingFields = directMissingFields(profile.discovery);
789
+
790
+ const contradictions = [];
791
+ const mappings = [
792
+ ["problem statement", "problemStatement", profile.discovery.problemStatement, "problema principal"],
793
+ ["target user", "targetUser", profile.discovery.targetUser, "usuario objetivo", "usuario objetivo principal"],
794
+ ["singular desired outcome", "singularDesiredOutcome", profile.discovery.singularDesiredOutcome, "resultado singular deseado", "resultado deseado"],
795
+ ["payload", "payload", profile.discovery.payload, "delivery target", "objetivo de entrega"],
796
+ ["source of truth", "sourceOfTruth", profile.discovery.sourceOfTruth, "fuente de la verdad"],
797
+ ];
798
+ for (const mapping of mappings) {
799
+ const [sectionName, fieldName, expected, ...aliases] = mapping;
800
+ const actual = sections[sectionName] || aliases.reduce((found, alias) => found || sections[alias], null);
650
801
  if (!actual) {
651
802
  missingFields.push(fieldName);
652
803
  continue;
@@ -700,23 +851,25 @@ function buildOperatingContract(control, profile, qualityReport, context) {
700
851
  sourceArtifacts: discovery.availableArtifacts || [],
701
852
  repoScan: profile.inference || {},
702
853
  },
703
- system: {
704
- sourceOfTruth: discovery.sourceOfTruth || "",
705
- externalServices: discovery.externalServices || [],
706
- inputSchema: discovery.inputSchema || {},
707
- outputSchema: discovery.outputSchema || {},
708
- behaviorRules: discovery.behaviorRules || [],
709
- architecturalInvariants: discovery.architecturalInvariants || [],
710
- },
711
- execution: {
712
- pipeline: discovery.pipeline || [],
713
- templates: discovery.templates || [],
714
- phaseModel: "opera-v3",
715
- taskSeeds: buildSeedTasks(control, profile).map((task) => task.id),
716
- },
717
- governance: {
718
- policyFile: policyRelativePath(context),
719
- riskProfile: "standard",
854
+ system: {
855
+ sourceOfTruth: discovery.sourceOfTruth || "",
856
+ externalServices: discovery.externalServices || [],
857
+ inputSchema: discovery.inputSchema || {},
858
+ outputSchema: discovery.outputSchema || {},
859
+ behaviorRules: discovery.behaviorRules || [],
860
+ architecturalInvariants: discovery.architecturalInvariants || [],
861
+ },
862
+ execution: {
863
+ pipeline: discovery.pipeline || [],
864
+ templates: discovery.templates || [],
865
+ deployment: discovery.deployment || null,
866
+ phaseModel: "opera-v3",
867
+ taskSeeds: buildSeedTasks(control, profile).map((task) => task.id),
868
+ },
869
+ governance: {
870
+ policyFile: policyRelativePath(context),
871
+ versionControl: discovery.versionControl || null,
872
+ riskProfile: "standard",
720
873
  approvalRules: [
721
874
  "destructive_changes_require_approval",
722
875
  "production_deploy_requires_approval",
@@ -795,7 +948,10 @@ function renderGenesis(control, contract) {
795
948
  }
796
949
 
797
950
  function buildSeedTasks(control, profile) {
798
- const phaseOrder = config.getPhases(control);
951
+ let phaseOrder = config.getPhases(control);
952
+ if (!phaseOrder || !phaseOrder.length) {
953
+ phaseOrder = config.buildDefaultPhases ? config.buildDefaultPhases(config.getLocale(control)) : config.DEFAULT_PHASES;
954
+ }
799
955
  const firstTaskStatus =
800
956
  profile.status === "completed"
801
957
  ? "completed"
@@ -986,8 +1142,21 @@ function applyBootstrap(root, control, profile) {
986
1142
  control.meta.phases = config.getPhases(control);
987
1143
  control.meta.opera.legacyStatus = "supported";
988
1144
 
1145
+ const existingBootstrap = new Map(
1146
+ (control.tasks || []).filter((task) => task.origin === "bootstrap").map((task) => [task.id, task])
1147
+ );
989
1148
  const remainingTasks = (control.tasks || []).filter((task) => task.id !== "ops-bootstrap" && task.origin !== "bootstrap");
990
- control.tasks = [...remainingTasks, ...buildSeedTasks(control, profile)];
1149
+ const newSeeds = buildSeedTasks(control, profile).map((seed) => {
1150
+ const existing = existingBootstrap.get(seed.id);
1151
+ if (!existing) return seed;
1152
+ return {
1153
+ ...seed,
1154
+ status: existing.status === "completed" ? "completed" : seed.status,
1155
+ history: existing.history || seed.history,
1156
+ blocker: existing.status === "blocked" ? existing.blocker : seed.blocker,
1157
+ };
1158
+ });
1159
+ control.tasks = [...remainingTasks, ...newSeeds];
991
1160
 
992
1161
  control.decisionsPending = (profile.missingFields || [])
993
1162
  .filter((field) => !["intakeJson", "specDossier"].includes(field))
@@ -1005,37 +1174,54 @@ function applyBootstrap(root, control, profile) {
1005
1174
  }
1006
1175
 
1007
1176
  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;
1177
+ const files = bootstrapFilePaths(context);
1178
+ if (profile.mode === "agent_handoff") {
1179
+ writeJson(files.json, buildHandoffPayload(control, profile, context));
1180
+ writeText(files.markdown, buildHandoffPrompt(control, profile));
1181
+ if (!fs.existsSync(files.specDossier)) {
1182
+ writeText(
1183
+ files.specDossier,
1184
+ `# ${t("bootstrap.scaffold.specTitle")}\n\n${t("bootstrap.scaffold.specPlaceholder")}\n`,
1185
+ );
1186
+ }
1187
+ writeText(files.openQuestions, buildOpenQuestions(["intakeJson", "specDossier"], []));
1188
+ }
1189
+ let directArtifacts = null;
1190
+ if (profile.mode === "direct_cli") {
1191
+ directArtifacts = ensureDirectBootstrapArtifacts(context, profile);
1192
+ profile = directArtifacts.profile;
1193
+ control.meta.opera.bootstrap = {
1194
+ ...control.meta.opera.bootstrap,
1195
+ ...profile,
1196
+ handoffFiles: null,
1197
+ };
1198
+ }
1199
+ const genesisPath = context.paths.genesisFile;
1200
+ if (profile.status === "completed") {
1201
+ const specText = directArtifacts?.specText || (
1202
+ fs.existsSync(files.specDossier) && readText(files.specDossier).trim()
1203
+ ? readText(files.specDossier)
1204
+ : `## 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`
1205
+ );
1206
+ const qualityReport = directArtifacts?.qualityReport || profile.qualityReport || buildQualityReport(context, profile, specText);
1207
+ const contract = buildOperatingContract(control, profile, qualityReport, context);
1208
+ writeJson(context.paths.contractFile, contract);
1209
+ fs.writeFileSync(genesisPath, `${renderGenesis(control, contract)}\n`, "utf8");
1210
+ control.meta.opera.contractVersion = CONTRACT_VERSION;
1027
1211
  control.meta.opera.contractReadiness = qualityReport.contractReadiness;
1028
1212
  control.meta.opera.contractFile = contractRelativePath(context);
1029
1213
  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
- }
1214
+ } else if (["needs_review", "blocked"].includes(profile.status)) {
1215
+ const qualityReport = directArtifacts?.qualityReport || profile.qualityReport || null;
1216
+ control.meta.opera.contractVersion = null;
1217
+ control.meta.opera.contractReadiness = qualityReport?.contractReadiness || "hypothesis";
1218
+ control.meta.opera.qualityStatus = qualityReport?.status || profile.status;
1219
+ } else {
1220
+ const qualityReport = directArtifacts?.qualityReport || profile.qualityReport || null;
1221
+ control.meta.opera.contractVersion = null;
1222
+ control.meta.opera.contractReadiness = qualityReport?.contractReadiness || "hypothesis";
1223
+ control.meta.opera.qualityStatus = qualityReport?.status || profile.status;
1224
+ }
1039
1225
 
1040
1226
  config.saveControl(context, control);
1041
1227
  return control;
@@ -1051,9 +1237,14 @@ function resumeBootstrap(root, control) {
1051
1237
  const files = bootstrapFilePaths(context);
1052
1238
  const intake = readJson(files.intakeJson);
1053
1239
  const specDossier = readText(files.specDossier);
1054
- if (!intake || !specDossier.trim()) {
1240
+ if (!intake || typeof intake !== "object") {
1055
1241
  return { resumed: false, status: "awaiting_agent", reason: "missing_agent_artifacts" };
1056
1242
  }
1243
+ const meaningfulKeys = ["problemStatement", "targetUser", "singularDesiredOutcome", "payload", "sourceOfTruth"];
1244
+ const hasContent = meaningfulKeys.some((k) => intake[k] && String(intake[k]).trim());
1245
+ if (!hasContent && !specDossier.trim()) {
1246
+ return { resumed: false, status: "awaiting_agent", reason: "empty_intake_and_spec" };
1247
+ }
1057
1248
 
1058
1249
  const discovery = {
1059
1250
  ...bootstrap.discovery,
@@ -1062,11 +1253,13 @@ function resumeBootstrap(root, control) {
1062
1253
  externalServices: normalizeList(intake.externalServices || bootstrap.discovery?.externalServices || []),
1063
1254
  behaviorRules: normalizeList(intake.behaviorRules || bootstrap.discovery?.behaviorRules || []),
1064
1255
  architecturalInvariants: normalizeList(intake.architecturalInvariants || bootstrap.discovery?.architecturalInvariants || []),
1065
- pipeline: normalizeList(intake.pipeline || bootstrap.discovery?.pipeline || []),
1066
- templates: normalizeList(intake.templates || bootstrap.discovery?.templates || []),
1067
- inputSchema: intake.inputSchema || bootstrap.discovery?.inputSchema || {},
1068
- outputSchema: intake.outputSchema || bootstrap.discovery?.outputSchema || {},
1069
- };
1256
+ pipeline: normalizeList(intake.pipeline || bootstrap.discovery?.pipeline || []),
1257
+ templates: normalizeList(intake.templates || bootstrap.discovery?.templates || []),
1258
+ inputSchema: intake.inputSchema || bootstrap.discovery?.inputSchema || {},
1259
+ outputSchema: intake.outputSchema || bootstrap.discovery?.outputSchema || {},
1260
+ versionControl: intake.versionControl || bootstrap.discovery?.versionControl || null,
1261
+ deployment: intake.deployment || bootstrap.discovery?.deployment || null,
1262
+ };
1070
1263
  const missingFields = directMissingFields(discovery);
1071
1264
  const profile = {
1072
1265
  ...bootstrap,
@@ -1081,12 +1274,13 @@ function resumeBootstrap(root, control) {
1081
1274
  discovery,
1082
1275
  inference: scanProject(context),
1083
1276
  };
1084
- const qualityReport = buildQualityReport(context, profile, specDossier);
1085
- profile.status = qualityReport.status === "ready" ? "completed" : qualityReport.status;
1086
- profile.completedAt = qualityReport.status === "ready" ? nowIso() : null;
1087
- profile.qualityReport = qualityReport;
1088
- return { resumed: true, profile };
1089
- }
1277
+ const qualityReport = buildQualityReport(context, profile, specDossier);
1278
+ profile.status = qualityReport.status === "ready" ? "completed" : qualityReport.status;
1279
+ profile.completedAt = qualityReport.status === "ready" ? nowIso() : null;
1280
+ profile.missingFields = qualityReport.missingFields || [];
1281
+ profile.qualityReport = qualityReport;
1282
+ return { resumed: true, profile };
1283
+ }
1090
1284
 
1091
1285
  function detectLegacyBootstrap(root, control) {
1092
1286
  const context = config.ensureContext(root);
@@ -1115,6 +1309,31 @@ function detectLegacyBootstrap(root, control) {
1115
1309
  return null;
1116
1310
  }
1117
1311
 
1312
+ function revalidateContract(root, control) {
1313
+ const context = config.ensureContext(root);
1314
+ if (!config.isOperaInstalled(control)) return { changed: false };
1315
+ if (!fs.existsSync(context.paths.contractFile)) return { changed: false };
1316
+ const files = bootstrapFilePaths(context);
1317
+ const intake = readJson(files.intakeJson);
1318
+ const specDossier = readText(files.specDossier);
1319
+ if (!intake) return { changed: false };
1320
+ const bootstrapState = getBootstrapState(control, context);
1321
+ if (!bootstrapState || !bootstrapState.discovery) return { changed: false };
1322
+ const discovery = { ...bootstrapState.discovery, ...intake };
1323
+ const profile = { ...bootstrapState, discovery };
1324
+ const qualityReport = buildQualityReport(context, profile, specDossier);
1325
+ const newContract = buildOperatingContract(control, profile, qualityReport, context);
1326
+ const existing = readJson(context.paths.contractFile);
1327
+ const changed = JSON.stringify(existing) !== JSON.stringify(newContract);
1328
+ if (changed) {
1329
+ writeJson(context.paths.contractFile, newContract);
1330
+ control.meta.opera.contractReadiness = qualityReport.contractReadiness;
1331
+ control.meta.opera.qualityStatus = qualityReport.status === "ready" ? "completed" : qualityReport.status;
1332
+ config.saveControl(context, control);
1333
+ }
1334
+ return { changed, contractReadiness: qualityReport.contractReadiness };
1335
+ }
1336
+
1118
1337
  module.exports = {
1119
1338
  TECHNICAL_LEVELS,
1120
1339
  PROJECT_STATES,
@@ -1132,6 +1351,7 @@ module.exports = {
1132
1351
  collectBootstrapProfile,
1133
1352
  applyBootstrap,
1134
1353
  resumeBootstrap,
1354
+ revalidateContract,
1135
1355
  detectLegacyBootstrap,
1136
1356
  getBootstrapState,
1137
1357
  buildQualityReport,