@codedology/code-forge 0.15.1 → 0.15.2

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/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.15.2] — 2026-10-30
10
+
11
+ ### Fixed
12
+
13
+ - OpenAI strict schemas make only optional keys nullable, so `forge facts` no longer refuses an answer with null on a required field; a facts row with a null command or excerpt is downgraded to UNVERIFIABLE on its own (issue #27, B165)
14
+
9
15
  ## [0.15.1] — 2026-10-19
10
16
 
11
17
  ### Changed
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.
4
4
 
5
- Latest: 0.15.1 - see [CHANGELOG.md](CHANGELOG.md)
5
+ Latest: 0.15.2 - see [CHANGELOG.md](CHANGELOG.md)
6
6
 
7
7
  ## The problem
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.15.1"
13
+ PINNED_VERSION="0.15.2"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * OpenAI's structured-output "strict" mode has three rules a hand-written schema violates by
6
6
  * default: (1) every property under an object must be listed in `required` (optional fields are
7
- * instead modeled as **nullable** required fields), (2) every object must set
7
+ * instead modeled as **nullable** required fields; source-required keys stay as they are), (2) every object must set
8
8
  * `additionalProperties: false`, (3) nothing may be missing from `required`. Claude and Grok take
9
9
  * the source schema unchanged (they support ordinary JSON Schema `required`/optional).
10
10
  *
@@ -149,8 +149,9 @@ function childSchemas(node) {
149
149
  }
150
150
 
151
151
  /**
152
- * Recursively rewrites every object node: a `properties` map makes every property `required` and
153
- * nullable and closes the node (unless it already has an open-ended `additionalProperties`); a
152
+ * Recursively rewrites every object node: a `properties` map makes every property `required`;
153
+ * optional keys become nullable; required keys stay as they are. It closes the node (unless it
154
+ * already has an open-ended `additionalProperties`); a
154
155
  * bare `{type: 'object'}` is closed to an empty strict object. `visited` guards against revisiting
155
156
  * the same shared node twice — not against a genuine schema cycle, which this project's schema
156
157
  * does not have.
@@ -165,12 +166,13 @@ function compileForOpenAI(node, visited) {
165
166
 
166
167
  if (node.properties && typeof node.properties === 'object') {
167
168
  const keys = Object.keys(node.properties);
169
+ const wasRequired = new Set(Array.isArray(node.required) ? node.required : []);
168
170
  node.required = keys;
169
171
  if (node.additionalProperties === undefined) {
170
172
  node.additionalProperties = false;
171
173
  }
172
174
  for (const key of keys) {
173
- makeNullable(node.properties[key]);
175
+ if (!wasRequired.has(key)) makeNullable(node.properties[key]);
174
176
  }
175
177
  } else if (isObjectTyped(node) && node.additionalProperties === undefined && node.patternProperties === undefined) {
176
178
  node.properties = {};
@@ -777,7 +777,8 @@ function ruleBroken(row, claim, ctx) {
777
777
  }
778
778
 
779
779
  /**
780
- * Validate the delegate's answer (step 3). Only a schema-invalid answer is refused. A row answers
780
+ * Validate the delegate's answer (step 3). Null commands/excerpts are normalized on a copy and
781
+ * their matched claims downgraded; every other schema-invalid answer is refused. A row answers
781
782
  * claim `F<n>` when its fact_id is `F<n>` and its kind is the claim's kind (the id is the anchor,
782
783
  * the claim text may be reworded); failing that, when its echoed claim ({@link echoedClaim})
783
784
  * equals the claim token and its fact_id is `F<n>` or its kind is the claim's kind. Claims the
@@ -794,11 +795,23 @@ function ruleBroken(row, claim, ctx) {
794
795
  * @throws {FactsError} `refused` when the answer does not match the schema.
795
796
  */
796
797
  export function validateFacts(answer, claims, opts = {}) {
797
- if (!validateFile(answer)) {
798
+ const normalized = structuredClone(answer);
799
+ /** @type {Map<object, string[]>} */
800
+ const nullFields = new Map();
801
+ if (normalized && typeof normalized === 'object' && 'facts' in normalized && Array.isArray(normalized.facts)) {
802
+ for (const row of normalized.facts) {
803
+ if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
804
+ const fields = ['command', 'output_excerpt'].filter((field) => row[field] === null);
805
+ if (fields.length === 0) continue;
806
+ for (const field of fields) row[field] = '';
807
+ nullFields.set(row, fields);
808
+ }
809
+ }
810
+ if (!validateFile(normalized)) {
798
811
  const where = (validateFile.errors ?? []).map((e) => `${e.instancePath || '/'} ${e.message}`).slice(0, 5);
799
812
  throw new FactsError('refused', `facts answer does not match facts.schema.json: ${where.join('; ')}`);
800
813
  }
801
- const rows = /** @type {{facts: Fact[]}} */ (answer).facts;
814
+ const rows = /** @type {{facts: Fact[]}} */ (normalized).facts;
802
815
  /** @type {Fact[]} */
803
816
  const facts = [];
804
817
  /** @type {Downgrade[]} */
@@ -828,7 +841,8 @@ export function validateFacts(answer, claims, opts = {}) {
828
841
  return;
829
842
  }
830
843
  const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
831
- const broken = ruleBroken(row, claim, opts);
844
+ const fields = nullFields.get(row);
845
+ const broken = fields ? `delegate returned null ${fields.join(', ')}` : ruleBroken(row, claim, opts);
832
846
  if (broken) {
833
847
  const reason = redact(broken);
834
848
  downgraded.push({ fact_id: id, reason });