@hilbras/omninode 2.0.0-alpha.10 → 2.0.0-alpha.12

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 CHANGED
@@ -18,7 +18,7 @@ existing intelligence; it does not try to become another model.
18
18
 
19
19
  ## Status
20
20
 
21
- **v2.0.0-alpha.10 — v2 Phase 10: Aggregation Improvements**
21
+ **v2.0.0-alpha.12 — v2 Phase 12: Persistence Layer v2**
22
22
  (the reliability & interoperability line: architecture → execution →
23
23
  protocol → adapters → pipelines → providers → OmniHilbras → memory → reports) (the v2
24
24
  roadmap is [docs/ROADMAP_V2.md](docs/ROADMAP_V2.md); the v1 plan is complete:
@@ -31,6 +31,19 @@ for the plan and [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design).
31
31
 
32
32
  ### What's new in v2 (so far)
33
33
 
34
+ - **Phase 12 — Persistence Layer v2**: writes are now **fsynced and atomic**
35
+ (temp → fsync → rename → directory fsync), corrupt store files are
36
+ **quarantined instead of lost**, documents carry a **schema version** with
37
+ v1 data migrated in place (`omninode migrate [--check]`), and files from a
38
+ newer OmniNode are refused rather than misread. Details:
39
+ [PERSISTENCE.md](docs/PERSISTENCE.md).
40
+ - **Phase 11 — Planner v2**: the planner now receives the full §15 input —
41
+ task, project context, agent reports, **aggregated findings (consensus and
42
+ conflicts included)**, memory and **constraints** (settable per pipeline
43
+ step). Plans carry an explicit goal, every generated plan is **schema
44
+ validated**, invalid output produces a **structured error** or falls back to
45
+ heuristic planning, and a guard test proves the planner module depends on no
46
+ specific provider.
34
47
  - **Phase 10 — Aggregation Improvements**: when agents disagree,
35
48
  OmniNode now says so instead of flattening the answer — **conflict
36
49
  detection** preserves every position (severity spread, divergent
@@ -352,7 +365,7 @@ required for the core engine.
352
365
  - [Architecture](docs/ARCHITECTURE.md) — module map, workflow, local state
353
366
  - [Security model](docs/SECURITY.md) — secrets, process execution, env isolation, audit log
354
367
  - [API stability policy](docs/API_STABILITY.md) and [public API inventory](docs/API.md)
355
- - [Agent Protocol v2 spec](docs/PROTOCOL.md) · [Providers](docs/PROVIDERS.md) · [Memory](docs/MEMORY.md) · [Reports](docs/REPORTS.md) · [Deprecations](docs/DEPRECATIONS.md) · [Changelog](CHANGELOG.md) · [v2 roadmap](docs/ROADMAP_V2.md)
368
+ - [Agent Protocol v2 spec](docs/PROTOCOL.md) · [Providers](docs/PROVIDERS.md) · [Memory](docs/MEMORY.md) · [Reports](docs/REPORTS.md) · [Persistence](docs/PERSISTENCE.md) · [Deprecations](docs/DEPRECATIONS.md) · [Changelog](CHANGELOG.md) · [v2 roadmap](docs/ROADMAP_V2.md)
356
369
  - [Example project](examples/demo/) — a runnable end-to-end demo (`./demo.sh`)
357
370
 
358
371
  Prefer a container? `docker build -t omninode .` then
package/dist/cli/index.js CHANGED
@@ -6,7 +6,7 @@ import { realpathSync } from "fs";
6
6
  import { Command } from "commander";
7
7
 
8
8
  // src/version.ts
9
- var OMNINODE_VERSION = "2.0.0-alpha.10";
9
+ var OMNINODE_VERSION = "2.0.0-alpha.12";
10
10
 
11
11
  // src/errors/index.ts
12
12
  var OmniNodeError = class extends Error {
@@ -109,7 +109,9 @@ var pipelineStepConfigSchema = z.object({
109
109
  model: z.string().optional(),
110
110
  depends_on: z.array(z.string()).optional(),
111
111
  retries: z.number().int().min(0).max(10).optional(),
112
- condition: z.enum(["always", "on-success", "on-failure"]).optional()
112
+ condition: z.enum(["always", "on-success", "on-failure"]).optional(),
113
+ /** Constraints passed to the planner for plan steps (§15). */
114
+ constraints: z.array(z.string()).optional()
113
115
  }).strict();
114
116
  var pipelineConfigSchema = z.object({
115
117
  id: z.string().min(1).regex(/^[a-z0-9][a-z0-9-]*$/, "pipeline id must be kebab-case"),
@@ -239,7 +241,8 @@ function toPipelineDefinition(raw) {
239
241
  model: step.model,
240
242
  dependsOn: step.depends_on,
241
243
  retries: step.retries,
242
- condition: step.condition
244
+ condition: step.condition,
245
+ constraints: step.constraints
243
246
  }))
244
247
  };
245
248
  }
@@ -2105,51 +2108,119 @@ function registerRoleCommands(program) {
2105
2108
  }
2106
2109
 
2107
2110
  // src/persistence/json-file-store.ts
2108
- import { mkdir, readFile, rename, writeFile } from "fs/promises";
2111
+ import { open, mkdir, readFile, rename, stat } from "fs/promises";
2109
2112
  import { dirname } from "path";
2113
+ var SCHEMA_VERSION = 2;
2114
+ async function writeFileAtomic(target, contents) {
2115
+ const tmp = `${target}.tmp`;
2116
+ await mkdir(dirname(target), { recursive: true });
2117
+ const handle = await open(tmp, "w");
2118
+ try {
2119
+ await handle.writeFile(contents, "utf8");
2120
+ await handle.sync();
2121
+ } finally {
2122
+ await handle.close();
2123
+ }
2124
+ await rename(tmp, target);
2125
+ try {
2126
+ const dir = await open(dirname(target), "r");
2127
+ try {
2128
+ await dir.sync();
2129
+ } finally {
2130
+ await dir.close();
2131
+ }
2132
+ } catch {
2133
+ }
2134
+ }
2135
+ async function inspectDocument(path) {
2136
+ let raw;
2137
+ try {
2138
+ await stat(path);
2139
+ } catch {
2140
+ return { status: "missing" };
2141
+ }
2142
+ try {
2143
+ raw = await readFile(path, "utf8");
2144
+ } catch (error) {
2145
+ return { status: "corrupt", detail: `unreadable: ${error instanceof Error ? error.message : String(error)}` };
2146
+ }
2147
+ if (raw.trim().length === 0) return { status: "empty" };
2148
+ let parsed;
2149
+ try {
2150
+ parsed = JSON.parse(raw);
2151
+ } catch (error) {
2152
+ return { status: "corrupt", detail: error instanceof Error ? error.message : "invalid JSON" };
2153
+ }
2154
+ if (Array.isArray(parsed)) return { status: "v1-legacy" };
2155
+ if (parsed === null || typeof parsed !== "object") return { status: "corrupt", detail: "not an object" };
2156
+ const doc = parsed;
2157
+ if (!Array.isArray(doc.items)) return { status: "corrupt", detail: "missing items array" };
2158
+ const version = typeof doc.schemaVersion === "number" ? doc.schemaVersion : 1;
2159
+ if (version > SCHEMA_VERSION) return { status: "future", schemaVersion: version };
2160
+ if (version < SCHEMA_VERSION) return { status: "v1-legacy" };
2161
+ return { status: "current", schemaVersion: version, count: doc.items.length };
2162
+ }
2163
+ async function readDocument(path, onCorrupt, quarantine = true) {
2164
+ const state = await inspectDocument(path);
2165
+ if (state.status === "missing" || state.status === "empty") return [];
2166
+ if (state.status === "future") {
2167
+ const error = new Error(
2168
+ `${path} was written by a newer OmniNode (schema v${state.schemaVersion} > v${SCHEMA_VERSION})`
2169
+ );
2170
+ onCorrupt?.(path, error);
2171
+ return [];
2172
+ }
2173
+ let raw;
2174
+ try {
2175
+ raw = await readFile(path, "utf8");
2176
+ } catch (error) {
2177
+ onCorrupt?.(path, error);
2178
+ return [];
2179
+ }
2180
+ try {
2181
+ const parsed = JSON.parse(raw);
2182
+ if (Array.isArray(parsed)) return parsed;
2183
+ return parsed.items ?? [];
2184
+ } catch (error) {
2185
+ if (quarantine) {
2186
+ const quarantinePath = `${path}.corrupt-${Date.now()}`;
2187
+ try {
2188
+ await rename(path, quarantinePath);
2189
+ logger.child({ component: "persistence" }).warn(
2190
+ `Quarantined unreadable store file ${path} \u2192 ${quarantinePath}`
2191
+ );
2192
+ } catch {
2193
+ }
2194
+ }
2195
+ onCorrupt?.(path, error);
2196
+ return [];
2197
+ }
2198
+ }
2110
2199
  var JsonFileStore = class {
2111
2200
  filePath;
2112
2201
  schemaVersion;
2113
2202
  onCorrupt;
2203
+ quarantine;
2114
2204
  log;
2115
2205
  queue = Promise.resolve();
2116
2206
  constructor(options) {
2117
2207
  this.filePath = `${options.directory ?? `${process.cwd()}/.omninode`}/${options.fileName}`;
2118
- this.schemaVersion = options.schemaVersion ?? 1;
2208
+ this.schemaVersion = options.schemaVersion ?? SCHEMA_VERSION;
2119
2209
  this.onCorrupt = options.onCorrupt;
2210
+ this.quarantine = options.quarantine ?? true;
2120
2211
  this.log = logger.child({ component: "persistence", file: options.fileName });
2121
2212
  }
2122
2213
  get path() {
2123
2214
  return this.filePath;
2124
2215
  }
2125
- /** All items, or [] when missing/corrupt. */
2216
+ /** All items, or [] when missing/migrated-from-v1/corrupt. */
2126
2217
  async readAll() {
2127
- let raw;
2128
- try {
2129
- raw = await readFile(this.filePath, "utf8");
2130
- } catch {
2131
- return [];
2132
- }
2133
- try {
2134
- const parsed = JSON.parse(raw);
2135
- if (Array.isArray(parsed)) return parsed;
2136
- if (parsed !== null && typeof parsed === "object" && Array.isArray(parsed.items)) {
2137
- return parsed.items;
2138
- }
2139
- throw new Error("persisted file has an unrecognized shape");
2140
- } catch (error) {
2141
- this.log.warn(`Treating ${this.filePath} as empty \u2014 it could not be parsed.`);
2142
- this.onCorrupt?.(this.filePath, error);
2143
- return [];
2144
- }
2218
+ return readDocument(this.filePath, this.onCorrupt, this.quarantine);
2145
2219
  }
2146
2220
  async writeAll(items) {
2147
2221
  const document = { schemaVersion: this.schemaVersion, items };
2148
- const tmp = `${this.filePath}.tmp`;
2149
- await mkdir(dirname(this.filePath), { recursive: true });
2150
- await writeFile(tmp, `${JSON.stringify(document, null, 2)}
2151
- `, "utf8");
2152
- await rename(tmp, this.filePath);
2222
+ await writeFileAtomic(this.filePath, `${JSON.stringify(document, null, 2)}
2223
+ `);
2153
2224
  }
2154
2225
  /** Serializes an async operation against this file. */
2155
2226
  synchronized(operation) {
@@ -2452,7 +2523,7 @@ function sleep(ms) {
2452
2523
  }
2453
2524
 
2454
2525
  // src/audit/index.ts
2455
- import { appendFile, mkdir as mkdir2 } from "fs/promises";
2526
+ import { mkdir as mkdir2, readFile as readFile2 } from "fs/promises";
2456
2527
  import { dirname as dirname2 } from "path";
2457
2528
  var FileAuditLog = class {
2458
2529
  filePath;
@@ -2464,8 +2535,15 @@ var FileAuditLog = class {
2464
2535
  async record(event) {
2465
2536
  try {
2466
2537
  await mkdir2(dirname2(this.filePath), { recursive: true });
2467
- await appendFile(this.filePath, `${JSON.stringify(event)}
2538
+ const { open: open2 } = await import("fs/promises");
2539
+ const handle = await open2(this.filePath, "a");
2540
+ try {
2541
+ await handle.appendFile(`${JSON.stringify(event)}
2468
2542
  `, "utf8");
2543
+ await handle.sync();
2544
+ } finally {
2545
+ await handle.close();
2546
+ }
2469
2547
  } catch (error) {
2470
2548
  this.log.warn(
2471
2549
  `Audit write failed: ${error instanceof Error ? error.message : String(error)}`
@@ -2474,7 +2552,6 @@ var FileAuditLog = class {
2474
2552
  }
2475
2553
  /** Recent events, oldest first; used by the CLI. */
2476
2554
  async recent(limit = 20) {
2477
- const { readFile: readFile2 } = await import("fs/promises");
2478
2555
  let raw;
2479
2556
  try {
2480
2557
  raw = await readFile2(this.filePath, "utf8");
@@ -3532,19 +3609,32 @@ var HeuristicPlanner = class {
3532
3609
  name = "heuristic";
3533
3610
  async plan(request) {
3534
3611
  const steps = [];
3535
- const findings = request.reports.flatMap(
3536
- (report) => report.findings.map((finding) => ({ finding, agent: report.agent }))
3537
- );
3538
3612
  const recommendations = dedupe(request.reports.flatMap((r) => r.recommendations));
3539
- for (const [index, text] of recommendations.entries()) {
3613
+ for (const text of recommendations) {
3540
3614
  steps.push({
3541
3615
  id: `step-${steps.length + 1}`,
3542
3616
  title: truncate5(text, 120),
3543
3617
  order: steps.length + 1,
3544
- ...index > 0 ? { dependsOn: [`step-${steps.length}`] } : {}
3618
+ ...steps.length > 0 ? { dependsOn: [`step-${steps.length}`] } : {}
3545
3619
  });
3546
3620
  }
3547
- const significant = findings.filter(({ finding }) => finding.severity ? SEVERITY_RANK2[finding.severity] >= SEVERITY_RANK2.high : false).sort((a, b) => SEVERITY_RANK2[b.finding.severity ?? "info"] - SEVERITY_RANK2[a.finding.severity ?? "info"]);
3621
+ const contributed = request.aggregated ? request.aggregated.findings.map((finding) => ({
3622
+ finding: {
3623
+ id: `agg-${slug(finding.title)}`,
3624
+ title: finding.title,
3625
+ detail: finding.details[0] ?? finding.title,
3626
+ ...finding.severity ? { severity: finding.severity } : {},
3627
+ ...finding.recommendations && finding.recommendations.length > 0 ? { recommendation: finding.recommendations[0] } : {}
3628
+ },
3629
+ agent: finding.sources.map((s) => s.agent).join("+")
3630
+ })) : request.reports.flatMap(
3631
+ (report) => report.findings.map((finding) => ({ finding, agent: report.agent }))
3632
+ );
3633
+ const significant = contributed.filter(
3634
+ ({ finding }) => finding.severity ? SEVERITY_RANK2[finding.severity] >= SEVERITY_RANK2.high : false
3635
+ ).sort(
3636
+ (a, b) => SEVERITY_RANK2[b.finding.severity ?? "info"] - SEVERITY_RANK2[a.finding.severity ?? "info"]
3637
+ );
3548
3638
  for (const { finding } of significant) {
3549
3639
  steps.push(remediationStep(finding, steps.length + 1));
3550
3640
  }
@@ -3557,12 +3647,20 @@ var HeuristicPlanner = class {
3557
3647
  });
3558
3648
  }
3559
3649
  const risks = significant.filter(({ finding }) => finding.severity === "critical").map(({ finding }) => `Critical finding unaddressed until its step completes: ${finding.title}`);
3650
+ const conflicts = request.aggregated?.conflicts ?? [];
3651
+ if (conflicts.length > 0) {
3652
+ risks.push(
3653
+ `${conflicts.length} unresolved agent disagreement(s): ${conflicts.map((c) => c.findingTitle).join("; ")}`
3654
+ );
3655
+ }
3560
3656
  return {
3561
3657
  id: `plan-${Date.now().toString(36)}-${randomBytes3(3).toString("hex")}`,
3562
3658
  objective: request.objective,
3659
+ goal: request.aggregated?.title ?? truncate5(request.objective, 120),
3563
3660
  summary: `Derived from ${request.reports.length} report(s): ${recommendations.length} recommendation step(s), ${significant.length} remediation step(s).`,
3564
3661
  steps,
3565
3662
  ...risks.length > 0 ? { risks } : {},
3663
+ ...request.constraints !== void 0 && request.constraints.length > 0 ? { constraints: request.constraints } : {},
3566
3664
  generatedBy: "heuristic",
3567
3665
  ...request.taskId !== void 0 ? { taskId: request.taskId } : {},
3568
3666
  ...request.pipelineRunId !== void 0 ? { pipelineRunId: request.pipelineRunId } : {},
@@ -3577,24 +3675,76 @@ function remediationStep(finding, order) {
3577
3675
  description: finding.detail,
3578
3676
  order,
3579
3677
  dependsOn: order > 1 ? [`step-${order - 1}`] : [],
3580
- sourceFindings: [finding.id]
3678
+ sourceFindings: [finding.id],
3679
+ ...finding.recommendation ? { recommendation: finding.recommendation } : {}
3581
3680
  };
3582
3681
  }
3583
3682
  function dedupe(texts) {
3584
3683
  return [...new Set(texts.map((t) => t.trim()).filter((t) => t.length > 0))];
3585
3684
  }
3685
+ function slug(text) {
3686
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40);
3687
+ }
3586
3688
  function truncate5(text, max) {
3587
3689
  return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
3588
3690
  }
3589
3691
 
3590
3692
  // src/planner/model.ts
3591
3693
  import { randomBytes as randomBytes4 } from "crypto";
3694
+
3695
+ // src/planner/schema.ts
3592
3696
  import { z as z3 } from "zod";
3697
+ var planStepSchema = z3.object({
3698
+ id: z3.string().min(1).optional(),
3699
+ title: z3.string().min(1),
3700
+ description: z3.string().optional(),
3701
+ order: z3.number().int().optional(),
3702
+ targets: z3.array(z3.string()).optional(),
3703
+ acceptance_criteria: z3.array(z3.string()).optional(),
3704
+ depends_on: z3.array(z3.string()).optional(),
3705
+ source_findings: z3.array(z3.string()).optional()
3706
+ });
3707
+ var planJsonSchema = z3.object({
3708
+ goal: z3.string().min(1),
3709
+ summary: z3.string().min(1),
3710
+ steps: z3.array(planStepSchema).min(1),
3711
+ risks: z3.array(z3.string()).optional(),
3712
+ notes: z3.array(z3.string()).optional()
3713
+ });
3714
+ function validatePlan(plan) {
3715
+ const issues = [];
3716
+ const stepIds = /* @__PURE__ */ new Set();
3717
+ for (const [index, step] of plan.steps.entries()) {
3718
+ if (stepIds.has(step.id)) {
3719
+ issues.push({ path: `steps.${index}.id`, message: `duplicate step id "${step.id}"` });
3720
+ }
3721
+ stepIds.add(step.id);
3722
+ if (!step.title || step.title.trim().length === 0) {
3723
+ issues.push({ path: `steps.${index}.title`, message: "title must not be empty" });
3724
+ }
3725
+ for (const dep of step.dependsOn ?? []) {
3726
+ if (!stepIds.has(dep) && !plan.steps.some((s) => s.id === dep)) {
3727
+ issues.push({ path: `steps.${index}.dependsOn`, message: `unknown step dependency "${dep}"` });
3728
+ }
3729
+ }
3730
+ }
3731
+ if (plan.steps.length === 0) {
3732
+ issues.push({ path: "steps", message: "a plan needs at least one step" });
3733
+ }
3734
+ if (!plan.summary || plan.summary.trim().length === 0) {
3735
+ issues.push({ path: "summary", message: "summary must not be empty" });
3736
+ }
3737
+ return issues.length > 0 ? { valid: false, issues, reason: `plan failed validation (${issues.length} issue(s))` } : { valid: true, plan };
3738
+ }
3593
3739
 
3594
3740
  // src/planner/context.ts
3595
3741
  function buildPlannerContext(request) {
3596
- const parts = [`# Objective
3742
+ const parts = [`# Task
3597
3743
  ${request.objective}`];
3744
+ if (request.projectContext) {
3745
+ parts.push(`# Project context
3746
+ ${request.projectContext}`);
3747
+ }
3598
3748
  if (request.role) {
3599
3749
  const roleLines = [`# Role
3600
3750
  ${request.role.name} (${request.role.id})`];
@@ -3614,7 +3764,7 @@ ${request.role.name} (${request.role.id})`];
3614
3764
  )
3615
3765
  );
3616
3766
  parts.push(
3617
- `# Findings (${request.reports.length} report(s) by ${agents})
3767
+ `# Agent reports (${request.reports.length} by ${agents})
3618
3768
  ` + (findingLines.length > 0 ? findingLines.join("\n") : "(no findings reported)")
3619
3769
  );
3620
3770
  const recommendationLines = request.reports.flatMap(
@@ -3624,9 +3774,20 @@ ${request.role.name} (${request.role.id})`];
3624
3774
  parts.push(`# Recommendations
3625
3775
  ${recommendationLines.join("\n")}`);
3626
3776
  }
3627
- const summaryLines = request.reports.map((report) => `- [${report.agent}] ${report.summary}`);
3628
- parts.push(`# Report summaries
3629
- ${summaryLines.join("\n")}`);
3777
+ }
3778
+ if (request.aggregated) {
3779
+ const aggregatedLines = [
3780
+ `- findings: ${request.aggregated.findings.length} (agreements: ${request.aggregated.agreements.length}, conflicts: ${request.aggregated.conflicts.length})`,
3781
+ ...request.aggregated.findings.map(
3782
+ (finding) => `- [${finding.severity ?? "info"}] ${finding.title} (x${finding.occurrences})`
3783
+ ),
3784
+ // Conflicts stay visible to the planner — it must plan around disagreement.
3785
+ ...request.aggregated.conflicts.map(
3786
+ (conflict) => `- CONFLICT (${conflict.type}) on "${conflict.findingTitle}": ${conflict.note}`
3787
+ )
3788
+ ];
3789
+ parts.push(`# Aggregated findings
3790
+ ${aggregatedLines.join("\n")}`);
3630
3791
  }
3631
3792
  if (request.memory && request.memory.length > 0) {
3632
3793
  parts.push(
@@ -3634,7 +3795,11 @@ ${summaryLines.join("\n")}`);
3634
3795
  ` + request.memory.map((entry2) => `- [${entry2.scope}] ${entry2.content}`).join("\n")
3635
3796
  );
3636
3797
  }
3637
- if (request.context && request.context.length > 0) {
3798
+ if (request.constraints && request.constraints.length > 0) {
3799
+ parts.push(`# Constraints
3800
+ ${request.constraints.map((c) => `- ${c}`).join("\n")}`);
3801
+ }
3802
+ if (request.context) {
3638
3803
  parts.push(`# Research context
3639
3804
  ${request.context}`);
3640
3805
  }
@@ -3642,22 +3807,6 @@ ${request.context}`);
3642
3807
  }
3643
3808
 
3644
3809
  // src/planner/model.ts
3645
- var planStepJsonSchema = z3.object({
3646
- id: z3.string().optional(),
3647
- title: z3.string().min(1),
3648
- description: z3.string().optional(),
3649
- order: z3.number().int().optional(),
3650
- targets: z3.array(z3.string()).optional(),
3651
- acceptance_criteria: z3.array(z3.string()).optional(),
3652
- depends_on: z3.array(z3.string()).optional(),
3653
- source_findings: z3.array(z3.string()).optional()
3654
- }).strict();
3655
- var planJsonSchema = z3.object({
3656
- summary: z3.string().min(1),
3657
- steps: z3.array(planStepJsonSchema).min(1),
3658
- risks: z3.array(z3.string()).optional(),
3659
- notes: z3.array(z3.string()).optional()
3660
- }).strict();
3661
3810
  var ModelPlanner = class {
3662
3811
  constructor(options) {
3663
3812
  this.options = options;
@@ -3671,7 +3820,7 @@ var ModelPlanner = class {
3671
3820
  const messages = [
3672
3821
  {
3673
3822
  role: "system",
3674
- content: 'You are the OmniNode planner. Given the objective and the combined research context, produce a final analysis and a prioritized, actionable implementation plan. Respond with JSON ONLY (no prose, no code fences) matching exactly this shape: {"summary": string, "steps": [{"id": string, "title": string, "description": string, "order": number, "targets": string[], "acceptance_criteria": string[], "source_findings": string[]}], "risks": string[], "notes": string[]}. ' + (this.options.instruction ?? "")
3823
+ content: 'You are the OmniNode planner. Given the objective and the combined research context, produce a final analysis and a prioritized, actionable implementation plan. Respond with JSON ONLY (no prose, no code fences) matching exactly this shape: {"goal": string, "summary": string, "steps": [{"id": string, "title": string, "description": string, "order": number, "targets": string[], "acceptance_criteria": string[], "source_findings": string[]}], "risks": string[], "notes": string[]}. ' + (this.options.instruction ?? "")
3675
3824
  },
3676
3825
  { role: "user", content: context }
3677
3826
  ];
@@ -3682,60 +3831,86 @@ var ModelPlanner = class {
3682
3831
  return this.degrade(request, `planner model call failed: ${describe(error)}`);
3683
3832
  }
3684
3833
  const parsed = parsePlanJson(responseText);
3685
- if (!parsed) {
3686
- return this.degrade(request, "planner model output was not valid plan JSON");
3834
+ if (!parsed.ok) {
3835
+ return this.degrade(request, `planner model output rejected: ${parsed.reason}`, parsed.issues);
3687
3836
  }
3688
3837
  const plan = {
3689
3838
  id: `plan-${Date.now().toString(36)}-${randomBytes4(3).toString("hex")}`,
3690
3839
  objective: request.objective,
3840
+ goal: parsed.goal,
3691
3841
  summary: parsed.summary,
3692
3842
  steps: parsed.steps,
3693
3843
  ...parsed.risks !== void 0 ? { risks: parsed.risks } : {},
3694
3844
  ...parsed.notes !== void 0 ? { notes: parsed.notes } : {},
3845
+ ...request.constraints !== void 0 && request.constraints.length > 0 ? { constraints: request.constraints } : {},
3695
3846
  generatedBy: `model:${this.options.model}`,
3696
3847
  model: this.options.model,
3697
3848
  ...request.taskId !== void 0 ? { taskId: request.taskId } : {},
3698
3849
  ...request.pipelineRunId !== void 0 ? { pipelineRunId: request.pipelineRunId } : {},
3699
3850
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
3700
3851
  };
3701
- return plan;
3852
+ const validation = validatePlan(plan);
3853
+ if (!validation.valid) {
3854
+ return this.degrade(request, `generated plan rejected: ${validation.reason}`, validation.issues);
3855
+ }
3856
+ return validation.plan;
3702
3857
  }
3703
- async degrade(request, reason) {
3858
+ async degrade(request, reason, issues = []) {
3704
3859
  if (this.options.fallback) {
3705
3860
  this.log.warn(`${reason} \u2014 using the heuristic fallback planner.`);
3706
3861
  return this.options.fallback.plan(request);
3707
3862
  }
3708
- throw new PlannerError("PLANNER_FAILED", `Planner failed: ${reason}.`);
3863
+ throw new PlannerError("PLAN_INVALID", `Planner failed: ${reason}.`, {
3864
+ details: { reason, issues }
3865
+ });
3709
3866
  }
3710
3867
  };
3711
3868
  function parsePlanJson(text) {
3712
3869
  const unfenced = text.replace(/```(?:json)?/gi, "");
3713
3870
  const start = unfenced.indexOf("{");
3714
3871
  const end = unfenced.lastIndexOf("}");
3715
- if (start < 0 || end <= start) return void 0;
3872
+ if (start < 0 || end <= start) {
3873
+ return { ok: false, reason: "planner output contained no JSON object", issues: [] };
3874
+ }
3875
+ let json;
3716
3876
  try {
3717
- const json = JSON.parse(unfenced.slice(start, end + 1));
3718
- const parsed = planJsonSchema.safeParse(json);
3719
- if (!parsed.success) return void 0;
3720
- const steps = parsed.data.steps.map((step, index) => ({
3721
- id: step.id ?? `step-${index + 1}`,
3722
- title: step.title,
3723
- description: step.description,
3724
- order: step.order ?? index + 1,
3725
- targets: step.targets,
3726
- acceptanceCriteria: step.acceptance_criteria,
3727
- dependsOn: step.depends_on,
3728
- sourceFindings: step.source_findings
3729
- }));
3877
+ json = JSON.parse(unfenced.slice(start, end + 1));
3878
+ } catch (error) {
3730
3879
  return {
3731
- summary: parsed.data.summary,
3732
- steps: steps.sort((a, b) => a.order - b.order),
3733
- risks: parsed.data.risks,
3734
- notes: parsed.data.notes
3880
+ ok: false,
3881
+ reason: `planner output was not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
3882
+ issues: []
3883
+ };
3884
+ }
3885
+ const parsed = planJsonSchema.safeParse(json);
3886
+ if (!parsed.success) {
3887
+ return {
3888
+ ok: false,
3889
+ reason: "planner output did not match the plan schema",
3890
+ issues: parsed.error.issues.map((issue) => ({
3891
+ path: issue.path.join(".") || "(root)",
3892
+ message: issue.message
3893
+ }))
3735
3894
  };
3736
- } catch {
3737
- return void 0;
3738
3895
  }
3896
+ const steps = parsed.data.steps.map((step, index) => ({
3897
+ id: step.id ?? `step-${index + 1}`,
3898
+ title: step.title,
3899
+ description: step.description,
3900
+ order: step.order ?? index + 1,
3901
+ targets: step.targets,
3902
+ acceptanceCriteria: step.acceptance_criteria,
3903
+ dependsOn: step.depends_on,
3904
+ sourceFindings: step.source_findings
3905
+ })).sort((a, b) => a.order - b.order);
3906
+ return {
3907
+ ok: true,
3908
+ goal: parsed.data.goal,
3909
+ summary: parsed.data.summary,
3910
+ steps,
3911
+ risks: parsed.data.risks,
3912
+ notes: parsed.data.notes
3913
+ };
3739
3914
  }
3740
3915
  function describe(error) {
3741
3916
  return error instanceof Error ? error.message : String(error);
@@ -4173,11 +4348,14 @@ ${contribution}`);
4173
4348
  };
4174
4349
  }
4175
4350
  async runPlan(step, ctx) {
4351
+ const aggregated = ctx.reports.length > 0 ? aggregateReports(ctx.reports) : void 0;
4176
4352
  const request = {
4177
4353
  objective: ctx.objective,
4178
4354
  reports: ctx.reports,
4355
+ ...aggregated !== void 0 ? { aggregated } : {},
4179
4356
  ...ctx.combinedContext.length > 0 ? { context: ctx.combinedContext } : {},
4180
4357
  ...ctx.role ? { role: ctx.role } : {},
4358
+ ...step.constraints !== void 0 && step.constraints.length > 0 ? { constraints: step.constraints } : {},
4181
4359
  ...ctx.pipelineRunId !== void 0 ? { pipelineRunId: ctx.pipelineRunId } : {}
4182
4360
  };
4183
4361
  let planner;
@@ -4705,6 +4883,78 @@ function registerAuditCommand(program) {
4705
4883
  });
4706
4884
  }
4707
4885
 
4886
+ // src/persistence/index.ts
4887
+ var STORE_FILES = [
4888
+ "tasks.json",
4889
+ "pipelines.json",
4890
+ "reports.json",
4891
+ "plans.json",
4892
+ "memory.json"
4893
+ ];
4894
+ async function migrateProjectStores(directory = `${process.cwd()}/.omninode`, options = {}) {
4895
+ const reports = [];
4896
+ for (const file of STORE_FILES) {
4897
+ const path = `${directory}/${file}`;
4898
+ const state = await inspectDocument(path);
4899
+ if (state.status === "missing") {
4900
+ reports.push({ file, state: state.status, migrated: false });
4901
+ continue;
4902
+ }
4903
+ if (state.status === "current") {
4904
+ reports.push({ file, state: state.status, before: state.count, after: state.count, migrated: false });
4905
+ continue;
4906
+ }
4907
+ if (state.status === "future") {
4908
+ reports.push({
4909
+ file,
4910
+ state: state.status,
4911
+ migrated: false,
4912
+ note: `written by a newer OmniNode (schema v${state.schemaVersion}); not touched`
4913
+ });
4914
+ continue;
4915
+ }
4916
+ if (state.status === "corrupt") {
4917
+ reports.push({ file, state: state.status, migrated: false, note: state.detail });
4918
+ continue;
4919
+ }
4920
+ const items = await readDocument(path, void 0, false);
4921
+ if (!options.checkOnly) {
4922
+ await writeFileAtomic(path, `${JSON.stringify({ schemaVersion: SCHEMA_VERSION, items }, null, 2)}
4923
+ `);
4924
+ }
4925
+ const beforeCount = items.length;
4926
+ reports.push({
4927
+ file,
4928
+ state: state.status,
4929
+ before: beforeCount,
4930
+ after: items.length,
4931
+ migrated: !options.checkOnly,
4932
+ ...options.checkOnly ? { note: "would migrate" } : {}
4933
+ });
4934
+ }
4935
+ return reports;
4936
+ }
4937
+
4938
+ // src/cli/commands/migrate.ts
4939
+ function registerMigrateCommand(program) {
4940
+ program.command("migrate").description(
4941
+ `Inspect and upgrade persisted store files to schema v${SCHEMA_VERSION} (v1 data is migrated in place).`
4942
+ ).option("--check", "Only report what would change; do not write.").action(async (options) => {
4943
+ const reports = await migrateProjectStores(`${process.cwd()}/.omninode`, {
4944
+ checkOnly: options.check ?? false
4945
+ });
4946
+ let changed = 0;
4947
+ for (const report of reports) {
4948
+ if (report.migrated || report.state === "v1-legacy") changed += 1;
4949
+ const detail = report.state === "corrupt" ? ` (corrupt: ${report.note})` : report.state === "future" ? ` (${report.note})` : report.state === "v1-legacy" ? ` ${report.migrated ? "migrated" : "would migrate"} ${report.before ?? 0} \u2192 ${report.after ?? 0} item(s)` : report.state === "current" ? ` ${report.after ?? 0} item(s)` : "";
4950
+ console.log(`${report.file.padEnd(16)} ${report.state}${detail}`);
4951
+ }
4952
+ console.log(
4953
+ changed === 0 ? `All stores are at schema v${SCHEMA_VERSION}.` : `${options.check ? "Would migrate" : "Migrated"} ${changed} store file(s) to schema v${SCHEMA_VERSION}.`
4954
+ );
4955
+ });
4956
+ }
4957
+
4708
4958
  // src/cli/index.ts
4709
4959
  function createProgram() {
4710
4960
  const program = new Command();
@@ -4724,6 +4974,7 @@ function createProgram() {
4724
4974
  registerPlanCommands(program);
4725
4975
  registerStatusCommand(program);
4726
4976
  registerAuditCommand(program);
4977
+ registerMigrateCommand(program);
4727
4978
  registerRunAlias(program);
4728
4979
  return program;
4729
4980
  }