@orkestrel/brief 0.0.1
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/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/src/core/index.cjs +2219 -0
- package/dist/src/core/index.cjs.map +1 -0
- package/dist/src/core/index.d.cts +1911 -0
- package/dist/src/core/index.d.ts +1911 -0
- package/dist/src/core/index.js +2129 -0
- package/dist/src/core/index.js.map +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1,2129 @@
|
|
|
1
|
+
import { andOf, arrayOf, arrayShape, attempt, booleanShape, boundsOf, cloneJSONRecord, createContract, integerShape, isBoolean, isInteger, isNonEmptyString, isNumber, isString, literalOf, literalShape, objectShape, optionalShape, parseJSONAs, recordOf, stringShape } from "@orkestrel/contract";
|
|
2
|
+
import { canonicalize, collapseWhitespace, createInterpret, digestValue } from "@orkestrel/interpret";
|
|
3
|
+
import { atom, compound, createLogicalReasoner, createReason, formatField, logicalDefinition, rule } from "@orkestrel/reason";
|
|
4
|
+
import { Emitter } from "@orkestrel/emitter";
|
|
5
|
+
//#region src/core/constants.ts
|
|
6
|
+
/** The twelve `TaskOperation` values, frozen. */
|
|
7
|
+
var TASK_OPERATIONS = Object.freeze([
|
|
8
|
+
"create",
|
|
9
|
+
"refactor",
|
|
10
|
+
"debug",
|
|
11
|
+
"extract",
|
|
12
|
+
"migrate",
|
|
13
|
+
"explain",
|
|
14
|
+
"review",
|
|
15
|
+
"optimize",
|
|
16
|
+
"audit",
|
|
17
|
+
"test",
|
|
18
|
+
"document",
|
|
19
|
+
"plan"
|
|
20
|
+
]);
|
|
21
|
+
/** The eight `TaskDomain` values, frozen. */
|
|
22
|
+
var TASK_DOMAINS = Object.freeze([
|
|
23
|
+
"code",
|
|
24
|
+
"writing",
|
|
25
|
+
"research",
|
|
26
|
+
"analysis",
|
|
27
|
+
"design",
|
|
28
|
+
"data",
|
|
29
|
+
"ops",
|
|
30
|
+
"other"
|
|
31
|
+
]);
|
|
32
|
+
/** The five `OutputFormat` values, frozen. */
|
|
33
|
+
var OUTPUT_FORMATS = Object.freeze([
|
|
34
|
+
"markdown",
|
|
35
|
+
"json",
|
|
36
|
+
"code",
|
|
37
|
+
"diff",
|
|
38
|
+
"prose"
|
|
39
|
+
]);
|
|
40
|
+
/** The three `RiskSeverity` values, frozen. */
|
|
41
|
+
var RISK_SEVERITIES = Object.freeze([
|
|
42
|
+
"low",
|
|
43
|
+
"medium",
|
|
44
|
+
"high"
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* `16` — the default turn cap `briefToGoal` renders.
|
|
48
|
+
*
|
|
49
|
+
* @remarks
|
|
50
|
+
* Domain-qualified so the barrel stays collision-free as sibling modules add their own
|
|
51
|
+
* turn defaults.
|
|
52
|
+
*/
|
|
53
|
+
var DEFAULT_BRIEF_TURNS = 16;
|
|
54
|
+
/** `'gate'` — the id of the `gateDefinition()` logical definition. */
|
|
55
|
+
var GATE_ID = "gate";
|
|
56
|
+
/**
|
|
57
|
+
* Every line terminator a brief field refuses.
|
|
58
|
+
*
|
|
59
|
+
* @remarks
|
|
60
|
+
* The four ECMAScript line terminators, not just `\n`: a renderer that splits on any of
|
|
61
|
+
* them would let the other three forge a markdown row. CRLF leads the alternation so a
|
|
62
|
+
* Windows exemplar splits as ONE break rather than two, which would insert a blank line the
|
|
63
|
+
* caller never wrote. Kept unanchored and stateless — no `g` flag — so `test` never carries
|
|
64
|
+
* `lastIndex` between calls.
|
|
65
|
+
*/
|
|
66
|
+
var LINE_BREAK_PATTERN = /\r\n|[\n\r\u2028\u2029]/;
|
|
67
|
+
/**
|
|
68
|
+
* The positive form of {@link LINE_BREAK_PATTERN}, for the shape DSL.
|
|
69
|
+
*
|
|
70
|
+
* @remarks
|
|
71
|
+
* `stringShape`'s `pattern` must MATCH an accepted value, so the guard's refusal regex
|
|
72
|
+
* cannot be reused directly. Both are derived from one character class, which is what
|
|
73
|
+
* keeps the hand-composed guards and the compiled shapes refusing the same strings.
|
|
74
|
+
*/
|
|
75
|
+
var SINGLE_LINE_PATTERN = /^[^\n\r\u2028\u2029]*$/;
|
|
76
|
+
/**
|
|
77
|
+
* A string of one or more spaces and nothing else.
|
|
78
|
+
*
|
|
79
|
+
* @remarks
|
|
80
|
+
* The one exemplar side `exampleToLines` must NOT pad. CommonMark strips a fully-blank code
|
|
81
|
+
* span to nothing rather than one space from each end, so padding inflates an all-space value
|
|
82
|
+
* while every other value needs the pad to keep its own boundary spaces.
|
|
83
|
+
*
|
|
84
|
+
* `+` rather than `*`, because the EMPTY string is not that case: it has no spaces to
|
|
85
|
+
* preserve, and withholding the pad emitted an empty backtick run that does not close.
|
|
86
|
+
*/
|
|
87
|
+
var BLANK_PATTERN = /^ +$/;
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/core/errors.ts
|
|
90
|
+
/**
|
|
91
|
+
* The one error class this package throws.
|
|
92
|
+
*
|
|
93
|
+
* @remarks
|
|
94
|
+
* Throws are reserved for caller misuse: `assertBrief`, `snapshotBrief`, and `pinBrief` on
|
|
95
|
+
* off-contract data throw `INVALID`; any method after `destroy()` throws `DESTROYED`; and `BriefCompiler.gate` throws
|
|
96
|
+
* `GATE_FAILED` when a borrowed reasoner returns a non-logical result. A stage that fails
|
|
97
|
+
* inside `compile` is CONTAINED as a `BriefStageFailure` on the `Briefing` instead.
|
|
98
|
+
*
|
|
99
|
+
* @example
|
|
100
|
+
* ```ts
|
|
101
|
+
* import { BriefError } from '@orkestrel/brief'
|
|
102
|
+
*
|
|
103
|
+
* const error = new BriefError('INVALID', 'Brief failed the exact-record contract', {
|
|
104
|
+
* field: 'proofs',
|
|
105
|
+
* })
|
|
106
|
+
* error.code // 'INVALID'
|
|
107
|
+
* error.context // { field: 'proofs' }
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
var BriefError = class extends Error {
|
|
111
|
+
code;
|
|
112
|
+
context;
|
|
113
|
+
constructor(code, message, context) {
|
|
114
|
+
super(message);
|
|
115
|
+
this.name = "BriefError";
|
|
116
|
+
this.code = code;
|
|
117
|
+
if (context !== void 0) this.context = context;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Narrow a caught value to a {@link BriefError}.
|
|
122
|
+
*
|
|
123
|
+
* @param value - The caught value to inspect.
|
|
124
|
+
* @returns `true` when `value` is a `BriefError`.
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* import { BriefError, isBriefError } from '@orkestrel/brief'
|
|
129
|
+
*
|
|
130
|
+
* try {
|
|
131
|
+
* throw new BriefError('DESTROYED', 'BriefCompiler has been destroyed')
|
|
132
|
+
* } catch (error) {
|
|
133
|
+
* if (isBriefError(error)) error.code // 'DESTROYED'
|
|
134
|
+
* }
|
|
135
|
+
* ```
|
|
136
|
+
*/
|
|
137
|
+
function isBriefError(value) {
|
|
138
|
+
return value instanceof BriefError;
|
|
139
|
+
}
|
|
140
|
+
//#endregion
|
|
141
|
+
//#region src/core/shapers.ts
|
|
142
|
+
/** A single-line string of any length, including empty. */
|
|
143
|
+
var textShape = stringShape({ pattern: SINGLE_LINE_PATTERN });
|
|
144
|
+
/** A non-empty single-line string — the shape mirror of `isLine`. */
|
|
145
|
+
var lineShape = stringShape({
|
|
146
|
+
min: 1,
|
|
147
|
+
pattern: SINGLE_LINE_PATTERN
|
|
148
|
+
});
|
|
149
|
+
/** The `Task` shape — closed operation and domain vocabularies plus a non-empty statement. */
|
|
150
|
+
var taskShape = objectShape({
|
|
151
|
+
operation: literalShape(TASK_OPERATIONS),
|
|
152
|
+
domain: literalShape(TASK_DOMAINS),
|
|
153
|
+
statement: lineShape
|
|
154
|
+
}, { description: "What the brief asks for, in one imperative sentence." });
|
|
155
|
+
/** The `Reference` shape — a path and the note that justifies listing it. */
|
|
156
|
+
var referenceShape = objectShape({
|
|
157
|
+
path: lineShape,
|
|
158
|
+
note: lineShape
|
|
159
|
+
}, { description: "One referenced path and why it is listed." });
|
|
160
|
+
/** The `Manifest` shape — four disjoint reference partitions. */
|
|
161
|
+
var manifestShape = objectShape({
|
|
162
|
+
read: arrayShape(referenceShape),
|
|
163
|
+
edit: arrayShape(referenceShape),
|
|
164
|
+
locked: arrayShape(referenceShape),
|
|
165
|
+
forbidden: arrayShape(referenceShape)
|
|
166
|
+
}, { description: "The four disjoint file partitions of a brief." });
|
|
167
|
+
/** The `Outcome` shape — a one-based rank, the result text, and whether it gates done. */
|
|
168
|
+
var outcomeShape = objectShape({
|
|
169
|
+
rank: integerShape({ min: 1 }),
|
|
170
|
+
text: lineShape,
|
|
171
|
+
required: booleanShape()
|
|
172
|
+
}, { description: "One ranked outcome — a result, never a step." });
|
|
173
|
+
/** The `Given` shape — one categorized context fact. */
|
|
174
|
+
var givenShape = objectShape({
|
|
175
|
+
category: lineShape,
|
|
176
|
+
name: lineShape,
|
|
177
|
+
value: textShape
|
|
178
|
+
}, { description: "One context fact handed to the executor." });
|
|
179
|
+
/** The `Example` shape — one input to output exemplar. */
|
|
180
|
+
var exampleShape = objectShape({
|
|
181
|
+
input: stringShape({ min: 1 }),
|
|
182
|
+
output: stringShape({ min: 1 }),
|
|
183
|
+
note: optionalShape(lineShape)
|
|
184
|
+
}, { description: "One input to output exemplar." });
|
|
185
|
+
/** The `Citation` shape — a name, a locator, and why the source is cited. */
|
|
186
|
+
var citationShape = objectShape({
|
|
187
|
+
name: lineShape,
|
|
188
|
+
url: lineShape,
|
|
189
|
+
note: lineShape
|
|
190
|
+
}, { description: "One external source; list order is the trust order." });
|
|
191
|
+
/** The `Gap` shape — an unknown, whether it blocks, and the candidates that would close it. */
|
|
192
|
+
var gapShape = objectShape({
|
|
193
|
+
field: lineShape,
|
|
194
|
+
question: lineShape,
|
|
195
|
+
blocking: booleanShape(),
|
|
196
|
+
candidates: optionalShape(arrayShape(lineShape))
|
|
197
|
+
}, { description: "One unresolved decision; blocking means the gate fails closed." });
|
|
198
|
+
/** The `Risk` shape — a closed severity, the risk, and its mitigation. */
|
|
199
|
+
var riskShape = objectShape({
|
|
200
|
+
severity: literalShape(RISK_SEVERITIES),
|
|
201
|
+
text: lineShape,
|
|
202
|
+
mitigation: lineShape
|
|
203
|
+
}, { description: "One pre-empted risk and the mitigation that answers it." });
|
|
204
|
+
/** The `Output` shape — a closed format plus its optional refinements. */
|
|
205
|
+
var outputShape = objectShape({
|
|
206
|
+
format: literalShape(OUTPUT_FORMATS),
|
|
207
|
+
sections: optionalShape(arrayShape(lineShape)),
|
|
208
|
+
include: optionalShape(arrayShape(lineShape)),
|
|
209
|
+
exclude: optionalShape(arrayShape(lineShape))
|
|
210
|
+
}, { description: "The closed shape of the deliverable." });
|
|
211
|
+
/** The `Proof` shape — the claim and the command that settles it. */
|
|
212
|
+
var proofShape = objectShape({
|
|
213
|
+
text: lineShape,
|
|
214
|
+
command: lineShape
|
|
215
|
+
}, { description: "One mechanical, transcript-provable check." });
|
|
216
|
+
/**
|
|
217
|
+
* The whole `Brief` shape, section shapes composed.
|
|
218
|
+
*
|
|
219
|
+
* @remarks
|
|
220
|
+
* `trace` and `hash` are optional because `pinBrief` fills them; an unpinned draft is
|
|
221
|
+
* on-contract without them.
|
|
222
|
+
*/
|
|
223
|
+
var briefShape = objectShape({
|
|
224
|
+
task: taskShape,
|
|
225
|
+
authority: arrayShape(referenceShape),
|
|
226
|
+
manifest: manifestShape,
|
|
227
|
+
outcomes: arrayShape(outcomeShape),
|
|
228
|
+
rules: arrayShape(lineShape),
|
|
229
|
+
invariants: arrayShape(lineShape),
|
|
230
|
+
givens: arrayShape(givenShape),
|
|
231
|
+
examples: arrayShape(exampleShape),
|
|
232
|
+
assumptions: arrayShape(lineShape),
|
|
233
|
+
citations: arrayShape(citationShape),
|
|
234
|
+
gaps: arrayShape(gapShape),
|
|
235
|
+
risks: arrayShape(riskShape),
|
|
236
|
+
output: outputShape,
|
|
237
|
+
proofs: arrayShape(proofShape),
|
|
238
|
+
trace: optionalShape(lineShape),
|
|
239
|
+
hash: optionalShape(lineShape)
|
|
240
|
+
}, { description: "The closed execution contract one agent can run with no interpretation left." });
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/core/validators.ts
|
|
243
|
+
/**
|
|
244
|
+
* `true` when the value is a string holding no line terminator, empty included.
|
|
245
|
+
*
|
|
246
|
+
* @remarks
|
|
247
|
+
* `briefToMarkdown` renders each brief field as ONE markdown row, so a field carrying a
|
|
248
|
+
* line break would forge a heading or an extra manifest row — which is how a rendered
|
|
249
|
+
* prompt and `briefToDispatch`'s path sets could disagree about the same brief.
|
|
250
|
+
*/
|
|
251
|
+
var isText = (value) => isString(value) && !LINE_BREAK_PATTERN.test(value);
|
|
252
|
+
/** `true` when the value is a non-empty string holding no line terminator. */
|
|
253
|
+
var isLine = andOf(isNonEmptyString, isText);
|
|
254
|
+
/** `true` when the value is one of the twelve `TaskOperation` literals. */
|
|
255
|
+
var isTaskOperation = literalOf(TASK_OPERATIONS);
|
|
256
|
+
/** `true` when the value is one of the eight `TaskDomain` literals. */
|
|
257
|
+
var isTaskDomain = literalOf(TASK_DOMAINS);
|
|
258
|
+
/** `true` when the value is one of the five `OutputFormat` literals. */
|
|
259
|
+
var isOutputFormat = literalOf(OUTPUT_FORMATS);
|
|
260
|
+
/** `true` when the value is one of the three `RiskSeverity` literals. */
|
|
261
|
+
var isRiskSeverity = literalOf(RISK_SEVERITIES);
|
|
262
|
+
/**
|
|
263
|
+
* `true` when the value is a non-null object whose named members can be read.
|
|
264
|
+
*
|
|
265
|
+
* @remarks
|
|
266
|
+
* Wider than the contract package's plain-record guard, which refuses any object carrying its
|
|
267
|
+
* own prototype — a class instance among them. The verdict guards below narrow FOREIGN
|
|
268
|
+
* interfaces, and an
|
|
269
|
+
* interface is satisfied by a class instance as readily as by a literal — refusing one is the
|
|
270
|
+
* same narrowing-past-the-contract mistake that made an exact-record verdict guard fail the
|
|
271
|
+
* gate closed on a valid engine.
|
|
272
|
+
*
|
|
273
|
+
* Arrays are excluded because no interface this narrows is an array, and admitting one would
|
|
274
|
+
* let index access stand in for member access.
|
|
275
|
+
*/
|
|
276
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
277
|
+
/**
|
|
278
|
+
* `true` when the value is a well-formed reasons `RuleResult`.
|
|
279
|
+
*
|
|
280
|
+
* @remarks
|
|
281
|
+
* `@orkestrel/reason` publishes the type but no guard for it, and `BriefCompiler` reads these
|
|
282
|
+
* off a BORROWED engine's return value, so the shape has to be checked rather than trusted.
|
|
283
|
+
*/
|
|
284
|
+
var isRuleVerdict = (value) => isObject(value) && isNonEmptyString(value["id"]) && isBoolean(value["applied"]) && arrayOf(isBoolean)(value["premises"]) && isBoolean(value["conclusion"]);
|
|
285
|
+
/**
|
|
286
|
+
* `true` when the value is a well-formed reasons `LogicalResult`.
|
|
287
|
+
*
|
|
288
|
+
* @remarks
|
|
289
|
+
* The gate's reasoner is supplied by the caller through `BriefCompilerOptions.reason`, so its
|
|
290
|
+
* return value is FOREIGN data no matter how well-typed the interface is. `BriefCompiler`
|
|
291
|
+
* dereferences `reasoning`, `conclusion`, and `rules`; checking one field left a malformed
|
|
292
|
+
* result to throw a raw `TypeError` out of `compile`, from the very code that contains stage
|
|
293
|
+
* failures. Total: returns `false` for `undefined`, `null`, and every off-shape value.
|
|
294
|
+
*
|
|
295
|
+
* Checks the WHOLE published shape rather than only the three members read today, because a
|
|
296
|
+
* guard that narrows to `LogicalResult` while ignoring four of its members is unsound.
|
|
297
|
+
*
|
|
298
|
+
* OPEN on unknown keys, deliberately. The exact-record combinator this file uses elsewhere
|
|
299
|
+
* refuses a value a FOREIGN interface permits: `LogicalResult` is a TypeScript interface,
|
|
300
|
+
* so a conforming reasoner returning a richer result is still returning a `LogicalResult`. An
|
|
301
|
+
* exact check refused it and failed the gate closed on a valid engine — trading a loud crash
|
|
302
|
+
* for a wrong refusal, which is the worse of the two. Exactness belongs on records this
|
|
303
|
+
* package OWNS, where an extra key means the caller misunderstood the contract.
|
|
304
|
+
*
|
|
305
|
+
* `count` is checked as a number rather than an integer for the same reason: the published
|
|
306
|
+
* type says `number`, and narrowing past a foreign contract is the same mistake.
|
|
307
|
+
*/
|
|
308
|
+
var isLogicalVerdict = (value) => isObject(value) && value["reasoning"] === "logical" && isBoolean(value["conclusion"]) && arrayOf(isRuleVerdict)(value["rules"]) && isNumber(value["count"]) && isBoolean(value["success"]) && arrayOf(isString)(value["trace"]) && arrayOf(isString)(value["errors"]);
|
|
309
|
+
/** `true` when the value is a well-formed `Task` — both vocabularies closed, statement one line. */
|
|
310
|
+
var isTask = recordOf({
|
|
311
|
+
operation: isTaskOperation,
|
|
312
|
+
domain: isTaskDomain,
|
|
313
|
+
statement: isLine
|
|
314
|
+
});
|
|
315
|
+
/** `true` when the value is a well-formed `Reference` — both members required, both single-line. */
|
|
316
|
+
var isReference = recordOf({
|
|
317
|
+
path: isLine,
|
|
318
|
+
note: isLine
|
|
319
|
+
});
|
|
320
|
+
/**
|
|
321
|
+
* `true` when the value is a well-formed `Manifest`.
|
|
322
|
+
*
|
|
323
|
+
* @remarks
|
|
324
|
+
* Partition presence only — disjointness is `validateBrief`'s semantic pass.
|
|
325
|
+
*/
|
|
326
|
+
var isManifest = recordOf({
|
|
327
|
+
read: arrayOf(isReference),
|
|
328
|
+
edit: arrayOf(isReference),
|
|
329
|
+
locked: arrayOf(isReference),
|
|
330
|
+
forbidden: arrayOf(isReference)
|
|
331
|
+
});
|
|
332
|
+
/** `true` when the value is a well-formed `Outcome` — `rank` a positive integer. */
|
|
333
|
+
var isOutcome = recordOf({
|
|
334
|
+
rank: andOf(isInteger, boundsOf(1)),
|
|
335
|
+
text: isLine,
|
|
336
|
+
required: isBoolean
|
|
337
|
+
});
|
|
338
|
+
/** `true` when the value is a well-formed `Given` — `value` may be empty but stays one line. */
|
|
339
|
+
var isGiven = recordOf({
|
|
340
|
+
category: isLine,
|
|
341
|
+
name: isLine,
|
|
342
|
+
value: isText
|
|
343
|
+
});
|
|
344
|
+
/**
|
|
345
|
+
* `true` when the value is a well-formed `Example`.
|
|
346
|
+
*
|
|
347
|
+
* @remarks
|
|
348
|
+
* An exemplar's two sides are the ONLY members a brief lets span lines, because they
|
|
349
|
+
* carry code. `briefToMarkdown` fences them rather than rendering them as a row.
|
|
350
|
+
*/
|
|
351
|
+
var isExample = recordOf({
|
|
352
|
+
input: isNonEmptyString,
|
|
353
|
+
output: isNonEmptyString,
|
|
354
|
+
note: isLine
|
|
355
|
+
}, ["note"]);
|
|
356
|
+
/** `true` when the value is a well-formed `Citation` — all three members single-line. */
|
|
357
|
+
var isCitation = recordOf({
|
|
358
|
+
name: isLine,
|
|
359
|
+
url: isLine,
|
|
360
|
+
note: isLine
|
|
361
|
+
});
|
|
362
|
+
/** `true` when the value is a well-formed `Gap`. */
|
|
363
|
+
var isGap = recordOf({
|
|
364
|
+
field: isLine,
|
|
365
|
+
question: isLine,
|
|
366
|
+
blocking: isBoolean,
|
|
367
|
+
candidates: arrayOf(isLine)
|
|
368
|
+
}, ["candidates"]);
|
|
369
|
+
/** `true` when the value is a well-formed `Risk` — `severity` on the closed vocabulary. */
|
|
370
|
+
var isRisk = recordOf({
|
|
371
|
+
severity: isRiskSeverity,
|
|
372
|
+
text: isLine,
|
|
373
|
+
mitigation: isLine
|
|
374
|
+
});
|
|
375
|
+
/** `true` when the value is a well-formed `Output` — `format` on the closed vocabulary. */
|
|
376
|
+
var isOutput = recordOf({
|
|
377
|
+
format: isOutputFormat,
|
|
378
|
+
sections: arrayOf(isLine),
|
|
379
|
+
include: arrayOf(isLine),
|
|
380
|
+
exclude: arrayOf(isLine)
|
|
381
|
+
}, [
|
|
382
|
+
"sections",
|
|
383
|
+
"include",
|
|
384
|
+
"exclude"
|
|
385
|
+
]);
|
|
386
|
+
/** `true` when the value is a well-formed `Proof`. */
|
|
387
|
+
var isProof = recordOf({
|
|
388
|
+
text: isLine,
|
|
389
|
+
command: isLine
|
|
390
|
+
});
|
|
391
|
+
/**
|
|
392
|
+
* `true` when the value satisfies the whole exact-record `Brief` contract.
|
|
393
|
+
*
|
|
394
|
+
* @remarks
|
|
395
|
+
* Every section must be present; an extra key fails. `trace` and `hash` are the only
|
|
396
|
+
* optional members, because `pinBrief` rather than the author fills them.
|
|
397
|
+
*/
|
|
398
|
+
var isBrief = recordOf({
|
|
399
|
+
task: isTask,
|
|
400
|
+
authority: arrayOf(isReference),
|
|
401
|
+
manifest: isManifest,
|
|
402
|
+
outcomes: arrayOf(isOutcome),
|
|
403
|
+
rules: arrayOf(isLine),
|
|
404
|
+
invariants: arrayOf(isLine),
|
|
405
|
+
givens: arrayOf(isGiven),
|
|
406
|
+
examples: arrayOf(isExample),
|
|
407
|
+
assumptions: arrayOf(isLine),
|
|
408
|
+
citations: arrayOf(isCitation),
|
|
409
|
+
gaps: arrayOf(isGap),
|
|
410
|
+
risks: arrayOf(isRisk),
|
|
411
|
+
output: isOutput,
|
|
412
|
+
proofs: arrayOf(isProof),
|
|
413
|
+
trace: isLine,
|
|
414
|
+
hash: isLine
|
|
415
|
+
}, ["trace", "hash"]);
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/core/cloners.ts
|
|
418
|
+
/**
|
|
419
|
+
* Return a deeply owned, deeply frozen copy of a brief, refusing anything off-contract.
|
|
420
|
+
*
|
|
421
|
+
* @remarks
|
|
422
|
+
* The one reading boundary this package has, used by the pin, the registry, and every
|
|
423
|
+
* projection. It matters twice over. A brief built from caller collections ADOPTS those
|
|
424
|
+
* arrays, so a later `outcomes.push` would change content a hash already described. And a
|
|
425
|
+
* caller's object may answer differently on each read, so validating one reading and
|
|
426
|
+
* rendering from a second let a brief that passed the contract render a row it does not
|
|
427
|
+
* contain — this takes ONE reading, validates that, and freezes it.
|
|
428
|
+
*
|
|
429
|
+
* `cloneJSONRecord` is `@orkestrel/contract`'s primitive rather than the ambient
|
|
430
|
+
* `structuredClone`: it deep-freezes, it refuses a value JSON cannot express, and it is a
|
|
431
|
+
* captured import rather than a mutable global. The result is a null-prototype record, so
|
|
432
|
+
* compare it structurally rather than by prototype.
|
|
433
|
+
*
|
|
434
|
+
* This file imports no sibling helper, which is what lets `helpers.ts` consume it without a
|
|
435
|
+
* module cycle.
|
|
436
|
+
*
|
|
437
|
+
* @param source - The brief to snapshot.
|
|
438
|
+
* @returns A deeply frozen `Brief` sharing no reference with `source`.
|
|
439
|
+
* @throws {@link BriefError} `INVALID` when the value is off-contract or JSON cannot express it.
|
|
440
|
+
*
|
|
441
|
+
* @example
|
|
442
|
+
* ```ts
|
|
443
|
+
* import { brief, outcome, snapshotBrief, task } from '@orkestrel/brief'
|
|
444
|
+
*
|
|
445
|
+
* const outcomes = [outcome(1, 'shipped')]
|
|
446
|
+
* const owned = snapshotBrief(brief(task('plan', 'ops', 'Plan the release.'), { outcomes }))
|
|
447
|
+
* owned.outcomes === outcomes // false — the alias is broken
|
|
448
|
+
* Object.isFrozen(owned.outcomes) // true
|
|
449
|
+
* ```
|
|
450
|
+
*/
|
|
451
|
+
function snapshotBrief(source) {
|
|
452
|
+
const owned = attempt(() => cloneJSONRecord(source));
|
|
453
|
+
if (!owned.success || !isBrief(owned.value)) throw new BriefError("INVALID", "Brief carries data that cannot be read as one value", { field: "brief" });
|
|
454
|
+
return owned.value;
|
|
455
|
+
}
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/core/helpers.ts
|
|
458
|
+
/**
|
|
459
|
+
* Build a `Task`.
|
|
460
|
+
*
|
|
461
|
+
* @param operation - What the brief asks for, from the closed operation vocabulary.
|
|
462
|
+
* @param domain - The subject matter, from the closed domain vocabulary.
|
|
463
|
+
* @param statement - One imperative sentence naming the object of the work.
|
|
464
|
+
* @returns A fresh `Task`.
|
|
465
|
+
*
|
|
466
|
+
* @example
|
|
467
|
+
* ```ts
|
|
468
|
+
* import { task } from '@orkestrel/brief'
|
|
469
|
+
*
|
|
470
|
+
* task('refactor', 'code', 'Refactor useForm to native browser form APIs.')
|
|
471
|
+
* ```
|
|
472
|
+
*/
|
|
473
|
+
function task(operation, domain, statement) {
|
|
474
|
+
return {
|
|
475
|
+
operation,
|
|
476
|
+
domain,
|
|
477
|
+
statement
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Build a `Reference`.
|
|
482
|
+
*
|
|
483
|
+
* @param path - The referenced path or glob.
|
|
484
|
+
* @param note - Why the path is listed.
|
|
485
|
+
* @returns A fresh `Reference`.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* ```ts
|
|
489
|
+
* import { reference } from '@orkestrel/brief'
|
|
490
|
+
*
|
|
491
|
+
* reference('AGENTS.md', 'project law') // { path: 'AGENTS.md', note: 'project law' }
|
|
492
|
+
* ```
|
|
493
|
+
*/
|
|
494
|
+
function reference(path, note) {
|
|
495
|
+
return {
|
|
496
|
+
path,
|
|
497
|
+
note
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Build a `Manifest`, defaulting every absent partition to an empty list.
|
|
502
|
+
*
|
|
503
|
+
* @param partitions - The partitions to fill; a partial literal is enough.
|
|
504
|
+
* @returns A fresh `Manifest` with all four partitions present.
|
|
505
|
+
*
|
|
506
|
+
* @example
|
|
507
|
+
* ```ts
|
|
508
|
+
* import { manifest, reference } from '@orkestrel/brief'
|
|
509
|
+
*
|
|
510
|
+
* manifest({ edit: [reference('src/core/helpers.ts', 'implementation')] })
|
|
511
|
+
* ```
|
|
512
|
+
*/
|
|
513
|
+
function manifest(partitions) {
|
|
514
|
+
return {
|
|
515
|
+
read: partitions?.read ?? [],
|
|
516
|
+
edit: partitions?.edit ?? [],
|
|
517
|
+
locked: partitions?.locked ?? [],
|
|
518
|
+
forbidden: partitions?.forbidden ?? []
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Build an `Outcome`.
|
|
523
|
+
*
|
|
524
|
+
* @param rank - The one-based rank; lower ranks matter more.
|
|
525
|
+
* @param text - The result, never a step.
|
|
526
|
+
* @param required - Whether the outcome gates "done"; defaults to `true`.
|
|
527
|
+
* @returns A fresh `Outcome`.
|
|
528
|
+
*
|
|
529
|
+
* @example
|
|
530
|
+
* ```ts
|
|
531
|
+
* import { outcome } from '@orkestrel/brief'
|
|
532
|
+
*
|
|
533
|
+
* outcome(1, 'useForm uses native FormData with no behavior change') // required: true
|
|
534
|
+
* outcome(2, 'the diff stays under 200 lines', false)
|
|
535
|
+
* ```
|
|
536
|
+
*/
|
|
537
|
+
function outcome(rank, text, required = true) {
|
|
538
|
+
return {
|
|
539
|
+
rank,
|
|
540
|
+
text,
|
|
541
|
+
required
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Build a `Given`.
|
|
546
|
+
*
|
|
547
|
+
* @param category - The kind of fact — a convention, a version, a constraint.
|
|
548
|
+
* @param name - The fact's name.
|
|
549
|
+
* @param value - The fact's value, already rendered as text.
|
|
550
|
+
* @returns A fresh `Given`.
|
|
551
|
+
*
|
|
552
|
+
* @example
|
|
553
|
+
* ```ts
|
|
554
|
+
* import { given } from '@orkestrel/brief'
|
|
555
|
+
*
|
|
556
|
+
* given('convention', 'indentation', 'tabs')
|
|
557
|
+
* ```
|
|
558
|
+
*/
|
|
559
|
+
function given(category, name, value) {
|
|
560
|
+
return {
|
|
561
|
+
category,
|
|
562
|
+
name,
|
|
563
|
+
value
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Build an `Example`.
|
|
568
|
+
*
|
|
569
|
+
* @param input - The exemplar input.
|
|
570
|
+
* @param result - The expected output for that input.
|
|
571
|
+
* @param note - Optional detail; the key is OMITTED when absent.
|
|
572
|
+
* @returns A fresh `Example`.
|
|
573
|
+
*
|
|
574
|
+
* @example
|
|
575
|
+
* ```ts
|
|
576
|
+
* import { example } from '@orkestrel/brief'
|
|
577
|
+
*
|
|
578
|
+
* example('<input required>', 'validity read from el.validity')
|
|
579
|
+
* ```
|
|
580
|
+
*/
|
|
581
|
+
function example(input, result, note) {
|
|
582
|
+
return note === void 0 ? {
|
|
583
|
+
input,
|
|
584
|
+
output: result
|
|
585
|
+
} : {
|
|
586
|
+
input,
|
|
587
|
+
output: result,
|
|
588
|
+
note
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Build a `Citation`.
|
|
593
|
+
*
|
|
594
|
+
* @param name - The source's display name.
|
|
595
|
+
* @param url - Where the source lives.
|
|
596
|
+
* @param note - Why the source is cited.
|
|
597
|
+
* @returns A fresh `Citation`.
|
|
598
|
+
*
|
|
599
|
+
* @example
|
|
600
|
+
* ```ts
|
|
601
|
+
* import { citation } from '@orkestrel/brief'
|
|
602
|
+
*
|
|
603
|
+
* citation(
|
|
604
|
+
* 'MDN Constraint Validation',
|
|
605
|
+
* 'https://developer.mozilla.org/',
|
|
606
|
+
* 'the native validity behavior being adopted',
|
|
607
|
+
* )
|
|
608
|
+
* ```
|
|
609
|
+
*/
|
|
610
|
+
function citation(name, url, note) {
|
|
611
|
+
return {
|
|
612
|
+
name,
|
|
613
|
+
url,
|
|
614
|
+
note
|
|
615
|
+
};
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Build a `Gap`.
|
|
619
|
+
*
|
|
620
|
+
* @param field - The brief section the unknown belongs to.
|
|
621
|
+
* @param question - The question that would close it.
|
|
622
|
+
* @param overrides - Optional `blocking` (defaults `false`) and `candidates`; an absent
|
|
623
|
+
* `candidates` key is OMITTED entirely.
|
|
624
|
+
* @returns A fresh `Gap`.
|
|
625
|
+
*
|
|
626
|
+
* @example
|
|
627
|
+
* ```ts
|
|
628
|
+
* import { gap } from '@orkestrel/brief'
|
|
629
|
+
*
|
|
630
|
+
* gap('rules', 'Should validation message wording change?') // blocking: false
|
|
631
|
+
* gap('output', 'Diff or full files?', { blocking: true, candidates: ['diff', 'code'] })
|
|
632
|
+
* ```
|
|
633
|
+
*/
|
|
634
|
+
function gap(field, question, overrides) {
|
|
635
|
+
const blocking = overrides?.blocking ?? false;
|
|
636
|
+
return overrides?.candidates === void 0 ? {
|
|
637
|
+
field,
|
|
638
|
+
question,
|
|
639
|
+
blocking
|
|
640
|
+
} : {
|
|
641
|
+
field,
|
|
642
|
+
question,
|
|
643
|
+
blocking,
|
|
644
|
+
candidates: overrides.candidates
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Build a `Risk`.
|
|
649
|
+
*
|
|
650
|
+
* @param severity - The closed severity.
|
|
651
|
+
* @param text - What could go wrong.
|
|
652
|
+
* @param mitigation - What answers it.
|
|
653
|
+
* @returns A fresh `Risk`.
|
|
654
|
+
*
|
|
655
|
+
* @example
|
|
656
|
+
* ```ts
|
|
657
|
+
* import { risk } from '@orkestrel/brief'
|
|
658
|
+
*
|
|
659
|
+
* risk('medium', 'native validation differs subtly', 'assert message and state in tests')
|
|
660
|
+
* ```
|
|
661
|
+
*/
|
|
662
|
+
function risk(severity, text, mitigation) {
|
|
663
|
+
return {
|
|
664
|
+
severity,
|
|
665
|
+
text,
|
|
666
|
+
mitigation
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Build an `Output`.
|
|
671
|
+
*
|
|
672
|
+
* @param format - The closed deliverable format.
|
|
673
|
+
* @param overrides - Optional `sections` / `include` / `exclude`; absent keys are OMITTED.
|
|
674
|
+
* @returns A fresh `Output`.
|
|
675
|
+
*
|
|
676
|
+
* @example
|
|
677
|
+
* ```ts
|
|
678
|
+
* import { output } from '@orkestrel/brief'
|
|
679
|
+
*
|
|
680
|
+
* output('markdown') // { format: 'markdown' }
|
|
681
|
+
* output('diff', { include: ['updated useForm.ts'] })
|
|
682
|
+
* ```
|
|
683
|
+
*/
|
|
684
|
+
function output(format, overrides) {
|
|
685
|
+
return {
|
|
686
|
+
format,
|
|
687
|
+
...overrides?.sections === void 0 ? {} : { sections: overrides.sections },
|
|
688
|
+
...overrides?.include === void 0 ? {} : { include: overrides.include },
|
|
689
|
+
...overrides?.exclude === void 0 ? {} : { exclude: overrides.exclude }
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Build a `Proof`.
|
|
694
|
+
*
|
|
695
|
+
* @param text - What the check settles.
|
|
696
|
+
* @param command - The command whose exit signal settles it.
|
|
697
|
+
* @returns A fresh `Proof`.
|
|
698
|
+
*
|
|
699
|
+
* @example
|
|
700
|
+
* ```ts
|
|
701
|
+
* import { proof } from '@orkestrel/brief'
|
|
702
|
+
*
|
|
703
|
+
* proof('type-check and lint pass', 'npm run check')
|
|
704
|
+
* ```
|
|
705
|
+
*/
|
|
706
|
+
function proof(text, command) {
|
|
707
|
+
return {
|
|
708
|
+
text,
|
|
709
|
+
command
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Build a `Brief` from a `Task` plus section overrides.
|
|
714
|
+
*
|
|
715
|
+
* @param subject - The task the brief is about.
|
|
716
|
+
* @param overrides - Any sections to fill; every absent collection defaults to `[]`,
|
|
717
|
+
* `output` defaults to `output('markdown')`, and `trace` / `hash` stay OMITTED so
|
|
718
|
+
* `pinBrief` can fill them.
|
|
719
|
+
* @returns A fresh, unpinned `Brief`.
|
|
720
|
+
*
|
|
721
|
+
* @example
|
|
722
|
+
* ```ts
|
|
723
|
+
* import { brief, outcome, proof, task } from '@orkestrel/brief'
|
|
724
|
+
*
|
|
725
|
+
* brief(task('audit', 'code', 'Audit the barrel for undocumented exports.'), {
|
|
726
|
+
* outcomes: [outcome(1, 'every export appears in the guide')],
|
|
727
|
+
* proofs: [proof('parity passes', 'npm run test:guides')],
|
|
728
|
+
* })
|
|
729
|
+
* ```
|
|
730
|
+
*/
|
|
731
|
+
function brief(subject, overrides) {
|
|
732
|
+
return {
|
|
733
|
+
task: subject,
|
|
734
|
+
authority: overrides?.authority ?? [],
|
|
735
|
+
manifest: overrides?.manifest ?? manifest(),
|
|
736
|
+
outcomes: overrides?.outcomes ?? [],
|
|
737
|
+
rules: overrides?.rules ?? [],
|
|
738
|
+
invariants: overrides?.invariants ?? [],
|
|
739
|
+
givens: overrides?.givens ?? [],
|
|
740
|
+
examples: overrides?.examples ?? [],
|
|
741
|
+
assumptions: overrides?.assumptions ?? [],
|
|
742
|
+
citations: overrides?.citations ?? [],
|
|
743
|
+
gaps: overrides?.gaps ?? [],
|
|
744
|
+
risks: overrides?.risks ?? [],
|
|
745
|
+
output: overrides?.output ?? output("markdown"),
|
|
746
|
+
proofs: overrides?.proofs ?? []
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Build the fail-closed readiness gate as a reasons `LogicalDefinition`.
|
|
751
|
+
*
|
|
752
|
+
* @remarks
|
|
753
|
+
* Six readiness rules each derive one named fact from `briefToSubject`'s measures, and a
|
|
754
|
+
* final `ready` rule conjoins all six. Forward chaining reports the LAST rule's
|
|
755
|
+
* conclusion, so `LogicalResult.conclusion` is exactly `ready`.
|
|
756
|
+
*
|
|
757
|
+
* The gate takes NO parameters, and that is deliberate rather than unfinished. The
|
|
758
|
+
* reasoner overlays every derived fact into one flat namespace, so a caller rule named
|
|
759
|
+
* for a readiness fact overwrites it and `ready` then conjoins a fact no base rule
|
|
760
|
+
* proved — a refusal silently becomes a pass. Readiness is this package's contract, not
|
|
761
|
+
* a caller setting. A caller who needs different readiness composes their own
|
|
762
|
+
* `LogicalDefinition` over `briefToSubject` and evaluates it on their own reasoner; both
|
|
763
|
+
* are exported for exactly that, and neither can reach this definition.
|
|
764
|
+
*
|
|
765
|
+
* @returns A fresh `LogicalDefinition` with id `GATE_ID`.
|
|
766
|
+
*
|
|
767
|
+
* @example
|
|
768
|
+
* ```ts
|
|
769
|
+
* import { briefToSubject, gateDefinition } from '@orkestrel/brief'
|
|
770
|
+
* import { createLogicalReasoner, createReason } from '@orkestrel/reason'
|
|
771
|
+
*
|
|
772
|
+
* const reason = createReason({ reasoners: [createLogicalReasoner()] })
|
|
773
|
+
* const verdict = reason.reason(briefToSubject(pinned), gateDefinition())
|
|
774
|
+
* reason.destroy()
|
|
775
|
+
* ```
|
|
776
|
+
*/
|
|
777
|
+
function gateDefinition() {
|
|
778
|
+
const readiness = [
|
|
779
|
+
rule("specified", [atom("blocking", "equals", 0)], atom("specified", "equals", true)),
|
|
780
|
+
rule("aimed", [compound("and", [atom("outcomes", "above", 0), atom("required", "above", 0)])], atom("aimed", "equals", true)),
|
|
781
|
+
rule("proven", [atom("proofs", "above", 0)], atom("proven", "equals", true)),
|
|
782
|
+
rule("disjoint", [atom("overlaps", "equals", 0)], atom("disjoint", "equals", true)),
|
|
783
|
+
rule("granted", [atom("ungranted", "equals", 0)], atom("granted", "equals", true)),
|
|
784
|
+
rule("single", [atom("sentences", "equals", 1)], atom("single", "equals", true))
|
|
785
|
+
];
|
|
786
|
+
return logicalDefinition(GATE_ID, "Brief readiness", [...readiness, rule("ready", [compound("and", readiness.map((entry) => atom(entry.id, "equals", true)))], atom("ready", "equals", true))]);
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* The readiness rules a brief fails, computed directly from its own measures.
|
|
790
|
+
*
|
|
791
|
+
* @remarks
|
|
792
|
+
* The gate's decision, in code. `gateDefinition()` states the same six rules as data for a
|
|
793
|
+
* reasoner to narrate, and a narration is not a decision: `BriefCompilerOptions.reason` lets a
|
|
794
|
+
* caller supply the engine, and an engine that answers "met" to everything would otherwise
|
|
795
|
+
* emit a brief with no proofs. `compile` refuses on THIS and keeps the verdict for its
|
|
796
|
+
* trace, so a supplied engine can add detail and never remove a refusal.
|
|
797
|
+
*
|
|
798
|
+
* The two must agree. `tests/src/core/helpers.test.ts` drives both over one value set, which
|
|
799
|
+
* is what stops the data and the code from drifting apart.
|
|
800
|
+
*
|
|
801
|
+
* @param source - The brief to measure.
|
|
802
|
+
* @returns The unmet rule ids, in gate order; empty when the brief is ready.
|
|
803
|
+
*
|
|
804
|
+
* @example
|
|
805
|
+
* ```ts
|
|
806
|
+
* import { brief, findUnmetRules, outcome, proof, task } from '@orkestrel/brief'
|
|
807
|
+
*
|
|
808
|
+
* findUnmetRules(brief(task('plan', 'ops', 'Plan the release.'))) // ['aimed', 'proven']
|
|
809
|
+
* findUnmetRules(
|
|
810
|
+
* brief(task('plan', 'ops', 'Plan the release.'), {
|
|
811
|
+
* outcomes: [outcome(1, 'shipped')],
|
|
812
|
+
* proofs: [proof('x', 'npm test')],
|
|
813
|
+
* }),
|
|
814
|
+
* ) // []
|
|
815
|
+
* ```
|
|
816
|
+
*/
|
|
817
|
+
function findUnmetRules(source) {
|
|
818
|
+
const unready = [];
|
|
819
|
+
if (findBlockingGaps(source).length !== 0) unready.push("specified");
|
|
820
|
+
if (source.outcomes.length === 0 || source.outcomes.filter((entry) => entry.required).length === 0) unready.push("aimed");
|
|
821
|
+
if (source.proofs.length === 0) unready.push("proven");
|
|
822
|
+
if (findManifestOverlaps(source).length !== 0) unready.push("disjoint");
|
|
823
|
+
if (findUngrantedAuthority(source).length !== 0) unready.push("granted");
|
|
824
|
+
if (countSentences(source.task.statement) !== 1) unready.push("single");
|
|
825
|
+
return unready;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Count the sentences a statement holds.
|
|
829
|
+
*
|
|
830
|
+
* @remarks
|
|
831
|
+
* A terminator run (`.`, `!`, `?`) followed by whitespace or the end of the text closes one
|
|
832
|
+
* sentence, and a trailing run with no terminator closes one more.
|
|
833
|
+
*
|
|
834
|
+
* LIMIT, stated because it decides a gate: an embedded abbreviation reads as a boundary, so
|
|
835
|
+
* `'Ask Dr. Smith'` and `'Compare React vs. Vue'` count TWO and the `single` rule refuses
|
|
836
|
+
* them. Rewrite the statement without the abbreviation — a brief's statement is one
|
|
837
|
+
* imperative sentence naming the object of the work, and it rarely needs one.
|
|
838
|
+
*
|
|
839
|
+
* This is inherent rather than unfinished. Separating `'Dr.'` from a real boundary needs a
|
|
840
|
+
* lexicon or a heuristic over capitalisation and word length, and a heuristic gets a
|
|
841
|
+
* different set of statements wrong — quietly, in the direction of letting a genuinely
|
|
842
|
+
* compound statement through, which is the failure this rule exists to prevent. `validateBrief`
|
|
843
|
+
* therefore reports the count and lets the author judge, rather than guessing at intent.
|
|
844
|
+
*
|
|
845
|
+
* @param statement - The statement to measure.
|
|
846
|
+
* @returns The sentence count; `0` for empty or whitespace-only text.
|
|
847
|
+
*
|
|
848
|
+
* @example
|
|
849
|
+
* ```ts
|
|
850
|
+
* import { countSentences } from '@orkestrel/brief'
|
|
851
|
+
*
|
|
852
|
+
* countSentences('Refactor useForm to native APIs.') // 1
|
|
853
|
+
* countSentences('Refactor useForm. Then update the tests') // 2 — the tail counts
|
|
854
|
+
* countSentences('Ask Dr. Smith') // 2 — an abbreviation reads as a boundary
|
|
855
|
+
* countSentences('') // 0
|
|
856
|
+
* ```
|
|
857
|
+
*/
|
|
858
|
+
function countSentences(statement) {
|
|
859
|
+
const text = collapseWhitespace(statement);
|
|
860
|
+
if (text.length === 0) return 0;
|
|
861
|
+
const matches = text.match(/[.!?]+(?=\s|$)/gu);
|
|
862
|
+
if (matches === null) return 1;
|
|
863
|
+
return /[.!?]$/u.test(text) ? matches.length : matches.length + 1;
|
|
864
|
+
}
|
|
865
|
+
/**
|
|
866
|
+
* The gaps that block emission.
|
|
867
|
+
*
|
|
868
|
+
* @param source - The brief to inspect.
|
|
869
|
+
* @returns Every gap carrying `blocking: true`, in declaration order.
|
|
870
|
+
*
|
|
871
|
+
* @example
|
|
872
|
+
* ```ts
|
|
873
|
+
* import { brief, findBlockingGaps, gap, task } from '@orkestrel/brief'
|
|
874
|
+
*
|
|
875
|
+
* const draft = brief(task('plan', 'ops', 'Plan the release.'), {
|
|
876
|
+
* gaps: [gap('output', 'Diff or files?', { blocking: true })],
|
|
877
|
+
* })
|
|
878
|
+
* findBlockingGaps(draft).length // 1
|
|
879
|
+
* ```
|
|
880
|
+
*/
|
|
881
|
+
function findBlockingGaps(source) {
|
|
882
|
+
return source.gaps.filter((entry) => entry.blocking);
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* The authority paths the manifest never grants access to.
|
|
886
|
+
*
|
|
887
|
+
* @remarks
|
|
888
|
+
* An authority the executor cannot open is an instruction it cannot follow, so every ranked
|
|
889
|
+
* path must appear in `read`, `edit`, or `locked`. Those three are the grants: `locked` is a
|
|
890
|
+
* grant, because read-only is exactly what obeying a file requires.
|
|
891
|
+
*
|
|
892
|
+
* This subsumes the narrower question of an authority sitting in `forbidden`. The four
|
|
893
|
+
* partitions are disjoint — `findManifestOverlaps` and the `disjoint` rule enforce it — so a
|
|
894
|
+
* forbidden path is in none of the three grants and is reported here. An authority named in
|
|
895
|
+
* NO partition at all is reported for the same reason, and that is the case a forbidden-only
|
|
896
|
+
* check misses entirely: the brief simply never says the executor may open what it must obey.
|
|
897
|
+
*
|
|
898
|
+
* Paths are compared as EXACT strings, matching `findManifestOverlaps`. A glob is never
|
|
899
|
+
* expanded, so `read: 'guides/**'` does not grant `authority: 'guides/brief.md'`. State a
|
|
900
|
+
* grant as the same literal path the authority carries.
|
|
901
|
+
*
|
|
902
|
+
* @param source - The brief to inspect.
|
|
903
|
+
* @returns Each ungranted authority path once, in authority order; empty when all are granted.
|
|
904
|
+
*
|
|
905
|
+
* @example
|
|
906
|
+
* ```ts
|
|
907
|
+
* import { brief, findUngrantedAuthority, manifest, reference, task } from '@orkestrel/brief'
|
|
908
|
+
*
|
|
909
|
+
* const draft = brief(task('debug', 'code', 'Fix the leak.'), {
|
|
910
|
+
* authority: [reference('AGENTS.md', 'project law')],
|
|
911
|
+
* manifest: manifest(),
|
|
912
|
+
* })
|
|
913
|
+
* findUngrantedAuthority(draft) // ['AGENTS.md'] — ranked, but no partition opens it
|
|
914
|
+
* ```
|
|
915
|
+
*/
|
|
916
|
+
function findUngrantedAuthority(source) {
|
|
917
|
+
const granted = new Set([
|
|
918
|
+
...source.manifest.read,
|
|
919
|
+
...source.manifest.edit,
|
|
920
|
+
...source.manifest.locked
|
|
921
|
+
].map((entry) => entry.path));
|
|
922
|
+
const ungranted = [];
|
|
923
|
+
for (const path of new Set(source.authority.map((entry) => entry.path))) if (!granted.has(path)) ungranted.push(path);
|
|
924
|
+
return ungranted;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The paths appearing in more than one manifest partition.
|
|
928
|
+
*
|
|
929
|
+
* @remarks
|
|
930
|
+
* Duplicates WITHIN one partition are not an overlap; the four partitions must be
|
|
931
|
+
* mutually disjoint, which is what `validateBrief` errors on.
|
|
932
|
+
*
|
|
933
|
+
* Paths are compared as EXACT strings. A glob is never expanded, so `edit: 'app/file.ts'`
|
|
934
|
+
* and `forbidden: 'app/**'` are not reported as an overlap even though a walker would place
|
|
935
|
+
* one inside the other. Disjointness here is a property of the written paths.
|
|
936
|
+
*
|
|
937
|
+
* @param source - The brief to inspect.
|
|
938
|
+
* @returns Each overlapping path once, in first-seen partition order.
|
|
939
|
+
*
|
|
940
|
+
* @example
|
|
941
|
+
* ```ts
|
|
942
|
+
* import { brief, findManifestOverlaps, manifest, reference, task } from '@orkestrel/brief'
|
|
943
|
+
*
|
|
944
|
+
* const draft = brief(task('debug', 'code', 'Fix the leak.'), {
|
|
945
|
+
* manifest: manifest({
|
|
946
|
+
* edit: [reference('src/core/BriefCompiler.ts', 'the leaking pipeline')],
|
|
947
|
+
* locked: [reference('src/core/BriefCompiler.ts', 'the published contract')],
|
|
948
|
+
* }),
|
|
949
|
+
* })
|
|
950
|
+
* findManifestOverlaps(draft) // ['src/core/BriefCompiler.ts']
|
|
951
|
+
* ```
|
|
952
|
+
*/
|
|
953
|
+
function findManifestOverlaps(source) {
|
|
954
|
+
const counts = /* @__PURE__ */ new Map();
|
|
955
|
+
const partitions = [
|
|
956
|
+
source.manifest.read,
|
|
957
|
+
source.manifest.edit,
|
|
958
|
+
source.manifest.locked,
|
|
959
|
+
source.manifest.forbidden
|
|
960
|
+
];
|
|
961
|
+
for (const partition of partitions) for (const path of new Set(partition.map((entry) => entry.path))) counts.set(path, (counts.get(path) ?? 0) + 1);
|
|
962
|
+
const overlaps = [];
|
|
963
|
+
for (const [path, count] of counts) if (count > 1) overlaps.push(path);
|
|
964
|
+
return overlaps;
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* The open gaps with no assumption to stand on.
|
|
968
|
+
*
|
|
969
|
+
* @remarks
|
|
970
|
+
* The discipline is exactly one recorded assumption per open gap, so the open gaps past
|
|
971
|
+
* the assumption count are the unpaired ones. A blocking gap is never unpaired — it is
|
|
972
|
+
* a question, not something to assume around.
|
|
973
|
+
*
|
|
974
|
+
* @param source - The brief to inspect.
|
|
975
|
+
* @returns The surplus open gaps, in declaration order.
|
|
976
|
+
*
|
|
977
|
+
* @example
|
|
978
|
+
* ```ts
|
|
979
|
+
* import { brief, findUnpairedGaps, gap, task } from '@orkestrel/brief'
|
|
980
|
+
*
|
|
981
|
+
* const draft = brief(task('plan', 'ops', 'Plan the release.'), {
|
|
982
|
+
* gaps: [gap('rules', 'Keep the wording?'), gap('output', 'Diff or files?')],
|
|
983
|
+
* assumptions: ['Wording is preserved.'],
|
|
984
|
+
* })
|
|
985
|
+
* findUnpairedGaps(draft).length // 1
|
|
986
|
+
* ```
|
|
987
|
+
*/
|
|
988
|
+
function findUnpairedGaps(source) {
|
|
989
|
+
return source.gaps.filter((entry) => !entry.blocking).slice(source.assumptions.length);
|
|
990
|
+
}
|
|
991
|
+
/**
|
|
992
|
+
* Project a brief into the reasons `Subject` of readiness measures the gate reads.
|
|
993
|
+
*
|
|
994
|
+
* @param source - The brief to measure.
|
|
995
|
+
* @returns A flat record of counts plus the task's two vocabulary values.
|
|
996
|
+
*
|
|
997
|
+
* @example
|
|
998
|
+
* ```ts
|
|
999
|
+
* import { brief, briefToSubject, proof, task } from '@orkestrel/brief'
|
|
1000
|
+
*
|
|
1001
|
+
* briefToSubject(brief(task('test', 'code', 'Cover the gate.'), { proofs: [proof('x', 'y')] }))
|
|
1002
|
+
* // { operation: 'test', domain: 'code', sentences: 1, proofs: 1, … }
|
|
1003
|
+
* ```
|
|
1004
|
+
*/
|
|
1005
|
+
function briefToSubject(source) {
|
|
1006
|
+
return {
|
|
1007
|
+
operation: source.task.operation,
|
|
1008
|
+
domain: source.task.domain,
|
|
1009
|
+
sentences: countSentences(source.task.statement),
|
|
1010
|
+
authority: source.authority.length,
|
|
1011
|
+
gaps: source.gaps.length,
|
|
1012
|
+
blocking: findBlockingGaps(source).length,
|
|
1013
|
+
unpaired: findUnpairedGaps(source).length,
|
|
1014
|
+
outcomes: source.outcomes.length,
|
|
1015
|
+
required: source.outcomes.filter((entry) => entry.required).length,
|
|
1016
|
+
proofs: source.proofs.length,
|
|
1017
|
+
reads: source.manifest.read.length,
|
|
1018
|
+
edits: source.manifest.edit.length,
|
|
1019
|
+
locks: source.manifest.locked.length,
|
|
1020
|
+
bans: source.manifest.forbidden.length,
|
|
1021
|
+
overlaps: findManifestOverlaps(source).length,
|
|
1022
|
+
ungranted: findUngrantedAuthority(source).length,
|
|
1023
|
+
risks: source.risks.length,
|
|
1024
|
+
examples: source.examples.length
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* The semantic pass over an already-shape-valid brief.
|
|
1029
|
+
*
|
|
1030
|
+
* @remarks
|
|
1031
|
+
* ERRORS are the structural violations no assumption can paper over: a manifest
|
|
1032
|
+
* overlap, an authority no partition grants access to, an empty `proofs` list, and a
|
|
1033
|
+
* statement that is not exactly one sentence.
|
|
1034
|
+
* WARNINGS are runnable but suspicious: duplicate outcome ranks, an unpaired open gap,
|
|
1035
|
+
* and an optional outcome ranked above a required one. Never throws.
|
|
1036
|
+
*
|
|
1037
|
+
* @param source - The brief to inspect.
|
|
1038
|
+
* @returns A reasons `ReasonValidationResult`; `valid` exactly when `errors` is empty.
|
|
1039
|
+
*
|
|
1040
|
+
* @example
|
|
1041
|
+
* ```ts
|
|
1042
|
+
* import { brief, proof, task, validateBrief } from '@orkestrel/brief'
|
|
1043
|
+
*
|
|
1044
|
+
* validateBrief(brief(task('plan', 'ops', 'Plan the release.'))) // valid: false — no proofs
|
|
1045
|
+
* validateBrief(
|
|
1046
|
+
* brief(task('plan', 'ops', 'Plan the release.'), { proofs: [proof('ok', 'npm test')] }),
|
|
1047
|
+
* ) // valid: true
|
|
1048
|
+
* ```
|
|
1049
|
+
*/
|
|
1050
|
+
function validateBrief(source) {
|
|
1051
|
+
const errors = [];
|
|
1052
|
+
const warnings = [];
|
|
1053
|
+
for (const path of findManifestOverlaps(source)) errors.push(`Path "${path}" appears in more than one manifest partition`);
|
|
1054
|
+
for (const path of findUngrantedAuthority(source)) errors.push(`Authority "${path}" is in no manifest partition that grants access — the executor cannot obey what it cannot open`);
|
|
1055
|
+
if (source.proofs.length === 0) errors.push("Brief records no proof — nothing can settle \"done\"");
|
|
1056
|
+
const sentences = countSentences(source.task.statement);
|
|
1057
|
+
if (sentences !== 1) errors.push(`Statement holds ${String(sentences)} sentences — a compound statement is two briefs`);
|
|
1058
|
+
const ranks = /* @__PURE__ */ new Map();
|
|
1059
|
+
for (const entry of source.outcomes) ranks.set(entry.rank, (ranks.get(entry.rank) ?? 0) + 1);
|
|
1060
|
+
for (const [rank, count] of ranks) if (count > 1) warnings.push(`Outcome rank ${String(rank)} is used ${String(count)} times`);
|
|
1061
|
+
for (const entry of findUnpairedGaps(source)) warnings.push(`Open gap "${entry.field}" has no paired assumption`);
|
|
1062
|
+
const required = source.outcomes.filter((entry) => entry.required).map((entry) => entry.rank);
|
|
1063
|
+
if (required.length > 0) {
|
|
1064
|
+
const floor = Math.min(...required);
|
|
1065
|
+
for (const entry of source.outcomes) if (!entry.required && entry.rank < floor) warnings.push(`Outcome ${String(entry.rank)} is optional but outranks every required outcome`);
|
|
1066
|
+
}
|
|
1067
|
+
return {
|
|
1068
|
+
valid: errors.length === 0,
|
|
1069
|
+
errors,
|
|
1070
|
+
warnings
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* The canonical structural digest of a brief's content.
|
|
1075
|
+
*
|
|
1076
|
+
* @remarks
|
|
1077
|
+
* `trace` and `hash` are stripped before digesting, so the value is the identity of what
|
|
1078
|
+
* the brief SAYS rather than of a particular pinning. Deterministic across runs — the
|
|
1079
|
+
* same interprets `digestValue` the fleet uses everywhere else.
|
|
1080
|
+
*
|
|
1081
|
+
* @param source - The brief to digest.
|
|
1082
|
+
* @returns An eight-hex-digit digest.
|
|
1083
|
+
*
|
|
1084
|
+
* @example
|
|
1085
|
+
* ```ts
|
|
1086
|
+
* import { brief, briefToHash, pinBrief, task } from '@orkestrel/brief'
|
|
1087
|
+
*
|
|
1088
|
+
* const draft = brief(task('plan', 'ops', 'Plan the release.'))
|
|
1089
|
+
* briefToHash(draft) === briefToHash(pinBrief(draft)) // true — pinning does not move it
|
|
1090
|
+
* ```
|
|
1091
|
+
*/
|
|
1092
|
+
function briefToHash(source) {
|
|
1093
|
+
return digestValue(briefToContent(source));
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* The canonical text of exactly what a brief's hash describes.
|
|
1097
|
+
*
|
|
1098
|
+
* @remarks
|
|
1099
|
+
* `trace` and `hash` are stripped, then interprets `canonicalize` renders the rest in a
|
|
1100
|
+
* key-order-stable form. Two briefs with the same hash are the same brief only when this
|
|
1101
|
+
* text matches — the digest is eight hex digits, so hash equality alone is not identity.
|
|
1102
|
+
*
|
|
1103
|
+
* @param source - The brief to render.
|
|
1104
|
+
* @returns The canonical content text.
|
|
1105
|
+
*
|
|
1106
|
+
* @example
|
|
1107
|
+
* ```ts
|
|
1108
|
+
* import { brief, briefToContent, pinBrief, task } from '@orkestrel/brief'
|
|
1109
|
+
*
|
|
1110
|
+
* const draft = brief(task('plan', 'ops', 'Plan the release.'))
|
|
1111
|
+
* briefToContent(draft) === briefToContent(pinBrief(draft)) // true — pinning adds no content
|
|
1112
|
+
* ```
|
|
1113
|
+
*/
|
|
1114
|
+
function briefToContent(source) {
|
|
1115
|
+
const { trace: _trace, hash: _hash, ...content } = source;
|
|
1116
|
+
return canonicalize(content);
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Freeze a value and everything reachable from it.
|
|
1120
|
+
*
|
|
1121
|
+
* @remarks
|
|
1122
|
+
* `Object.freeze` is SHALLOW, so freezing a record leaves every nested array and object
|
|
1123
|
+
* writable. A `Briefing` is documented as a replayable record, and a shallow freeze let a
|
|
1124
|
+
* consumer rewrite the recorded stage input after the digest describing it was already
|
|
1125
|
+
* sealed — the replay and its hash could disagree.
|
|
1126
|
+
*
|
|
1127
|
+
* Cycles terminate: `structuredClone` preserves them, so a naive walk would not return.
|
|
1128
|
+
* Delegates each branch to `freezeBranch` with the shared visited set.
|
|
1129
|
+
*
|
|
1130
|
+
* Reaches PLAIN objects and arrays, which is the whole of a `Brief` — it is JSON-serializable
|
|
1131
|
+
* by contract. A `Map`, `Set`, or typed array is frozen as an object and its CONTENTS are left
|
|
1132
|
+
* writable, and `Object.isFrozen` reports `true` for it either way. Nothing this package
|
|
1133
|
+
* produces contains one; a caller freezing their own value should know the limit.
|
|
1134
|
+
*
|
|
1135
|
+
* @param value - The value to freeze in place; returned for convenience.
|
|
1136
|
+
* @returns The same value, now deeply frozen.
|
|
1137
|
+
*
|
|
1138
|
+
* @example
|
|
1139
|
+
* ```ts
|
|
1140
|
+
* import { freezeDeep } from '@orkestrel/brief'
|
|
1141
|
+
*
|
|
1142
|
+
* const owned = freezeDeep({ outcomes: [{ rank: 1 }] })
|
|
1143
|
+
* Object.isFrozen(owned.outcomes) // true — the nested array too
|
|
1144
|
+
* ```
|
|
1145
|
+
*/
|
|
1146
|
+
function freezeDeep(value) {
|
|
1147
|
+
return freezeBranch(value, /* @__PURE__ */ new WeakSet());
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Freeze one branch of a value graph, skipping what the visited set already holds.
|
|
1151
|
+
*
|
|
1152
|
+
* @param value - The branch to freeze.
|
|
1153
|
+
* @param seen - The objects already frozen on this walk; what makes a cycle terminate.
|
|
1154
|
+
* @returns The same branch, now frozen.
|
|
1155
|
+
*
|
|
1156
|
+
* @example
|
|
1157
|
+
* ```ts
|
|
1158
|
+
* import { freezeBranch } from '@orkestrel/brief'
|
|
1159
|
+
*
|
|
1160
|
+
* freezeBranch({ a: [1] }, new WeakSet()) // frozen, one level of nesting included
|
|
1161
|
+
* ```
|
|
1162
|
+
*/
|
|
1163
|
+
function freezeBranch(value, seen) {
|
|
1164
|
+
if (value === null || typeof value !== "object") return value;
|
|
1165
|
+
if (seen.has(value)) return value;
|
|
1166
|
+
seen.add(value);
|
|
1167
|
+
Object.freeze(value);
|
|
1168
|
+
for (const nested of Object.values(value)) freezeBranch(nested, seen);
|
|
1169
|
+
return value;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Render a value thrown by a stage into a message.
|
|
1173
|
+
*
|
|
1174
|
+
* @remarks
|
|
1175
|
+
* TOTAL: it never throws, for any input. That is load-bearing rather than tidy, because this
|
|
1176
|
+
* is the containment code itself — `compile` calls it inside the `catch` that turns a thrown
|
|
1177
|
+
* stage into a recorded `BriefStageFailure`. A throw here escapes `compile` uncontained and
|
|
1178
|
+
* falsifies the package's central promise that a failing stage yields an incomplete
|
|
1179
|
+
* `Briefing` rather than an exception.
|
|
1180
|
+
*
|
|
1181
|
+
* Three real inputs used to throw: an `Error` subclass whose `message` getter throws, a value
|
|
1182
|
+
* whose string conversion throws, and a null-prototype object, which has no inherited
|
|
1183
|
+
* conversion for String() to reach. Each is wrapped, and an unreadable value degrades to its
|
|
1184
|
+
* type rather than propagating.
|
|
1185
|
+
*
|
|
1186
|
+
* @param error - The caught value, of any shape.
|
|
1187
|
+
* @returns The `Error` message when there is one, otherwise the value stringified; a fixed
|
|
1188
|
+
* description when the value cannot be read at all.
|
|
1189
|
+
*
|
|
1190
|
+
* @example
|
|
1191
|
+
* ```ts
|
|
1192
|
+
* import { errorToMessage } from '@orkestrel/brief'
|
|
1193
|
+
*
|
|
1194
|
+
* errorToMessage(new Error('boom')) // 'boom'
|
|
1195
|
+
* errorToMessage('boom') // 'boom'
|
|
1196
|
+
* errorToMessage(Object.create(null)) // 'an unreadable object was thrown'
|
|
1197
|
+
* ```
|
|
1198
|
+
*/
|
|
1199
|
+
function errorToMessage(error) {
|
|
1200
|
+
const read = attempt(() => error instanceof Error ? error.message : String(error));
|
|
1201
|
+
if (read.success && typeof read.value === "string") return read.value;
|
|
1202
|
+
return `an unreadable ${typeof error} was thrown`;
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Narrow unknown data to a `Brief`, throwing when it is off-contract.
|
|
1206
|
+
*
|
|
1207
|
+
* @remarks
|
|
1208
|
+
* The throwing half of the intake pair: this returns its argument by IDENTITY once the
|
|
1209
|
+
* guard passes, while `parseBrief` returns `undefined` for bad input. It constructs
|
|
1210
|
+
* nothing, so it is an assertion rather than a factory. Reserve it for programmer-error
|
|
1211
|
+
* contexts where invalidity is a bug.
|
|
1212
|
+
*
|
|
1213
|
+
* @param data - The candidate brief data.
|
|
1214
|
+
* @returns The same value, now known to satisfy {@link Brief}.
|
|
1215
|
+
* @throws {@link BriefError} `INVALID` when `data` fails `isBrief`.
|
|
1216
|
+
*
|
|
1217
|
+
* @example
|
|
1218
|
+
* ```ts
|
|
1219
|
+
* import { assertBrief, brief, proof, task } from '@orkestrel/brief'
|
|
1220
|
+
*
|
|
1221
|
+
* assertBrief(brief(task('plan', 'ops', 'Plan the release.'), { proofs: [proof('x', 'y')] }))
|
|
1222
|
+
* assertBrief({ task: { operation: 'plan', domain: 'ops', statement: 'x.' } }) // throws INVALID
|
|
1223
|
+
* ```
|
|
1224
|
+
*/
|
|
1225
|
+
function assertBrief(data) {
|
|
1226
|
+
if (!isBrief(data)) throw new BriefError("INVALID", "Brief failed the exact-record contract", { field: "brief" });
|
|
1227
|
+
return data;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Return a fresh brief with `trace` and `hash` derived from its own content.
|
|
1231
|
+
*
|
|
1232
|
+
* @remarks
|
|
1233
|
+
* Deterministic: no clock, no randomness, no run-specific data. Any existing `trace` /
|
|
1234
|
+
* `hash` is stripped before the digest, so pinning is idempotent and a re-pin of unchanged
|
|
1235
|
+
* content produces the same hash.
|
|
1236
|
+
*
|
|
1237
|
+
* The snapshot is taken FIRST, before any member is read, so a hostile input whose getters
|
|
1238
|
+
* throw surfaces as this package's coded error rather than as whatever it threw.
|
|
1239
|
+
*
|
|
1240
|
+
* @param source - The brief to pin.
|
|
1241
|
+
* @returns A fresh, pinned, deeply frozen `Brief`.
|
|
1242
|
+
* @throws {@link BriefError} `INVALID` when the brief carries data JSON cannot express.
|
|
1243
|
+
*
|
|
1244
|
+
* @example
|
|
1245
|
+
* ```ts
|
|
1246
|
+
* import { brief, pinBrief, task } from '@orkestrel/brief'
|
|
1247
|
+
*
|
|
1248
|
+
* const pinned = pinBrief(brief(task('document', 'writing', 'Write the brief guide.')))
|
|
1249
|
+
* pinned.hash // an 8-hex-digit structural digest
|
|
1250
|
+
* pinned.trace // 'document/writing · outcomes:0 · gaps:0/0 · proofs:0'
|
|
1251
|
+
* ```
|
|
1252
|
+
*/
|
|
1253
|
+
function pinBrief(source) {
|
|
1254
|
+
const owned = snapshotBrief(source);
|
|
1255
|
+
const { trace: _trace, hash: _hash, ...content } = owned;
|
|
1256
|
+
return snapshotBrief({
|
|
1257
|
+
...content,
|
|
1258
|
+
trace: briefToTrace(owned),
|
|
1259
|
+
hash: briefToHash(owned)
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* The one-line census `pinBrief` stamps onto a brief.
|
|
1264
|
+
*
|
|
1265
|
+
* @remarks
|
|
1266
|
+
* Extracted so it has ONE implementation. `pinBrief` derives it and `BriefManager` re-derives
|
|
1267
|
+
* it to reconcile an inbound brief's own `trace` against its content — an inbound `trace` is
|
|
1268
|
+
* shape-checked rather than verified, and it is the line `briefToMarkdown` prints at the top
|
|
1269
|
+
* of the executor's prompt, so a stale one misdescribes the brief where it is most read.
|
|
1270
|
+
*
|
|
1271
|
+
* @param source - The brief to describe.
|
|
1272
|
+
* @returns The census line: operation/domain, outcomes, blocking-over-total gaps, proofs.
|
|
1273
|
+
*
|
|
1274
|
+
* @example
|
|
1275
|
+
* ```ts
|
|
1276
|
+
* import { brief, briefToTrace, task } from '@orkestrel/brief'
|
|
1277
|
+
*
|
|
1278
|
+
* briefToTrace(brief(task('document', 'writing', 'Write the guide.')))
|
|
1279
|
+
* // 'document/writing · outcomes:0 · gaps:0/0 · proofs:0'
|
|
1280
|
+
* ```
|
|
1281
|
+
*/
|
|
1282
|
+
function briefToTrace(source) {
|
|
1283
|
+
return [
|
|
1284
|
+
`${source.task.operation}/${source.task.domain}`,
|
|
1285
|
+
`outcomes:${String(source.outcomes.length)}`,
|
|
1286
|
+
`gaps:${String(findBlockingGaps(source).length)}/${String(source.gaps.length)}`,
|
|
1287
|
+
`proofs:${String(source.proofs.length)}`
|
|
1288
|
+
].join(" · ");
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Render one exemplar as markdown lines.
|
|
1292
|
+
*
|
|
1293
|
+
* @remarks
|
|
1294
|
+
* An `Example`'s two sides are the only brief members permitted to span lines, so a
|
|
1295
|
+
* single-line pair renders as one row and a multi-line pair renders as a fenced block.
|
|
1296
|
+
* Fencing is what stops the one permissive field from forging a heading.
|
|
1297
|
+
*
|
|
1298
|
+
* @param entry - The exemplar to render.
|
|
1299
|
+
* @returns The markdown lines, without a trailing blank.
|
|
1300
|
+
*
|
|
1301
|
+
* @example
|
|
1302
|
+
* ```ts
|
|
1303
|
+
* import { example, exampleToLines } from '@orkestrel/brief'
|
|
1304
|
+
*
|
|
1305
|
+
* exampleToLines(example('<input required>', 'el.validity')) // ['- ` <input required> ` → ` el.validity `']
|
|
1306
|
+
* ```
|
|
1307
|
+
*/
|
|
1308
|
+
function exampleToLines(entry) {
|
|
1309
|
+
const note = entry.note === void 0 ? "" : ` (${entry.note})`;
|
|
1310
|
+
let runs = 0;
|
|
1311
|
+
let current = 0;
|
|
1312
|
+
for (const character of `${entry.input} ${entry.output}`) {
|
|
1313
|
+
current = character === "`" ? current + 1 : 0;
|
|
1314
|
+
if (current > runs) runs = current;
|
|
1315
|
+
}
|
|
1316
|
+
if (!LINE_BREAK_PATTERN.test(entry.input) && !LINE_BREAK_PATTERN.test(entry.output)) {
|
|
1317
|
+
const tick = "`".repeat(runs + 1);
|
|
1318
|
+
const inputPad = BLANK_PATTERN.test(entry.input) ? "" : " ";
|
|
1319
|
+
const outputPad = BLANK_PATTERN.test(entry.output) ? "" : " ";
|
|
1320
|
+
return [`- ${tick}${inputPad}${entry.input}${inputPad}${tick} → ${tick}${outputPad}${entry.output}${outputPad}${tick}${note}`];
|
|
1321
|
+
}
|
|
1322
|
+
const fence = "`".repeat(Math.max(3, runs) + 1);
|
|
1323
|
+
return [
|
|
1324
|
+
`- exemplar${note}`,
|
|
1325
|
+
"",
|
|
1326
|
+
` ${fence}text`,
|
|
1327
|
+
...entry.input.split(LINE_BREAK_PATTERN).map((line) => ` ${line}`),
|
|
1328
|
+
` ${fence}`,
|
|
1329
|
+
"",
|
|
1330
|
+
` ${fence}text`,
|
|
1331
|
+
...entry.output.split(LINE_BREAK_PATTERN).map((line) => ` ${line}`),
|
|
1332
|
+
` ${fence}`
|
|
1333
|
+
];
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Project a brief into the copy-ready agent prompt.
|
|
1337
|
+
*
|
|
1338
|
+
* @remarks
|
|
1339
|
+
* Paths are REFERENCED, never inlined — the executor retrieves them. An empty section is
|
|
1340
|
+
* omitted entirely, so the rendering carries no filler an executor must read past.
|
|
1341
|
+
*
|
|
1342
|
+
* @param source - The brief to render.
|
|
1343
|
+
* @returns The markdown prompt.
|
|
1344
|
+
*
|
|
1345
|
+
* @example
|
|
1346
|
+
* ```ts
|
|
1347
|
+
* import { brief, briefToMarkdown, task } from '@orkestrel/brief'
|
|
1348
|
+
*
|
|
1349
|
+
* briefToMarkdown(brief(task('review', 'code', 'Review the gate rules.')))
|
|
1350
|
+
* // '# Brief: Review the gate rules.\n\nreview · code\n\n## Output\n\n- format: markdown\n'
|
|
1351
|
+
* ```
|
|
1352
|
+
*/
|
|
1353
|
+
function briefToMarkdown(input) {
|
|
1354
|
+
const source = snapshotBrief(input);
|
|
1355
|
+
const lines = [`# Brief: ${source.task.statement}`, ""];
|
|
1356
|
+
lines.push(`${source.task.operation} · ${source.task.domain}`, "");
|
|
1357
|
+
if (source.trace !== void 0) lines.push(`Trace: ${source.trace}`, "");
|
|
1358
|
+
if (source.hash !== void 0) lines.push(`Hash: ${source.hash}`, "");
|
|
1359
|
+
if (source.authority.length > 0) {
|
|
1360
|
+
lines.push("## Authority (ranked)", "");
|
|
1361
|
+
lines.push(...source.authority.map((entry, index) => `${String(index + 1)}. ${entry.path} — ${entry.note}`));
|
|
1362
|
+
lines.push("");
|
|
1363
|
+
}
|
|
1364
|
+
const partitions = [
|
|
1365
|
+
["Read", source.manifest.read],
|
|
1366
|
+
["Edit", source.manifest.edit],
|
|
1367
|
+
["Locked", source.manifest.locked],
|
|
1368
|
+
["Forbidden", source.manifest.forbidden]
|
|
1369
|
+
];
|
|
1370
|
+
if (partitions.some((partition) => partition[1].length > 0)) {
|
|
1371
|
+
lines.push("## Manifest", "");
|
|
1372
|
+
for (const [heading, entries] of partitions) {
|
|
1373
|
+
if (entries.length === 0) continue;
|
|
1374
|
+
lines.push(`### ${heading}`, "");
|
|
1375
|
+
lines.push(...entries.map((entry) => `- ${entry.path} — ${entry.note}`));
|
|
1376
|
+
lines.push("");
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
if (source.outcomes.length > 0) {
|
|
1380
|
+
lines.push("## Outcomes", "");
|
|
1381
|
+
lines.push(...source.outcomes.map((entry) => `${String(entry.rank)}. ${entry.text}${entry.required ? " (required)" : " (optional)"}`));
|
|
1382
|
+
lines.push("");
|
|
1383
|
+
}
|
|
1384
|
+
const prose = [
|
|
1385
|
+
["Rules", source.rules],
|
|
1386
|
+
["Invariants", source.invariants],
|
|
1387
|
+
["Assumptions", source.assumptions]
|
|
1388
|
+
];
|
|
1389
|
+
for (const [heading, entries] of prose) {
|
|
1390
|
+
if (entries.length === 0) continue;
|
|
1391
|
+
lines.push(`## ${heading}`, "");
|
|
1392
|
+
lines.push(...entries.map((entry) => `- ${entry}`));
|
|
1393
|
+
lines.push("");
|
|
1394
|
+
}
|
|
1395
|
+
if (source.givens.length > 0) {
|
|
1396
|
+
lines.push("## Givens", "");
|
|
1397
|
+
lines.push(...source.givens.map((entry) => `- ${entry.category} · ${entry.name}: ${entry.value}`));
|
|
1398
|
+
lines.push("");
|
|
1399
|
+
}
|
|
1400
|
+
if (source.examples.length > 0) {
|
|
1401
|
+
lines.push("## Examples", "");
|
|
1402
|
+
for (const entry of source.examples) lines.push(...exampleToLines(entry));
|
|
1403
|
+
lines.push("");
|
|
1404
|
+
}
|
|
1405
|
+
if (source.citations.length > 0) {
|
|
1406
|
+
lines.push("## Citations (trust order)", "");
|
|
1407
|
+
lines.push(...source.citations.map((entry, index) => `${String(index + 1)}. ${entry.name} — ${entry.note} — ${entry.url}`));
|
|
1408
|
+
lines.push("");
|
|
1409
|
+
}
|
|
1410
|
+
if (source.gaps.length > 0) {
|
|
1411
|
+
lines.push("## Gaps", "");
|
|
1412
|
+
lines.push(...source.gaps.map((entry) => {
|
|
1413
|
+
const mark = entry.blocking ? "blocking" : "open";
|
|
1414
|
+
const candidates = entry.candidates === void 0 ? "" : ` (candidates: ${entry.candidates.join(", ")})`;
|
|
1415
|
+
return `- [${mark}] ${entry.field}: ${entry.question}${candidates}`;
|
|
1416
|
+
}));
|
|
1417
|
+
lines.push("");
|
|
1418
|
+
}
|
|
1419
|
+
if (source.risks.length > 0) {
|
|
1420
|
+
lines.push("## Risks", "");
|
|
1421
|
+
lines.push(...source.risks.map((entry) => `- ${entry.severity}: ${entry.text} — ${entry.mitigation}`));
|
|
1422
|
+
lines.push("");
|
|
1423
|
+
}
|
|
1424
|
+
lines.push("## Output", "", `- format: ${source.output.format}`);
|
|
1425
|
+
const refinements = [
|
|
1426
|
+
["sections", source.output.sections],
|
|
1427
|
+
["include", source.output.include],
|
|
1428
|
+
["exclude", source.output.exclude]
|
|
1429
|
+
];
|
|
1430
|
+
for (const [label, entries] of refinements) {
|
|
1431
|
+
if (entries === void 0 || entries.length === 0) continue;
|
|
1432
|
+
lines.push(`- ${label}: ${entries.join(", ")}`);
|
|
1433
|
+
}
|
|
1434
|
+
lines.push("");
|
|
1435
|
+
if (source.proofs.length > 0) {
|
|
1436
|
+
lines.push("## Proofs", "");
|
|
1437
|
+
lines.push(...source.proofs.map((entry) => `- ${entry.text} — \`${entry.command}\``));
|
|
1438
|
+
lines.push("");
|
|
1439
|
+
}
|
|
1440
|
+
return lines.join("\n");
|
|
1441
|
+
}
|
|
1442
|
+
/**
|
|
1443
|
+
* Project a brief into a `/goal` completion condition.
|
|
1444
|
+
*
|
|
1445
|
+
* @remarks
|
|
1446
|
+
* The proofs' commands VERBATIM plus a turn cap — the goal never adds a condition the
|
|
1447
|
+
* brief does not carry.
|
|
1448
|
+
*
|
|
1449
|
+
* @param source - The brief to render.
|
|
1450
|
+
* @param turns - The turn cap; defaults to `DEFAULT_BRIEF_TURNS`.
|
|
1451
|
+
* @returns The one-line completion condition.
|
|
1452
|
+
*
|
|
1453
|
+
* @example
|
|
1454
|
+
* ```ts
|
|
1455
|
+
* import { brief, briefToGoal, proof, task } from '@orkestrel/brief'
|
|
1456
|
+
*
|
|
1457
|
+
* briefToGoal(brief(task('test', 'code', 'Cover the gate.'), { proofs: [proof('x', 'npm test')] }))
|
|
1458
|
+
* // 'Done when every proof passes: npm test exits 0. Cap: 16 turns.'
|
|
1459
|
+
* ```
|
|
1460
|
+
*/
|
|
1461
|
+
function briefToGoal(input, turns = 16) {
|
|
1462
|
+
const source = snapshotBrief(input);
|
|
1463
|
+
return `Done when every proof passes: ${source.proofs.length === 0 ? "no proofs recorded" : source.proofs.map((entry) => `${entry.command} exits 0`).join("; ")}. Cap: ${String(turns)} turns.`;
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Project a brief into a subagent `Dispatch`.
|
|
1467
|
+
*
|
|
1468
|
+
* @remarks
|
|
1469
|
+
* `edit` is exactly `manifest.edit`, so two dispatches whose `edit` sets do not intersect
|
|
1470
|
+
* can run concurrently under the same brief without conflict.
|
|
1471
|
+
*
|
|
1472
|
+
* `authority` is exactly `brief.authority` in rank order, and it is a SEPARATE axis from the
|
|
1473
|
+
* four permission sets rather than a fifth partition — a ranked path normally also appears in
|
|
1474
|
+
* `read` or `locked`, because the executor has to open what it obeys. It is projected as
|
|
1475
|
+
* paths so a machine consumer never has to parse `prompt`, which is written for a model.
|
|
1476
|
+
*
|
|
1477
|
+
* @param source - The brief to project.
|
|
1478
|
+
* @returns The dispatch — the rendered prompt, the ranked authority, and the four path sets.
|
|
1479
|
+
*
|
|
1480
|
+
* @example
|
|
1481
|
+
* ```ts
|
|
1482
|
+
* import { brief, briefToDispatch, manifest, reference, task } from '@orkestrel/brief'
|
|
1483
|
+
*
|
|
1484
|
+
* const draft = brief(task('migrate', 'code', 'Migrate the stores.'), {
|
|
1485
|
+
* authority: [reference('AGENTS.md', 'project law')],
|
|
1486
|
+
* manifest: manifest({ edit: [reference('src/core/stores/**', 'the legacy stores')] }),
|
|
1487
|
+
* })
|
|
1488
|
+
* briefToDispatch(draft).edit // ['src/core/stores/**']
|
|
1489
|
+
* briefToDispatch(draft).authority // ['AGENTS.md']
|
|
1490
|
+
* ```
|
|
1491
|
+
*/
|
|
1492
|
+
function briefToDispatch(input) {
|
|
1493
|
+
const source = snapshotBrief(input);
|
|
1494
|
+
return {
|
|
1495
|
+
prompt: briefToMarkdown(source),
|
|
1496
|
+
authority: source.authority.map((entry) => entry.path),
|
|
1497
|
+
read: source.manifest.read.map((entry) => entry.path),
|
|
1498
|
+
edit: source.manifest.edit.map((entry) => entry.path),
|
|
1499
|
+
locked: source.manifest.locked.map((entry) => entry.path),
|
|
1500
|
+
forbidden: source.manifest.forbidden.map((entry) => entry.path)
|
|
1501
|
+
};
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Derive one imperative statement from free text.
|
|
1505
|
+
*
|
|
1506
|
+
* @remarks
|
|
1507
|
+
* Whitespace collapses, the first character uppercases, and a terminator is appended
|
|
1508
|
+
* when the text carries none. Nothing else is invented.
|
|
1509
|
+
*
|
|
1510
|
+
* @param text - The raw request text.
|
|
1511
|
+
* @returns The statement, or `''` for empty or whitespace-only text.
|
|
1512
|
+
*
|
|
1513
|
+
* @example
|
|
1514
|
+
* ```ts
|
|
1515
|
+
* import { deriveStatement } from '@orkestrel/brief'
|
|
1516
|
+
*
|
|
1517
|
+
* deriveStatement(' clean up useForm ') // 'Clean up useForm.'
|
|
1518
|
+
* deriveStatement('') // ''
|
|
1519
|
+
* ```
|
|
1520
|
+
*/
|
|
1521
|
+
function deriveStatement(text) {
|
|
1522
|
+
const collapsed = collapseWhitespace(text);
|
|
1523
|
+
if (collapsed.length === 0) return "";
|
|
1524
|
+
const capitalized = collapsed.charAt(0).toUpperCase() + collapsed.slice(1);
|
|
1525
|
+
return /[.!?]$/u.test(capitalized) ? capitalized : `${capitalized}.`;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Derive a `Task` from an interprets `Intent` through the caller's vocabularies.
|
|
1529
|
+
*
|
|
1530
|
+
* @remarks
|
|
1531
|
+
* The vocabularies are the CALLER's policy: this maps and never guesses. An action or
|
|
1532
|
+
* domain the caller did not map — or mapped to an off-vocabulary value — yields
|
|
1533
|
+
* `undefined` rather than an invented task. Inherited keys never resolve.
|
|
1534
|
+
*
|
|
1535
|
+
* @param intent - The classified intent from an interpret pipeline.
|
|
1536
|
+
* @param text - The text the statement derives from.
|
|
1537
|
+
* @param actions - Maps an intent action onto a closed `TaskOperation`.
|
|
1538
|
+
* @param domains - Maps an intent domain onto a closed `TaskDomain`.
|
|
1539
|
+
* @returns The derived `Task`, or `undefined` when either side is unmapped.
|
|
1540
|
+
*
|
|
1541
|
+
* @example
|
|
1542
|
+
* ```ts
|
|
1543
|
+
* import { deriveTask } from '@orkestrel/brief'
|
|
1544
|
+
*
|
|
1545
|
+
* const intent = { action: 'migrate', domain: 'code', confidence: 1 }
|
|
1546
|
+
* deriveTask(intent, 'migrate the stores', { migrate: 'migrate' }, { code: 'code' })
|
|
1547
|
+
* // { operation: 'migrate', domain: 'code', statement: 'Migrate the stores.' }
|
|
1548
|
+
* deriveTask(intent, 'migrate the stores', {}, { code: 'code' }) // undefined
|
|
1549
|
+
* ```
|
|
1550
|
+
*/
|
|
1551
|
+
function deriveTask(intent, text, actions, domains) {
|
|
1552
|
+
const operation = Object.hasOwn(actions, intent.action) ? actions[intent.action] : void 0;
|
|
1553
|
+
const domain = Object.hasOwn(domains, intent.domain) ? domains[intent.domain] : void 0;
|
|
1554
|
+
if (!isTaskOperation(operation) || !isTaskDomain(domain)) return void 0;
|
|
1555
|
+
const statement = deriveStatement(text);
|
|
1556
|
+
return statement.length === 0 ? void 0 : task(operation, domain, statement);
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Derive `Given[]` from an interprets `Entity[]`.
|
|
1560
|
+
*
|
|
1561
|
+
* @remarks
|
|
1562
|
+
* Every extracted entity becomes one `extracted` fact. A nameless entity is dropped; an
|
|
1563
|
+
* object value renders through interprets `canonicalize`, so the text is key-order stable.
|
|
1564
|
+
*
|
|
1565
|
+
* @param entities - The entities an interpret pipeline extracted.
|
|
1566
|
+
* @returns One `Given` per named entity, in extraction order.
|
|
1567
|
+
*
|
|
1568
|
+
* @example
|
|
1569
|
+
* ```ts
|
|
1570
|
+
* import { deriveGivens } from '@orkestrel/brief'
|
|
1571
|
+
*
|
|
1572
|
+
* deriveGivens([
|
|
1573
|
+
* { name: 'value', value: 3, provenance: { category: 'extracted' }, confidence: 1 },
|
|
1574
|
+
* ]) // [{ category: 'extracted', name: 'value', value: '3' }]
|
|
1575
|
+
* ```
|
|
1576
|
+
*/
|
|
1577
|
+
function deriveGivens(entities) {
|
|
1578
|
+
return entities.filter((entity) => entity.name.length > 0).map((entity) => given("extracted", entity.name, typeof entity.value === "string" ? entity.value : typeof entity.value === "object" && entity.value !== null ? canonicalize(entity.value) : String(entity.value)));
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* Derive `Gap[]` from an interprets `Ambiguity[]`.
|
|
1582
|
+
*
|
|
1583
|
+
* @remarks
|
|
1584
|
+
* A REQUIRED ambiguity becomes a BLOCKING gap — the gate must fail closed on it. The
|
|
1585
|
+
* rest stay open, to be answered with a recorded assumption. An array field path flattens
|
|
1586
|
+
* through reasons `formatField`.
|
|
1587
|
+
*
|
|
1588
|
+
* @param ambiguities - The ambiguities an interpret pipeline surfaced.
|
|
1589
|
+
* @returns One `Gap` per ambiguity, in surfacing order.
|
|
1590
|
+
*
|
|
1591
|
+
* @example
|
|
1592
|
+
* ```ts
|
|
1593
|
+
* import { deriveGaps } from '@orkestrel/brief'
|
|
1594
|
+
*
|
|
1595
|
+
* deriveGaps([{ field: 'output', question: 'Diff or files?', candidates: [], required: true }])
|
|
1596
|
+
* // [{ field: 'output', question: 'Diff or files?', blocking: true }]
|
|
1597
|
+
* ```
|
|
1598
|
+
*/
|
|
1599
|
+
function deriveGaps(ambiguities) {
|
|
1600
|
+
return ambiguities.map((ambiguity) => {
|
|
1601
|
+
const candidates = ambiguity.candidates.filter((candidate) => candidate.length > 0);
|
|
1602
|
+
return gap(formatField(ambiguity.field), ambiguity.question, {
|
|
1603
|
+
blocking: ambiguity.required,
|
|
1604
|
+
...candidates.length === 0 ? {} : { candidates }
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region src/core/parsers.ts
|
|
1610
|
+
/**
|
|
1611
|
+
* Parse a JSON string into a `Brief`.
|
|
1612
|
+
*
|
|
1613
|
+
* @remarks
|
|
1614
|
+
* The parse-then-trust boundary for a stored brief, a tool argument, or an agent's
|
|
1615
|
+
* emission. Invalid JSON, an extra key, an off-vocabulary literal, and a missing section
|
|
1616
|
+
* all fail the same way — `undefined`, never a throw. Coerce a bare vocabulary value with
|
|
1617
|
+
* `parseEnum` from `@orkestrel/contract` against the exported tuple instead.
|
|
1618
|
+
*
|
|
1619
|
+
* @param value - The JSON text to parse.
|
|
1620
|
+
* @returns The `Brief` when the parsed value satisfies `isBrief`, otherwise `undefined`.
|
|
1621
|
+
*
|
|
1622
|
+
* @example
|
|
1623
|
+
* ```ts
|
|
1624
|
+
* import { parseBrief } from '@orkestrel/brief'
|
|
1625
|
+
*
|
|
1626
|
+
* parseBrief('not json') // undefined
|
|
1627
|
+
* parseBrief('{"task":{"operation":"plan","domain":"ops","statement":"x."}}') // undefined
|
|
1628
|
+
* ```
|
|
1629
|
+
*/
|
|
1630
|
+
function parseBrief(value) {
|
|
1631
|
+
return parseJSONAs(value, isBrief);
|
|
1632
|
+
}
|
|
1633
|
+
//#endregion
|
|
1634
|
+
//#region src/core/BriefManager.ts
|
|
1635
|
+
/**
|
|
1636
|
+
* The self-owning, versioned and content-hashed brief registry.
|
|
1637
|
+
*
|
|
1638
|
+
* @remarks
|
|
1639
|
+
* Record ids are MINTED from each brief's own content hash unless the caller names one,
|
|
1640
|
+
* so registering unchanged content twice is a version no-op and two callers who compiled
|
|
1641
|
+
* the same request land on the same id with no coordination. A call after `destroy()`
|
|
1642
|
+
* throws `BriefError('DESTROYED', …)`.
|
|
1643
|
+
*
|
|
1644
|
+
* @example
|
|
1645
|
+
* ```ts
|
|
1646
|
+
* import { BriefManager, brief, task } from '@orkestrel/brief'
|
|
1647
|
+
*
|
|
1648
|
+
* const briefs = new BriefManager()
|
|
1649
|
+
* const record = briefs.add(brief(task('document', 'writing', 'Write the brief guide.')))
|
|
1650
|
+
* record.id === record.hash // true
|
|
1651
|
+
* briefs.destroy()
|
|
1652
|
+
* ```
|
|
1653
|
+
*/
|
|
1654
|
+
var BriefManager = class {
|
|
1655
|
+
#emitter;
|
|
1656
|
+
#records = /* @__PURE__ */ new Map();
|
|
1657
|
+
#destroyed = false;
|
|
1658
|
+
constructor(options) {
|
|
1659
|
+
const hooks = options?.on;
|
|
1660
|
+
const failed = options?.error;
|
|
1661
|
+
const seeds = options?.briefs ?? [];
|
|
1662
|
+
const staged = /* @__PURE__ */ new Map();
|
|
1663
|
+
for (const entry of seeds) {
|
|
1664
|
+
const record = this.#stage(entry, staged);
|
|
1665
|
+
staged.set(record.id, record);
|
|
1666
|
+
}
|
|
1667
|
+
this.#emitter = new Emitter({
|
|
1668
|
+
...hooks === void 0 ? {} : { on: hooks },
|
|
1669
|
+
...failed === void 0 ? {} : { error: failed }
|
|
1670
|
+
});
|
|
1671
|
+
for (const record of staged.values()) this.#commit(record);
|
|
1672
|
+
}
|
|
1673
|
+
get emitter() {
|
|
1674
|
+
return this.#emitter;
|
|
1675
|
+
}
|
|
1676
|
+
get size() {
|
|
1677
|
+
return this.#records.size;
|
|
1678
|
+
}
|
|
1679
|
+
has(id) {
|
|
1680
|
+
this.#refuseDestroyed();
|
|
1681
|
+
return this.#records.has(id);
|
|
1682
|
+
}
|
|
1683
|
+
brief(id) {
|
|
1684
|
+
this.#refuseDestroyed();
|
|
1685
|
+
return this.#records.get(id);
|
|
1686
|
+
}
|
|
1687
|
+
briefs() {
|
|
1688
|
+
this.#refuseDestroyed();
|
|
1689
|
+
return [...this.#records.values()];
|
|
1690
|
+
}
|
|
1691
|
+
add(source, options) {
|
|
1692
|
+
this.#refuseDestroyed();
|
|
1693
|
+
const record = this.#stage(source, this.#records, options);
|
|
1694
|
+
this.#commit(record);
|
|
1695
|
+
return record;
|
|
1696
|
+
}
|
|
1697
|
+
remove(target) {
|
|
1698
|
+
this.#refuseDestroyed();
|
|
1699
|
+
if (target === void 0) {
|
|
1700
|
+
for (const id of [...this.#records.keys()]) this.#discard(id);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
if (typeof target === "string") return this.#discard(target);
|
|
1704
|
+
let removed = true;
|
|
1705
|
+
for (const id of new Set(target)) if (!this.#discard(id)) removed = false;
|
|
1706
|
+
return removed;
|
|
1707
|
+
}
|
|
1708
|
+
destroy() {
|
|
1709
|
+
if (this.#destroyed) return;
|
|
1710
|
+
this.#destroyed = true;
|
|
1711
|
+
this.#records.clear();
|
|
1712
|
+
this.#emitter.emit("destroy");
|
|
1713
|
+
this.#emitter.destroy();
|
|
1714
|
+
}
|
|
1715
|
+
#stage(source, against, options) {
|
|
1716
|
+
const owned = snapshotBrief(source);
|
|
1717
|
+
const hash = briefToHash(owned);
|
|
1718
|
+
if (owned.hash !== void 0 && owned.hash !== hash) throw new BriefError("INVALID", "Brief carries a hash that does not describe it", {
|
|
1719
|
+
field: "hash",
|
|
1720
|
+
hash: owned.hash
|
|
1721
|
+
});
|
|
1722
|
+
const trace = briefToTrace(owned);
|
|
1723
|
+
if (owned.trace !== void 0 && owned.trace !== trace) throw new BriefError("INVALID", "Brief carries a trace that does not describe it", {
|
|
1724
|
+
field: "trace",
|
|
1725
|
+
trace: owned.trace
|
|
1726
|
+
});
|
|
1727
|
+
const id = options?.id ?? hash;
|
|
1728
|
+
const previous = against.get(id);
|
|
1729
|
+
return Object.freeze({
|
|
1730
|
+
id,
|
|
1731
|
+
brief: owned,
|
|
1732
|
+
version: previous === void 0 ? 1 : this.#version(previous, owned, hash),
|
|
1733
|
+
hash
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
#commit(record) {
|
|
1737
|
+
this.#records.set(record.id, record);
|
|
1738
|
+
this.#emitter.emit("add", record.id);
|
|
1739
|
+
}
|
|
1740
|
+
#version(previous, incoming, hash) {
|
|
1741
|
+
if (previous.hash !== hash) return previous.version + 1;
|
|
1742
|
+
if (briefToContent(previous.brief) === briefToContent(incoming)) return previous.version;
|
|
1743
|
+
throw new BriefError("INVALID", "Two different briefs share one content hash — name them with distinct ids", {
|
|
1744
|
+
field: "hash",
|
|
1745
|
+
hash
|
|
1746
|
+
});
|
|
1747
|
+
}
|
|
1748
|
+
#discard(id) {
|
|
1749
|
+
if (!this.#records.delete(id)) return false;
|
|
1750
|
+
this.#emitter.emit("remove", id);
|
|
1751
|
+
return true;
|
|
1752
|
+
}
|
|
1753
|
+
#refuseDestroyed() {
|
|
1754
|
+
if (this.#destroyed) throw new BriefError("DESTROYED", "BriefManager has been destroyed");
|
|
1755
|
+
}
|
|
1756
|
+
};
|
|
1757
|
+
//#endregion
|
|
1758
|
+
//#region src/core/BriefCompiler.ts
|
|
1759
|
+
/**
|
|
1760
|
+
* The compilation orchestrator — the four-stage `[interpret, draft, gate, pin]` pipeline.
|
|
1761
|
+
*
|
|
1762
|
+
* @remarks
|
|
1763
|
+
* `compile` is genuinely SYNCHRONOUS and never throws for a brief it cannot emit: a
|
|
1764
|
+
* blocking gap, a refused gate, and a thrown stage all yield a visible INCOMPLETE
|
|
1765
|
+
* `Briefing`. It owns the engines it created and BORROWS the ones passed in, so
|
|
1766
|
+
* `destroy()` releases only what it made.
|
|
1767
|
+
*
|
|
1768
|
+
* @example
|
|
1769
|
+
* ```ts
|
|
1770
|
+
* import { BriefCompiler, proof, task } from '@orkestrel/brief'
|
|
1771
|
+
*
|
|
1772
|
+
* const compiler = new BriefCompiler()
|
|
1773
|
+
* const briefing = compiler.compile({
|
|
1774
|
+
* task: task('audit', 'code', 'Audit the barrel for undocumented exports.'),
|
|
1775
|
+
* outcomes: [{ rank: 1, text: 'every export appears in the guide', required: true }],
|
|
1776
|
+
* proofs: [proof('parity passes', 'npm run test:guides')],
|
|
1777
|
+
* })
|
|
1778
|
+
* briefing.brief !== undefined // true — the presence of the brief IS the completeness test
|
|
1779
|
+
* compiler.destroy()
|
|
1780
|
+
* ```
|
|
1781
|
+
*/
|
|
1782
|
+
var BriefCompiler = class {
|
|
1783
|
+
#emitter;
|
|
1784
|
+
#interpret;
|
|
1785
|
+
#reason;
|
|
1786
|
+
#ownInterpret;
|
|
1787
|
+
#ownReason;
|
|
1788
|
+
#actions;
|
|
1789
|
+
#domains;
|
|
1790
|
+
#destroyed = false;
|
|
1791
|
+
constructor(options) {
|
|
1792
|
+
const hooks = options?.on;
|
|
1793
|
+
const failed = options?.error;
|
|
1794
|
+
const borrowedInterpret = options?.interpret;
|
|
1795
|
+
const borrowedReason = options?.reason;
|
|
1796
|
+
this.#emitter = new Emitter({
|
|
1797
|
+
...hooks === void 0 ? {} : { on: hooks },
|
|
1798
|
+
...failed === void 0 ? {} : { error: failed }
|
|
1799
|
+
});
|
|
1800
|
+
this.#ownInterpret = borrowedInterpret === void 0;
|
|
1801
|
+
this.#ownReason = borrowedReason === void 0;
|
|
1802
|
+
this.#interpret = borrowedInterpret ?? createInterpret();
|
|
1803
|
+
this.#reason = borrowedReason ?? createReason({ reasoners: [createLogicalReasoner()] });
|
|
1804
|
+
this.#actions = options?.actions ?? {};
|
|
1805
|
+
this.#domains = options?.domains ?? {};
|
|
1806
|
+
}
|
|
1807
|
+
get emitter() {
|
|
1808
|
+
return this.#emitter;
|
|
1809
|
+
}
|
|
1810
|
+
get interpret() {
|
|
1811
|
+
return this.#interpret;
|
|
1812
|
+
}
|
|
1813
|
+
get reason() {
|
|
1814
|
+
return this.#reason;
|
|
1815
|
+
}
|
|
1816
|
+
compile(input) {
|
|
1817
|
+
this.#refuseDestroyed();
|
|
1818
|
+
const stages = [];
|
|
1819
|
+
const failures = [];
|
|
1820
|
+
const taken = attempt(() => this.#snapshot(input));
|
|
1821
|
+
if (!taken.success) {
|
|
1822
|
+
const message = errorToMessage(taken.error);
|
|
1823
|
+
stages.push(Object.freeze({
|
|
1824
|
+
stage: "draft",
|
|
1825
|
+
input: {},
|
|
1826
|
+
error: message
|
|
1827
|
+
}));
|
|
1828
|
+
failures.push(Object.freeze({
|
|
1829
|
+
stage: "draft",
|
|
1830
|
+
code: "DRAFT_FAILED",
|
|
1831
|
+
message
|
|
1832
|
+
}));
|
|
1833
|
+
this.#emitter.emit("error", taken.error);
|
|
1834
|
+
return this.#refuse(void 0, void 0, [], void 0, stages, failures);
|
|
1835
|
+
}
|
|
1836
|
+
const owned = taken.value;
|
|
1837
|
+
const interpretation = this.#read(owned, stages, failures);
|
|
1838
|
+
const drafted = attempt(() => this.#draft(owned, interpretation, this.#unresolved(interpretation, failures)));
|
|
1839
|
+
if (!drafted.success) {
|
|
1840
|
+
const message = errorToMessage(drafted.error);
|
|
1841
|
+
stages.push(Object.freeze({
|
|
1842
|
+
stage: "draft",
|
|
1843
|
+
input: owned,
|
|
1844
|
+
error: message
|
|
1845
|
+
}));
|
|
1846
|
+
failures.push(Object.freeze({
|
|
1847
|
+
stage: "draft",
|
|
1848
|
+
code: "DRAFT_FAILED",
|
|
1849
|
+
message
|
|
1850
|
+
}));
|
|
1851
|
+
this.#emitter.emit("error", drafted.error);
|
|
1852
|
+
return this.#refuse(interpretation, void 0, [], void 0, stages, failures);
|
|
1853
|
+
}
|
|
1854
|
+
const draft = drafted.value;
|
|
1855
|
+
stages.push(Object.freeze({
|
|
1856
|
+
stage: "draft",
|
|
1857
|
+
input: owned,
|
|
1858
|
+
output: draft
|
|
1859
|
+
}));
|
|
1860
|
+
const questions = findBlockingGaps(draft);
|
|
1861
|
+
const subject = Object.freeze(briefToSubject(draft));
|
|
1862
|
+
const ruled = attempt(() => this.gate(draft));
|
|
1863
|
+
if (ruled.success) stages.push(Object.freeze({
|
|
1864
|
+
stage: "gate",
|
|
1865
|
+
input: subject,
|
|
1866
|
+
output: ruled.value
|
|
1867
|
+
}));
|
|
1868
|
+
else {
|
|
1869
|
+
const message = errorToMessage(ruled.error);
|
|
1870
|
+
stages.push(Object.freeze({
|
|
1871
|
+
stage: "gate",
|
|
1872
|
+
input: subject,
|
|
1873
|
+
error: message
|
|
1874
|
+
}));
|
|
1875
|
+
failures.push(Object.freeze({
|
|
1876
|
+
stage: "gate",
|
|
1877
|
+
code: "GATE_FAILED",
|
|
1878
|
+
message
|
|
1879
|
+
}));
|
|
1880
|
+
this.#emitter.emit("error", ruled.error);
|
|
1881
|
+
}
|
|
1882
|
+
const verdict = ruled.success ? ruled.value : void 0;
|
|
1883
|
+
const unready = findUnmetRules(draft);
|
|
1884
|
+
if (unready.length > 0 || verdict === void 0 || !verdict.conclusion) {
|
|
1885
|
+
const refusal = this.#blockage(questions, unready, verdict);
|
|
1886
|
+
if (refusal !== void 0) failures.push(Object.freeze(refusal));
|
|
1887
|
+
return this.#refuse(interpretation, draft, questions, verdict, stages, failures);
|
|
1888
|
+
}
|
|
1889
|
+
const stamped = attempt(() => pinBrief(draft));
|
|
1890
|
+
if (!stamped.success) {
|
|
1891
|
+
const message = errorToMessage(stamped.error);
|
|
1892
|
+
stages.push(Object.freeze({
|
|
1893
|
+
stage: "pin",
|
|
1894
|
+
input: draft,
|
|
1895
|
+
error: message
|
|
1896
|
+
}));
|
|
1897
|
+
failures.push(Object.freeze({
|
|
1898
|
+
stage: "pin",
|
|
1899
|
+
code: "PIN_FAILED",
|
|
1900
|
+
message
|
|
1901
|
+
}));
|
|
1902
|
+
this.#emitter.emit("error", stamped.error);
|
|
1903
|
+
return this.#refuse(interpretation, draft, questions, verdict, stages, failures);
|
|
1904
|
+
}
|
|
1905
|
+
const pinned = stamped.value;
|
|
1906
|
+
stages.push(Object.freeze({
|
|
1907
|
+
stage: "pin",
|
|
1908
|
+
input: draft,
|
|
1909
|
+
output: pinned
|
|
1910
|
+
}));
|
|
1911
|
+
const briefing = Object.freeze({
|
|
1912
|
+
...interpretation === void 0 ? {} : { interpretation },
|
|
1913
|
+
brief: pinned,
|
|
1914
|
+
questions: Object.freeze([]),
|
|
1915
|
+
verdict,
|
|
1916
|
+
stages: Object.freeze([...stages]),
|
|
1917
|
+
failures: Object.freeze([...failures]),
|
|
1918
|
+
digest: digestValue({
|
|
1919
|
+
brief: pinned,
|
|
1920
|
+
questions: [],
|
|
1921
|
+
failures
|
|
1922
|
+
})
|
|
1923
|
+
});
|
|
1924
|
+
this.#emitter.emit("compile", briefing);
|
|
1925
|
+
return briefing;
|
|
1926
|
+
}
|
|
1927
|
+
gate(source) {
|
|
1928
|
+
this.#refuseDestroyed();
|
|
1929
|
+
const ruled = attempt(() => this.#own(this.#reason.reason(briefToSubject(source), gateDefinition())));
|
|
1930
|
+
if (!ruled.success) throw new BriefError("GATE_FAILED", errorToMessage(ruled.error), {
|
|
1931
|
+
stage: "gate",
|
|
1932
|
+
field: "reason"
|
|
1933
|
+
});
|
|
1934
|
+
const verdict = ruled.value;
|
|
1935
|
+
if (!isLogicalVerdict(verdict)) throw new BriefError("GATE_FAILED", "The gate reasoner returned a non-logical result", {
|
|
1936
|
+
stage: "gate",
|
|
1937
|
+
field: "reasoning"
|
|
1938
|
+
});
|
|
1939
|
+
return verdict;
|
|
1940
|
+
}
|
|
1941
|
+
destroy() {
|
|
1942
|
+
if (this.#destroyed) return;
|
|
1943
|
+
this.#destroyed = true;
|
|
1944
|
+
if (this.#ownInterpret) this.#interpret.destroy();
|
|
1945
|
+
if (this.#ownReason) this.#reason.destroy();
|
|
1946
|
+
this.#emitter.emit("destroy");
|
|
1947
|
+
this.#emitter.destroy();
|
|
1948
|
+
}
|
|
1949
|
+
#snapshot(input) {
|
|
1950
|
+
return freezeDeep(structuredClone(input));
|
|
1951
|
+
}
|
|
1952
|
+
#own(value) {
|
|
1953
|
+
const cloned = attempt(() => structuredClone(value));
|
|
1954
|
+
return freezeDeep(cloned.success ? cloned.value : value);
|
|
1955
|
+
}
|
|
1956
|
+
#read(input, stages, failures) {
|
|
1957
|
+
const text = input.text;
|
|
1958
|
+
if (text === void 0) return input.interpretation;
|
|
1959
|
+
const read = attempt(() => this.#own(this.#interpret.interpret(text)));
|
|
1960
|
+
if (read.success) {
|
|
1961
|
+
stages.push(Object.freeze({
|
|
1962
|
+
stage: "interpret",
|
|
1963
|
+
input: text,
|
|
1964
|
+
output: read.value
|
|
1965
|
+
}));
|
|
1966
|
+
return read.value;
|
|
1967
|
+
}
|
|
1968
|
+
const message = errorToMessage(read.error);
|
|
1969
|
+
stages.push(Object.freeze({
|
|
1970
|
+
stage: "interpret",
|
|
1971
|
+
input: text,
|
|
1972
|
+
error: message
|
|
1973
|
+
}));
|
|
1974
|
+
failures.push(Object.freeze({
|
|
1975
|
+
stage: "interpret",
|
|
1976
|
+
code: "INTERPRET_FAILED",
|
|
1977
|
+
message
|
|
1978
|
+
}));
|
|
1979
|
+
this.#emitter.emit("error", read.error);
|
|
1980
|
+
return input.interpretation;
|
|
1981
|
+
}
|
|
1982
|
+
#blockage(questions, unready, verdict) {
|
|
1983
|
+
if (questions.length > 0) return {
|
|
1984
|
+
stage: "gate",
|
|
1985
|
+
code: "BLOCKED",
|
|
1986
|
+
message: `${String(questions.length)} blocking gap(s)`
|
|
1987
|
+
};
|
|
1988
|
+
if (unready.length > 0) return {
|
|
1989
|
+
stage: "gate",
|
|
1990
|
+
code: "BLOCKED",
|
|
1991
|
+
message: `Gate refused: ${unready.join(", ")}`
|
|
1992
|
+
};
|
|
1993
|
+
if (!isLogicalVerdict(verdict)) return void 0;
|
|
1994
|
+
const refused = verdict.rules.filter((entry) => !entry.conclusion).map((entry) => entry.id).join(", ");
|
|
1995
|
+
if (refused.length === 0) return {
|
|
1996
|
+
stage: "gate",
|
|
1997
|
+
code: "BLOCKED",
|
|
1998
|
+
message: "Gate refused: the supplied reasoner named no failing rule"
|
|
1999
|
+
};
|
|
2000
|
+
return {
|
|
2001
|
+
stage: "gate",
|
|
2002
|
+
code: "BLOCKED",
|
|
2003
|
+
message: `Gate refused: ${refused}`
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
#unresolved(interpretation, failures) {
|
|
2007
|
+
if (interpretation !== void 0) return [];
|
|
2008
|
+
if (!failures.some((entry) => entry.stage === "interpret")) return [];
|
|
2009
|
+
return [gap("gaps", "The interpret stage failed, so the request is unread and its unknowns are unknown", { blocking: true })];
|
|
2010
|
+
}
|
|
2011
|
+
#draft(input, interpretation, unresolved) {
|
|
2012
|
+
const derived = interpretation === void 0 ? void 0 : deriveTask(interpretation.intent, interpretation.text, this.#actions, this.#domains);
|
|
2013
|
+
const subject = input.task ?? derived;
|
|
2014
|
+
if (subject === void 0) throw new BriefError("DRAFT_FAILED", "No task: supply BriefInput.task, or map the intent through the actions and domains vocabularies", {
|
|
2015
|
+
stage: "draft",
|
|
2016
|
+
field: "task"
|
|
2017
|
+
});
|
|
2018
|
+
return snapshotBrief(brief(subject, {
|
|
2019
|
+
authority: input.authority ?? [],
|
|
2020
|
+
manifest: input.manifest ?? manifest(),
|
|
2021
|
+
outcomes: input.outcomes ?? [],
|
|
2022
|
+
rules: input.rules ?? [],
|
|
2023
|
+
invariants: input.invariants ?? [],
|
|
2024
|
+
givens: [...interpretation === void 0 ? [] : deriveGivens(interpretation.entities), ...input.givens ?? []],
|
|
2025
|
+
examples: input.examples ?? [],
|
|
2026
|
+
assumptions: input.assumptions ?? [],
|
|
2027
|
+
citations: input.citations ?? [],
|
|
2028
|
+
gaps: [
|
|
2029
|
+
...interpretation === void 0 ? [] : deriveGaps(interpretation.ambiguities),
|
|
2030
|
+
...unresolved,
|
|
2031
|
+
...input.gaps ?? []
|
|
2032
|
+
],
|
|
2033
|
+
risks: input.risks ?? [],
|
|
2034
|
+
output: input.output ?? output("markdown"),
|
|
2035
|
+
proofs: input.proofs ?? []
|
|
2036
|
+
}));
|
|
2037
|
+
}
|
|
2038
|
+
#refuse(interpretation, draft, questions, verdict, stages, failures) {
|
|
2039
|
+
const asked = Object.freeze([...questions]);
|
|
2040
|
+
const briefing = Object.freeze({
|
|
2041
|
+
...interpretation === void 0 ? {} : { interpretation },
|
|
2042
|
+
questions: asked,
|
|
2043
|
+
...verdict === void 0 ? {} : { verdict },
|
|
2044
|
+
stages: Object.freeze([...stages]),
|
|
2045
|
+
failures: Object.freeze([...failures]),
|
|
2046
|
+
digest: digestValue({
|
|
2047
|
+
...draft === void 0 ? {} : { brief: draft },
|
|
2048
|
+
questions,
|
|
2049
|
+
failures
|
|
2050
|
+
})
|
|
2051
|
+
});
|
|
2052
|
+
this.#emitter.emit("block", asked);
|
|
2053
|
+
return briefing;
|
|
2054
|
+
}
|
|
2055
|
+
#refuseDestroyed() {
|
|
2056
|
+
if (this.#destroyed) throw new BriefError("DESTROYED", "BriefCompiler has been destroyed");
|
|
2057
|
+
}
|
|
2058
|
+
};
|
|
2059
|
+
//#endregion
|
|
2060
|
+
//#region src/core/factories.ts
|
|
2061
|
+
/**
|
|
2062
|
+
* Create a compilation orchestrator.
|
|
2063
|
+
*
|
|
2064
|
+
* @remarks
|
|
2065
|
+
* With no engines supplied the compiler wires its own: a default `createInterpret()`
|
|
2066
|
+
* (empty vocabularies, so `options.actions` / `options.domains` drive `deriveTask`) and a
|
|
2067
|
+
* `createReason` carrying one `LogicalReasoner` for the gate. Pass your own to share
|
|
2068
|
+
* instances or observe their emitters — the compiler destroys ONLY what it created.
|
|
2069
|
+
*
|
|
2070
|
+
* @param options - Engines to borrow, the two intent vocabularies, and emitter hooks.
|
|
2071
|
+
* @returns A working {@link BriefCompilerInterface}.
|
|
2072
|
+
*
|
|
2073
|
+
* @example
|
|
2074
|
+
* ```ts
|
|
2075
|
+
* import { createBriefCompiler } from '@orkestrel/brief'
|
|
2076
|
+
*
|
|
2077
|
+
* const compiler = createBriefCompiler({ actions: { refactor: 'refactor' }, domains: { code: 'code' } })
|
|
2078
|
+
* compiler.destroy()
|
|
2079
|
+
* ```
|
|
2080
|
+
*/
|
|
2081
|
+
function createBriefCompiler(options) {
|
|
2082
|
+
return new BriefCompiler(options);
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Create a brief registry.
|
|
2086
|
+
*
|
|
2087
|
+
* @param options - An optional seed collection plus emitter hooks.
|
|
2088
|
+
* @returns A working {@link BriefManagerInterface}.
|
|
2089
|
+
*
|
|
2090
|
+
* @example
|
|
2091
|
+
* ```ts
|
|
2092
|
+
* import { createBriefManager } from '@orkestrel/brief'
|
|
2093
|
+
*
|
|
2094
|
+
* const briefs = createBriefManager()
|
|
2095
|
+
* briefs.size // 0
|
|
2096
|
+
* briefs.destroy()
|
|
2097
|
+
* ```
|
|
2098
|
+
*/
|
|
2099
|
+
function createBriefManager(options) {
|
|
2100
|
+
return new BriefManager(options);
|
|
2101
|
+
}
|
|
2102
|
+
/**
|
|
2103
|
+
* Compile `briefShape` into a guard, parser, JSON Schema, and seeded generator bundle.
|
|
2104
|
+
*
|
|
2105
|
+
* @remarks
|
|
2106
|
+
* The schema is what a tool boundary needs — hand it to `schemaToParameters` — and
|
|
2107
|
+
* `generate(seededRandom(n))` yields a reproducible on-contract brief for tests. This
|
|
2108
|
+
* bundle and the hand-composed `isBrief` are two independent mechanisms over one
|
|
2109
|
+
* vocabulary; `tests/src/core/shapers.test.ts` is what holds them in lockstep.
|
|
2110
|
+
*
|
|
2111
|
+
* @returns A `ContractInterface` over `Brief`.
|
|
2112
|
+
*
|
|
2113
|
+
* @example
|
|
2114
|
+
* ```ts
|
|
2115
|
+
* import { createBriefContract } from '@orkestrel/brief'
|
|
2116
|
+
* import { schemaToParameters, seededRandom } from '@orkestrel/contract'
|
|
2117
|
+
*
|
|
2118
|
+
* const contract = createBriefContract()
|
|
2119
|
+
* schemaToParameters(contract.schema) // the open tool-parameters record, no `as` anywhere
|
|
2120
|
+
* contract.generate(seededRandom(42)) // a reproducible on-contract brief
|
|
2121
|
+
* ```
|
|
2122
|
+
*/
|
|
2123
|
+
function createBriefContract() {
|
|
2124
|
+
return createContract(briefShape);
|
|
2125
|
+
}
|
|
2126
|
+
//#endregion
|
|
2127
|
+
export { BLANK_PATTERN, BriefCompiler, BriefError, BriefManager, DEFAULT_BRIEF_TURNS, GATE_ID, LINE_BREAK_PATTERN, OUTPUT_FORMATS, RISK_SEVERITIES, SINGLE_LINE_PATTERN, TASK_DOMAINS, TASK_OPERATIONS, assertBrief, brief, briefShape, briefToContent, briefToDispatch, briefToGoal, briefToHash, briefToMarkdown, briefToSubject, briefToTrace, citation, citationShape, countSentences, createBriefCompiler, createBriefContract, createBriefManager, deriveGaps, deriveGivens, deriveStatement, deriveTask, errorToMessage, example, exampleShape, exampleToLines, findBlockingGaps, findManifestOverlaps, findUngrantedAuthority, findUnmetRules, findUnpairedGaps, freezeBranch, freezeDeep, gap, gapShape, gateDefinition, given, givenShape, isBrief, isBriefError, isCitation, isExample, isGap, isGiven, isLine, isLogicalVerdict, isManifest, isObject, isOutcome, isOutput, isOutputFormat, isProof, isReference, isRisk, isRiskSeverity, isRuleVerdict, isTask, isTaskDomain, isTaskOperation, isText, lineShape, manifest, manifestShape, outcome, outcomeShape, output, outputShape, parseBrief, pinBrief, proof, proofShape, reference, referenceShape, risk, riskShape, snapshotBrief, task, taskShape, textShape, validateBrief };
|
|
2128
|
+
|
|
2129
|
+
//# sourceMappingURL=index.js.map
|