@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 +6 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/skill/scripts/forge +1 -1
- package/src/config/schema-compile.mjs +6 -4
- package/src/session/facts.mjs +18 -4
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
package/package.json
CHANGED
package/skill/scripts/forge
CHANGED
|
@@ -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.
|
|
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
|
|
153
|
-
* nullable
|
|
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 = {};
|
package/src/session/facts.mjs
CHANGED
|
@@ -777,7 +777,8 @@ function ruleBroken(row, claim, ctx) {
|
|
|
777
777
|
}
|
|
778
778
|
|
|
779
779
|
/**
|
|
780
|
-
* Validate the delegate's answer (step 3).
|
|
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
|
-
|
|
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[]}} */ (
|
|
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
|
|
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 });
|