@sabaiway/agent-workflow-kit 1.16.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +2 -1
- package/SKILL.md +37 -7
- package/bridges/codex-cli-bridge/SKILL.md +81 -28
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +337 -44
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +593 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +279 -19
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +542 -0
- package/bridges/codex-cli-bridge/capability.json +1 -1
- package/bridges/codex-cli-bridge/references/driving-codex.md +67 -40
- package/bridges/codex-cli-bridge/references/sandbox-and-flags.md +148 -43
- package/bridges/codex-cli-bridge/setup/README.md +6 -2
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/templates/orchestration.json +8 -3
- package/tools/commands.mjs +7 -0
- package/tools/family-registry.mjs +3 -1
- package/tools/inject-methodology.mjs +90 -16
- package/tools/orchestration-config.mjs +297 -0
- package/tools/orchestration-write.mjs +97 -0
- package/tools/procedures.mjs +17 -102
- package/tools/set-recipe.mjs +217 -0
- package/tools/setup-backends.mjs +71 -5
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
// STOP loudly (never a silent fallback) when the engine is needed but absent/invalid.
|
|
19
19
|
//
|
|
20
20
|
// Pure string functions (testable with byte-preservation fixtures); dependency-free, Node >= 18.
|
|
21
|
+
//
|
|
22
|
+
// Canonical-refresh (AD-025): a slot filled BEFORE a clause existed is normally preserved verbatim
|
|
23
|
+
// (reconcile only fills an EMPTY slot). To push a NEW canonical clause to the EXISTING filled base
|
|
24
|
+
// without clobbering a user's customization, reconcileMarkerSlot also REFRESHES a filled slot — but
|
|
25
|
+
// ONLY when its content normalize-matches a KNOWN PRIOR canonical fragment (drift-guarded, append-only
|
|
26
|
+
// per descriptor). A customized slot never matches → preserved verbatim + a read-only upgrade advisory.
|
|
27
|
+
|
|
28
|
+
import { normalizeCanonical } from './orchestration-config.mjs';
|
|
21
29
|
|
|
22
30
|
export const START_MARKER = '<!-- workflow:methodology:start -->';
|
|
23
31
|
export const END_MARKER = '<!-- workflow:methodology:end -->';
|
|
@@ -57,11 +65,29 @@ export const ORCH_ANCHOR = new RegExp(`^.*${escapeRegExp(END_MARKER)}.*$`, 'm');
|
|
|
57
65
|
export const EMPTY_SLOT = `${START_MARKER}\n${END_MARKER}`;
|
|
58
66
|
export const ORCH_EMPTY_SLOT = `${ORCH_START_MARKER}\n${ORCH_END_MARKER}`;
|
|
59
67
|
|
|
68
|
+
// ── known-prior canonical fragments (drift-guarded, APPEND-ONLY per slot) ───────
|
|
69
|
+
// The EXACT content a PREVIOUS release's engine fragment shipped (what a filled slot would carry today).
|
|
70
|
+
// reconcileMarkerSlot refreshes a filled slot to the current engine fragment IFF its content
|
|
71
|
+
// normalize-matches one of these — so the install base gains a new clause without clobbering a
|
|
72
|
+
// customization (which never matches). Any release that CHANGES an engine fragment must FIRST append the
|
|
73
|
+
// OUTGOING content here, so the immediately-previous deployments still match (a drift-guard test pins
|
|
74
|
+
// current-minus-one → new). NEVER edit an existing entry — only append.
|
|
75
|
+
export const KNOWN_PRIOR_METHODOLOGY_SLOT = [
|
|
76
|
+
// v1.3.0 — pre-communication-contract methodology pointer (procedures route, no §1.9 clause).
|
|
77
|
+
'> **Workflow methodology** — plan → execute → review. Plans are ephemeral `docs/plans/*.md` (gitignored, **never committed**); every Plan ends with a mandatory **Phase: Cleanup**; series order lives in `docs/plans/queue.md`. Full vocabulary, lifecycle, and the plan-then-execute split live in the project\'s **planning skill** (it overrides the generic `writing-plans`); summary in `docs/ai/agent_rules.md` §5. Named activities (plan-authoring, plan-execution) have procedures — see `/agent-workflow-kit procedures <activity>` for the steps + resolved recipe.',
|
|
78
|
+
];
|
|
79
|
+
export const KNOWN_PRIOR_ORCH_SLOT = [
|
|
80
|
+
// v1.3.0 — pre-read-at-start orchestration pointer (recipes vocabulary, no orchestration.json clause).
|
|
81
|
+
'> **Orchestration recipes** — compose plan → execute → review with a named recipe: **Solo** (no backend), **Reviewed** (one backend reviews), **Council** (both review, you synthesize), **Delegated** (a backend executes a bounded sub-task); the orchestrator always commits, a backend is never autonomous. Pick + plan one for this environment with `/agent-workflow-kit recipes` (read-only); the deployed how/why lives in your `docs/ai/` workflow docs.',
|
|
82
|
+
];
|
|
83
|
+
|
|
60
84
|
// A slot descriptor bundles everything the generic engine needs to operate on ONE marker pair.
|
|
61
85
|
// `leadingBlank` controls whether the inserted empty pair gets a blank separator line above it — the
|
|
62
86
|
// methodology insert keeps it (readability), the orchestration insert omits it to save a cap line
|
|
63
|
-
// (the pair already sits right under the methodology block).
|
|
64
|
-
|
|
87
|
+
// (the pair already sits right under the methodology block). `knownPriorCanonicals` drives the refresh;
|
|
88
|
+
// `upgradeSignature` + `upgradeAdvice` drive the read-only advisory for a CUSTOMIZED (non-matching) slot
|
|
89
|
+
// that predates the current clause.
|
|
90
|
+
export const METHODOLOGY_DESCRIPTOR = {
|
|
65
91
|
startMarker: START_MARKER,
|
|
66
92
|
endMarker: END_MARKER,
|
|
67
93
|
anchor: METHODOLOGY_ANCHOR,
|
|
@@ -69,6 +95,10 @@ const METHODOLOGY_DESCRIPTOR = {
|
|
|
69
95
|
leadingBlank: true,
|
|
70
96
|
markerName: 'methodology',
|
|
71
97
|
anchorLabel: 'methodology anchor (the "Read it before any code change." Session-Protocols line)',
|
|
98
|
+
knownPriorCanonicals: KNOWN_PRIOR_METHODOLOGY_SLOT,
|
|
99
|
+
upgradeSignature: 'Communication',
|
|
100
|
+
upgradeAdvice:
|
|
101
|
+
'the workflow-methodology pointer predates the communication-contract clause (deliver the artifact inline, lead with the result) — refresh it to the current canon, or add the clause by hand; the contract still applies.',
|
|
72
102
|
};
|
|
73
103
|
export const ORCHESTRATION_DESCRIPTOR = {
|
|
74
104
|
startMarker: ORCH_START_MARKER,
|
|
@@ -78,6 +108,10 @@ export const ORCHESTRATION_DESCRIPTOR = {
|
|
|
78
108
|
leadingBlank: false,
|
|
79
109
|
markerName: 'orchestration',
|
|
80
110
|
anchorLabel: 'orchestration anchor (the methodology end-marker line)',
|
|
111
|
+
knownPriorCanonicals: KNOWN_PRIOR_ORCH_SLOT,
|
|
112
|
+
upgradeSignature: '/agent-workflow-kit set-recipe',
|
|
113
|
+
upgradeAdvice:
|
|
114
|
+
'the orchestration-recipes pointer predates the read-at-start clause — add "read `docs/ai/orchestration.json` at session start; set it with /agent-workflow-kit set-recipe", or set your preference now with /agent-workflow-kit set-recipe.',
|
|
81
115
|
};
|
|
82
116
|
|
|
83
117
|
// ── generic marker-slot engine (descriptor-parameterized) ──────────────────────
|
|
@@ -160,16 +194,27 @@ export const ensureMarkerSlot = (text, descriptor) => {
|
|
|
160
194
|
// Bootstrap/upgrade reconciliation policy (pure): ensure the slot exists, then fill it ONLY IF it
|
|
161
195
|
// is empty (a filled/customized slot is preserved verbatim), enforcing the line cap — all as one step
|
|
162
196
|
// the CLI commits with a single atomic write. On ANY error the INPUT bytes are returned unchanged.
|
|
163
|
-
// reconciled-inserted
|
|
164
|
-
// reconciled-filled
|
|
165
|
-
//
|
|
166
|
-
//
|
|
197
|
+
// reconciled-inserted — slot was absent, inserted at the anchor, then filled.
|
|
198
|
+
// reconciled-filled — slot existed but was empty, now filled.
|
|
199
|
+
// reconciled-refreshed — slot was filled with a KNOWN PRIOR canonical → replaced with `fragment`.
|
|
200
|
+
// present-filled — slot already carried CUSTOM content → preserved verbatim.
|
|
201
|
+
// error — malformed slot, 0/>1 anchors, or cap exceeded → input unchanged.
|
|
167
202
|
export const reconcileMarkerSlot = (text, descriptor, fragment, { maxLines } = {}) => {
|
|
168
203
|
const ensured = ensureMarkerSlot(text, descriptor);
|
|
169
204
|
if (ensured.status === 'error') return { status: 'error', text, error: ensured.error };
|
|
170
205
|
const current = extractMarkerSlot(ensured.text, descriptor);
|
|
171
206
|
const isEmpty = current == null || current.trim() === '';
|
|
172
207
|
if (!isEmpty) {
|
|
208
|
+
// Canonical-refresh: a slot whose content normalize-matches a known prior canonical is STALE — replace
|
|
209
|
+
// it with the current `fragment`. A customized slot never matches → preserved verbatim. (`fragment`
|
|
210
|
+
// is empty for a customized slot — main() only sources it when markerSlotNeedsFill is true.)
|
|
211
|
+
const priors = descriptor.knownPriorCanonicals ?? [];
|
|
212
|
+
const matchesPrior = fragment && fragment.trim() !== '' && priors.some((p) => normalizeCanonical(p) === normalizeCanonical(current));
|
|
213
|
+
if (matchesPrior) {
|
|
214
|
+
const injected = injectIntoSlot(ensured.text, descriptor, fragment, { maxLines });
|
|
215
|
+
if (injected.status !== 'injected') return { status: 'error', text, error: injected.error };
|
|
216
|
+
return { status: 'reconciled-refreshed', text: injected.text };
|
|
217
|
+
}
|
|
173
218
|
if (maxLines != null && lineCount(ensured.text) > maxLines) {
|
|
174
219
|
return { status: 'error', text, error: `AGENTS.md is ${lineCount(ensured.text)} lines (cap ${maxLines}) — trim the file (a customized ${descriptor.markerName} slot must still fit the cap)` };
|
|
175
220
|
}
|
|
@@ -189,7 +234,11 @@ export const markerSlotNeedsFill = (text, descriptor) => {
|
|
|
189
234
|
const ensured = ensureMarkerSlot(text, descriptor);
|
|
190
235
|
if (ensured.status === 'error') return false;
|
|
191
236
|
const current = extractMarkerSlot(ensured.text, descriptor);
|
|
192
|
-
|
|
237
|
+
if (current == null || current.trim() === '') return true; // empty → fill
|
|
238
|
+
// filled-but-STALE (content matches a known prior canonical) → needs a refresh, so main() must
|
|
239
|
+
// re-source the fragment. A customized slot doesn't match → no source needed (preserved verbatim).
|
|
240
|
+
const priors = descriptor.knownPriorCanonicals ?? [];
|
|
241
|
+
return priors.some((p) => normalizeCanonical(p) === normalizeCanonical(current));
|
|
193
242
|
};
|
|
194
243
|
|
|
195
244
|
// ── methodology-slot exports (delegate to the generic engine, byte-for-byte) ────
|
|
@@ -217,6 +266,19 @@ export const methodologyProceduresHint = (text) => {
|
|
|
217
266
|
return `the methodology pointer has no procedures route — add "${PROCEDURES_POINTER} <activity>" for auto-discovery; the activity procedures are reachable now via ${PROCEDURES_POINTER}.`;
|
|
218
267
|
};
|
|
219
268
|
|
|
269
|
+
// Generic read-only upgrade advisory (AD-025; the §1.6a/§1.9 reach to CUSTOMIZED filled slots): a slot
|
|
270
|
+
// that is present + FILLED but lacks the descriptor's current-canon `upgradeSignature` (and so could not
|
|
271
|
+
// be canonical-refreshed — its content is customized) gets a one-line note the upgrade flow surfaces.
|
|
272
|
+
// Returns null for an absent / empty / malformed slot, or one that already carries the signature. Pure;
|
|
273
|
+
// never edits the file (a customization is never rewritten — only the user decides).
|
|
274
|
+
export const markerSlotUpgradeHint = (text, descriptor) => {
|
|
275
|
+
if (!descriptor.upgradeSignature) return null;
|
|
276
|
+
const content = extractMarkerSlot(text, descriptor);
|
|
277
|
+
if (content == null || content.trim() === '') return null; // only a FILLED slot
|
|
278
|
+
if (content.includes(descriptor.upgradeSignature)) return null; // already carries the current clause
|
|
279
|
+
return descriptor.upgradeAdvice;
|
|
280
|
+
};
|
|
281
|
+
|
|
220
282
|
// A cap-refusal is a SOFT, reported skip (distinct from a malformed/anchor STOP) — keyed off the
|
|
221
283
|
// stable "(cap N)" substring both cap messages carry, so the dual-slot reconcile can skip the
|
|
222
284
|
// orchestration pointer (loud) while keeping the methodology fill, instead of aborting both.
|
|
@@ -317,18 +379,23 @@ const main = async (argv) => {
|
|
|
317
379
|
console.error(`[inject-methodology] reconcile refused — ${methResult.error}`);
|
|
318
380
|
process.exit(1);
|
|
319
381
|
}
|
|
320
|
-
const afterMeth = methResult.text; // === text when the methodology slot was already filled
|
|
382
|
+
const afterMeth = methResult.text; // === text when the methodology slot was already filled (custom)
|
|
321
383
|
const describeMeth = {
|
|
322
384
|
'reconciled-inserted': 'inserted the workflow-methodology pointer at the Session-Protocols anchor and filled it',
|
|
323
385
|
'reconciled-filled': 'filled the empty workflow-methodology pointer',
|
|
386
|
+
'reconciled-refreshed': 'refreshed the workflow-methodology pointer to the current canon',
|
|
324
387
|
'present-filled': 'workflow-methodology pointer already present',
|
|
325
388
|
}[methResult.status];
|
|
326
|
-
// Read-only upgrade
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
if (
|
|
389
|
+
// Read-only upgrade advisories for a CUSTOMIZED methodology pointer that reconcile preserved verbatim
|
|
390
|
+
// (a refreshed/filled/inserted slot already carries the current canon, so it gets none): the AD-019
|
|
391
|
+
// procedures route AND the §1.9 communication-contract clause. No mutation — reported notes only.
|
|
392
|
+
const notes = [];
|
|
393
|
+
if (methResult.status === 'present-filled') {
|
|
394
|
+
const p = methodologyProceduresHint(afterMeth); if (p) notes.push(p);
|
|
395
|
+
const u = markerSlotUpgradeHint(afterMeth, METHODOLOGY_DESCRIPTOR); if (u) notes.push(u);
|
|
396
|
+
}
|
|
397
|
+
const reportNotes = () => {
|
|
398
|
+
for (const n of notes) console.log(`[inject-methodology] note: ${n}`);
|
|
332
399
|
};
|
|
333
400
|
|
|
334
401
|
// ── Explicit [fragment.md] binds methodology ONLY → skip the orchestration reconcile ──
|
|
@@ -374,8 +441,15 @@ const main = async (argv) => {
|
|
|
374
441
|
describeOrch = {
|
|
375
442
|
'reconciled-inserted': 'inserted the orchestration-recipes pointer below it and filled it',
|
|
376
443
|
'reconciled-filled': 'filled the empty orchestration-recipes pointer',
|
|
444
|
+
'reconciled-refreshed': 'refreshed the orchestration-recipes pointer to the current canon',
|
|
377
445
|
'present-filled': 'orchestration-recipes pointer already present',
|
|
378
446
|
}[orchResult.status];
|
|
447
|
+
// §1.6a advisory: a CUSTOMIZED orchestration pointer preserved verbatim, lacking the read-at-start
|
|
448
|
+
// clause, gets a read-only nudge (a refreshed/filled slot already carries it).
|
|
449
|
+
if (orchResult.status === 'present-filled') {
|
|
450
|
+
const u = markerSlotUpgradeHint(finalText, ORCHESTRATION_DESCRIPTOR);
|
|
451
|
+
if (u) notes.push(u);
|
|
452
|
+
}
|
|
379
453
|
}
|
|
380
454
|
}
|
|
381
455
|
|
|
@@ -384,12 +458,12 @@ const main = async (argv) => {
|
|
|
384
458
|
// Byte-unchanged. Still report a cap-skip (it is not "nothing to do" — a pointer was withheld).
|
|
385
459
|
if (orchSkipped) console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
|
|
386
460
|
else console.log('[inject-methodology] reconcile: both pointers already present and filled — nothing to do (zero-diff).');
|
|
387
|
-
|
|
461
|
+
reportNotes();
|
|
388
462
|
return;
|
|
389
463
|
}
|
|
390
464
|
await writeAtomic(finalText);
|
|
391
465
|
console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
|
|
392
|
-
|
|
466
|
+
reportNotes();
|
|
393
467
|
return;
|
|
394
468
|
}
|
|
395
469
|
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// orchestration-config.mjs — the schema / read / pure-transform core for the per-project
|
|
3
|
+
// orchestration config (docs/ai/orchestration.json). It is the SINGLE source of the config contract:
|
|
4
|
+
//
|
|
5
|
+
// loadConfig / validateConfig / CONFIG_REL — the strict-JSON reader the READ-ONLY surfaces share
|
|
6
|
+
// (procedures.mjs re-exports CONFIG_REL; family-registry
|
|
7
|
+
// + procedures import loadConfig from here).
|
|
8
|
+
// parseOp / assertSlotRecipe — the TYPED op parser + the ONE slot/recipe validity
|
|
9
|
+
// table the set-recipe writer AND procedures --override
|
|
10
|
+
// both reuse (drift-guarded: one accept/reject table).
|
|
11
|
+
// applySetOps / serializeConfig — the PURE merge + the canonical (2-space, _README-first)
|
|
12
|
+
// serializer the writer commits.
|
|
13
|
+
// normalizeCanonical / refreshIfCanonical — the PURE "replace IFF it matches a known prior
|
|
14
|
+
// canonical, else preserve a customization" helper shared
|
|
15
|
+
// by the _README refresh and the injected-slot refresh.
|
|
16
|
+
//
|
|
17
|
+
// This module performs NO filesystem WRITES — only reads (loadConfig). The single fs-writer lives in
|
|
18
|
+
// orchestration-write.mjs, which procedures.mjs never imports, so "procedures never reaches a writer"
|
|
19
|
+
// is structurally true. Pure-where-possible (fs injectable), dependency-free, Node >= 18. No side
|
|
20
|
+
// effects on import.
|
|
21
|
+
|
|
22
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { ACTIVITIES, SLOT_RECIPES } from './recipes.mjs';
|
|
25
|
+
|
|
26
|
+
// The hand-editable / agent-writable, per-project config (strict JSON). cwd-relative — the error prefix
|
|
27
|
+
// uses this rel path so a user sees a path they can open, never an absolute temp/host path.
|
|
28
|
+
export const CONFIG_REL = 'docs/ai/orchestration.json';
|
|
29
|
+
|
|
30
|
+
// A tagged failure: a plain Error carrying the intended process exit code (2 usage / 1 config). Avoids
|
|
31
|
+
// a class (project rule) while letting a CLI main() map a throw to the right code in one place. Shared
|
|
32
|
+
// so procedures.mjs + set-recipe.mjs raise identically-typed errors.
|
|
33
|
+
export const fail = (exitCode, message) => Object.assign(new Error(message), { exitCode });
|
|
34
|
+
|
|
35
|
+
const KNOWN_ACTIVITIES = () => Object.keys(ACTIVITIES).join(', ');
|
|
36
|
+
|
|
37
|
+
// ── the ONE slot/recipe validity table (shared accept/reject) ───────────────────────
|
|
38
|
+
// Both the set-recipe op parser and the procedures --override parser route through these, so the
|
|
39
|
+
// accept/reject decision can never drift between the two surfaces (one table, drift-guarded by tests).
|
|
40
|
+
|
|
41
|
+
// True iff `recipe` is valid for the (activity, slot) pair. Pure predicate; throws nothing.
|
|
42
|
+
export const recipeValidForSlot = (activity, slot, recipe) => {
|
|
43
|
+
const slotType = ACTIVITIES[activity]?.slots?.[slot];
|
|
44
|
+
if (!slotType) return false;
|
|
45
|
+
return (SLOT_RECIPES[slotType] ?? []).includes(recipe);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Assert (activity, slot) is a known slot of a known activity; return its recipe-TYPE. Loud (exit 2)
|
|
49
|
+
// on an unknown activity or an unknown slot — the shared "unknown slot" message both parsers emit.
|
|
50
|
+
export const assertSlot = (activity, slot, exitCode = 2) => {
|
|
51
|
+
const activityDef = ACTIVITIES[activity];
|
|
52
|
+
if (!activityDef) throw fail(exitCode, `unknown activity "${activity}" (known: ${KNOWN_ACTIVITIES()})`);
|
|
53
|
+
const slotType = activityDef.slots[slot];
|
|
54
|
+
if (!slotType) {
|
|
55
|
+
throw fail(
|
|
56
|
+
exitCode,
|
|
57
|
+
`unknown slot "${slot}" for activity "${activity}" (${activity} slots: ${Object.keys(activityDef.slots).join(', ')})`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return slotType;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Assert (activity, slot, recipe) is valid — unknown activity/slot OR invalid-recipe-for-slot → loud.
|
|
64
|
+
// Used by the set-recipe op parser AND the procedures --override parser, so they accept/reject in
|
|
65
|
+
// lockstep. `exitCode` is 2 for both CLIs (usage error); validateConfig reuses it with exitCode 1.
|
|
66
|
+
export const assertSlotRecipe = (activity, slot, recipe, exitCode = 2) => {
|
|
67
|
+
const slotType = assertSlot(activity, slot, exitCode);
|
|
68
|
+
if (!(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
|
|
69
|
+
throw fail(
|
|
70
|
+
exitCode,
|
|
71
|
+
`invalid recipe "${recipe}" for ${slotType} slot of "${activity}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return slotType;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── the typed op parser (usage errors → exit 2) ─────────────────────────────────────
|
|
78
|
+
// The grammar is ALWAYS fully-qualified `<activity>.<slot>` — the writer never guesses an activity. A
|
|
79
|
+
// bare `review=council` is rejected (name the activity). The kit performs no `all`-magic; the agent
|
|
80
|
+
// expands plain language like "both review" into explicit per-activity ops (asking if scope is unclear).
|
|
81
|
+
|
|
82
|
+
const parseQualified = (lhs, flag) => {
|
|
83
|
+
const dot = lhs.indexOf('.');
|
|
84
|
+
if (dot <= 0 || dot === lhs.length - 1) {
|
|
85
|
+
throw fail(
|
|
86
|
+
2,
|
|
87
|
+
`${flag} must be fully-qualified <activity>.<slot> (got "${lhs}") — name the activity, e.g. plan-authoring.review / plan-execution.review`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return { activity: lhs.slice(0, dot), slot: lhs.slice(dot + 1) };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// parseOp(kind, token) → a typed record:
|
|
94
|
+
// kind 'set' + token `<activity>.<slot>=<recipe>` → { kind:'set', activity, slot, recipe }
|
|
95
|
+
// kind 'unset' + token `<activity>.<slot>` → { kind:'unset', activity, slot }
|
|
96
|
+
// Every malformed token is a USAGE error (exit 2): a bare recipe (no activity), an unknown activity /
|
|
97
|
+
// slot, an invalid recipe-for-slot, a missing recipe on --set, or a stray recipe on --unset.
|
|
98
|
+
export const parseOp = (kind, token) => {
|
|
99
|
+
if (kind === 'set') {
|
|
100
|
+
const eq = token.indexOf('=');
|
|
101
|
+
if (eq <= 0) throw fail(2, `--set must be <activity>.<slot>=<recipe> (got "${token}")`);
|
|
102
|
+
const recipe = token.slice(eq + 1);
|
|
103
|
+
if (!recipe) throw fail(2, `--set must be <activity>.<slot>=<recipe> (got "${token}")`);
|
|
104
|
+
const { activity, slot } = parseQualified(token.slice(0, eq), '--set');
|
|
105
|
+
assertSlotRecipe(activity, slot, recipe);
|
|
106
|
+
return { kind: 'set', activity, slot, recipe };
|
|
107
|
+
}
|
|
108
|
+
if (token.includes('=')) throw fail(2, `--unset takes <activity>.<slot> without a recipe (got "${token}")`);
|
|
109
|
+
const { activity, slot } = parseQualified(token, '--unset');
|
|
110
|
+
assertSlot(activity, slot);
|
|
111
|
+
return { kind: 'unset', activity, slot };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// ── config validation (config errors → exit 1) ──────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
// Validate a parsed orchestration.json object against the schema. Strict: an unknown top-level
|
|
117
|
+
// activity, an unknown slot for an activity, or a recipe invalid-for-slot is an error. All slots are
|
|
118
|
+
// optional. An optional "_README" string key is allowed + ignored (self-documentation). Never a silent
|
|
119
|
+
// fallback — every rejection is a loud `path: reason` (exit 1). Returns the config on success.
|
|
120
|
+
export const validateConfig = (config) => {
|
|
121
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
122
|
+
throw fail(1, `${CONFIG_REL}: must be a JSON object of activity → { slot: recipe }`);
|
|
123
|
+
}
|
|
124
|
+
for (const [key, val] of Object.entries(config)) {
|
|
125
|
+
if (key === '_README') {
|
|
126
|
+
if (typeof val !== 'string') throw fail(1, `${CONFIG_REL}: "_README" must be a string`);
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const activityDef = ACTIVITIES[key];
|
|
130
|
+
if (!activityDef) {
|
|
131
|
+
throw fail(1, `${CONFIG_REL}: unknown activity "${key}" (known: ${KNOWN_ACTIVITIES()})`);
|
|
132
|
+
}
|
|
133
|
+
if (val === null || typeof val !== 'object' || Array.isArray(val)) {
|
|
134
|
+
throw fail(1, `${CONFIG_REL}: activity "${key}" must be a JSON object of slot → recipe`);
|
|
135
|
+
}
|
|
136
|
+
for (const [slot, recipe] of Object.entries(val)) {
|
|
137
|
+
const slotType = activityDef.slots[slot];
|
|
138
|
+
if (!slotType) {
|
|
139
|
+
throw fail(
|
|
140
|
+
1,
|
|
141
|
+
`${CONFIG_REL}: unknown slot "${slot}" for activity "${key}" (${key} slots: ${Object.keys(activityDef.slots).join(', ')})`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
if (typeof recipe !== 'string' || !(SLOT_RECIPES[slotType] ?? []).includes(recipe)) {
|
|
145
|
+
throw fail(
|
|
146
|
+
1,
|
|
147
|
+
`${CONFIG_REL}: invalid recipe "${recipe}" for ${slotType} slot of "${key}" (${slotType} accepts: ${SLOT_RECIPES[slotType].join(', ')})`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return config;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── config IO (config errors → exit 1) ──────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
// Load + validate the config from <cwd>/docs/ai/orchestration.json. Absent FILE → computed defaults
|
|
158
|
+
// (NOT an error): { config: null, source: 'none' }. Malformed JSON / schema-invalid / unreadable →
|
|
159
|
+
// loud `path: reason` (exit 1). The read-only status survey + procedures + the set-recipe writer all
|
|
160
|
+
// reuse THIS reader — one strict-JSON + loud-on-malformed contract, no second drifting implementation.
|
|
161
|
+
export const loadConfig = (cwd, readFile = readFileSync, lstat = lstatSync) => {
|
|
162
|
+
const full = join(cwd, CONFIG_REL);
|
|
163
|
+
// Distinguish a TRULY-absent config (no entry at all → computed defaults) from a present-but-
|
|
164
|
+
// unreadable one (a directory, a DANGLING SYMLINK, a permission error → loud exit 1). lstat does NOT
|
|
165
|
+
// follow the link, so a dangling symlink reads as "present" here and its later read failure surfaces
|
|
166
|
+
// loudly — never silently treated as absent (no-silent-failures Hard Constraint).
|
|
167
|
+
try {
|
|
168
|
+
lstat(full);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (err && err.code === 'ENOENT') return { config: null, source: 'none' };
|
|
171
|
+
throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
172
|
+
}
|
|
173
|
+
let raw;
|
|
174
|
+
try {
|
|
175
|
+
raw = readFile(full, 'utf8');
|
|
176
|
+
} catch (err) {
|
|
177
|
+
throw fail(1, `${CONFIG_REL}: unreadable (${(err && err.code) || (err && err.message) || err})`);
|
|
178
|
+
}
|
|
179
|
+
let parsed;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(raw);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
throw fail(1, `${CONFIG_REL}: malformed JSON (${err.message})`);
|
|
184
|
+
}
|
|
185
|
+
return { config: validateConfig(parsed), source: CONFIG_REL };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// ── pure merge + canonical serialization ────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
// A pure deep-equal over the JSON-ish config shape (plain objects + string values). Used only for the
|
|
191
|
+
// "did anything actually change?" decision (no-op detection + seed-on-change), never for output.
|
|
192
|
+
const deepEqual = (a, b) => {
|
|
193
|
+
if (a === b) return true;
|
|
194
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
195
|
+
const ak = Object.keys(a);
|
|
196
|
+
const bk = Object.keys(b);
|
|
197
|
+
if (ak.length !== bk.length) return false;
|
|
198
|
+
return ak.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// applySetOps(currentConfig, ops, { seedReadme }) → the merged config. PURE: deep-clones `current`
|
|
202
|
+
// (or {}), applies each set/unset, preserves `_README` + every untouched activity/slot, drops an
|
|
203
|
+
// activity that an unset empties (sparse), then re-runs validateConfig (loud on invalid — defensive;
|
|
204
|
+
// the op parser pre-validates). When (and ONLY when) the merge CHANGES the config, `_README` is absent,
|
|
205
|
+
// and `seedReadme` is supplied, the canonical note is seeded — so a no-op set never spuriously seeds it.
|
|
206
|
+
export const applySetOps = (currentConfig, ops, { seedReadme = null } = {}) => {
|
|
207
|
+
const base = currentConfig == null ? {} : structuredClone(currentConfig);
|
|
208
|
+
const next = structuredClone(base);
|
|
209
|
+
for (const op of ops) {
|
|
210
|
+
if (op.kind === 'set') {
|
|
211
|
+
next[op.activity] = { ...(next[op.activity] ?? {}) };
|
|
212
|
+
next[op.activity][op.slot] = op.recipe;
|
|
213
|
+
} else {
|
|
214
|
+
if (next[op.activity] && op.slot in next[op.activity]) {
|
|
215
|
+
const rest = { ...next[op.activity] };
|
|
216
|
+
delete rest[op.slot];
|
|
217
|
+
if (Object.keys(rest).length === 0) delete next[op.activity];
|
|
218
|
+
else next[op.activity] = rest;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
validateConfig(next);
|
|
223
|
+
const changed = !deepEqual(next, base);
|
|
224
|
+
if (changed && seedReadme != null && next._README === undefined) next._README = seedReadme;
|
|
225
|
+
return next;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// serializeConfig(config) → strict JSON, 2-space, trailing newline, `_README` FIRST (explicitly, so the
|
|
229
|
+
// onboarding note never sinks below the activities). This is the canonical on-disk form: a touched
|
|
230
|
+
// write normalizes to it (content-preserving, NOT byte-preserving of arbitrary hand-formatting).
|
|
231
|
+
export const serializeConfig = (config) => {
|
|
232
|
+
const ordered = {};
|
|
233
|
+
if (config._README !== undefined) ordered._README = config._README;
|
|
234
|
+
for (const [k, v] of Object.entries(config)) {
|
|
235
|
+
if (k !== '_README') ordered[k] = v;
|
|
236
|
+
}
|
|
237
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
// ── canonical-refresh (shared by the _README refresh + the injected-slot refresh) ───
|
|
241
|
+
// normalizeCanonical: trim + LF-normalize (handles the CRLF / trailing-whitespace trap) so a byte-noisy
|
|
242
|
+
// copy of a canonical string still matches. refreshIfCanonical: replace `current` with `next` IFF it
|
|
243
|
+
// normalize-equals ANY known prior canonical; otherwise return `current` UNCHANGED (preserve a
|
|
244
|
+
// customization). Pure; no fs. Used for the orchestration `_README` and the two injected pointers.
|
|
245
|
+
|
|
246
|
+
export const normalizeCanonical = (s) => String(s).replace(/\r\n/g, '\n').trim();
|
|
247
|
+
|
|
248
|
+
export const refreshIfCanonical = (current, knownPriorCanonicals, next) => {
|
|
249
|
+
const cur = normalizeCanonical(current);
|
|
250
|
+
return knownPriorCanonicals.some((prior) => normalizeCanonical(prior) === cur) ? next : current;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
// ── canonical `_README` (drift-guarded, append-only known-prior set) ─────────────────
|
|
254
|
+
// CANON_README is the CURRENT onboarding note — what the templates ship + what a refresh installs. It
|
|
255
|
+
// frames hand-edit as a still-available option AND points at the set-recipe writer (no "never written
|
|
256
|
+
// for you"). KNOWN_PRIOR_README is the APPEND-ONLY set of every PREVIOUS canonical note: any release
|
|
257
|
+
// that changes CANON_README must FIRST append the outgoing string here, so an immediately-previous
|
|
258
|
+
// deployment still normalize-matches and gets refreshed (a customized note never matches → preserved).
|
|
259
|
+
export const CANON_README =
|
|
260
|
+
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. " +
|
|
261
|
+
"Easiest: tell the agent in plain language and run the `set-recipe` writer — it interprets your intent, " +
|
|
262
|
+
"previews the change, and writes valid JSON for you. You can still hand-edit this file directly whenever you " +
|
|
263
|
+
"prefer; that option never goes away. Each activity is configured independently (e.g. plan-authoring, " +
|
|
264
|
+
"plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts " +
|
|
265
|
+
"solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an " +
|
|
266
|
+
"'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). The default " +
|
|
267
|
+
"below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second " +
|
|
268
|
+
"opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's " +
|
|
269
|
+
"line (or run `set-recipe --unset <activity>.<slot>`) to fall back to the computed default (reviewed when a " +
|
|
270
|
+
"review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps " +
|
|
271
|
+
"plus the recipe resolved for your environment. Strict JSON — no comments.";
|
|
272
|
+
|
|
273
|
+
export const KNOWN_PRIOR_README = [
|
|
274
|
+
// v1 (pre-set-recipe) — the "Hand-edit this file — it is never written for you" note. APPEND-ONLY.
|
|
275
|
+
"Per-project orchestration config: the recipe used at each step (slot) of each named activity. Hand-edit this file — it is never written for you. Each activity is configured independently (e.g. plan-authoring, plan-execution), and so is each slot within it. A slot's value is a recipe: a 'review' slot accepts solo | reviewed | council (you self-review / one backend reviews / both review and you synthesize); an 'execute' slot accepts solo | delegated (you implement / a backend runs a bounded sub-task). The default below is 'solo' everywhere — no execution backend required. Raise a slot to reviewed or council for a second opinion, or to delegated to hand off execution; those need an execution backend set up first. Remove a slot's line to fall back to the computed default (reviewed when a review backend is ready, otherwise solo). Run the read-only procedures advisor to see an activity's steps plus the recipe resolved for your environment, and pass a per-run override to change one slot just once. Strict JSON — no comments.",
|
|
276
|
+
];
|
|
277
|
+
|
|
278
|
+
// refreshReadme(config) → { config, changed }: refresh ONLY the `_README` value when it normalize-
|
|
279
|
+
// matches a known prior canonical (preserve a customized note untouched); seed it when absent. The
|
|
280
|
+
// stamp-independent config-ensure (kit fallback + memory delegated upgrade paths) uses this so an
|
|
281
|
+
// install-base deployment gains the new note without a migration file — never clobbering a customization.
|
|
282
|
+
export const refreshReadme = (config) => {
|
|
283
|
+
if (config == null || typeof config !== 'object' || Array.isArray(config)) {
|
|
284
|
+
return { config, changed: false };
|
|
285
|
+
}
|
|
286
|
+
const had = config._README;
|
|
287
|
+
const nextReadme = had === undefined ? CANON_README : refreshIfCanonical(had, KNOWN_PRIOR_README, CANON_README);
|
|
288
|
+
if (nextReadme === had) return { config, changed: false };
|
|
289
|
+
const next = { _README: nextReadme };
|
|
290
|
+
for (const [k, v] of Object.entries(config)) {
|
|
291
|
+
if (k !== '_README') next[k] = v;
|
|
292
|
+
}
|
|
293
|
+
return { config: next, changed: true };
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// The canonical seed file body (what `init` deploys + what serializeConfig round-trips byte-identically).
|
|
297
|
+
export const SEED_CONFIG = { _README: CANON_README, 'plan-authoring': { review: 'solo' }, 'plan-execution': { execute: 'solo', review: 'solo' } };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// orchestration-write.mjs — the ONLY filesystem WRITER for docs/ai/orchestration.json. It is imported
|
|
3
|
+
// by the set-recipe writer alone; procedures.mjs never imports it, so "the read-only procedures advisor
|
|
4
|
+
// can never reach a writer" is a STRUCTURAL invariant (an import-split test pins it), not just an
|
|
5
|
+
// assertion. Splitting the writer out of the schema/read module keeps the read surface fs-write-free.
|
|
6
|
+
//
|
|
7
|
+
// writeConfig is hardened (mirrors the setup-backends posture):
|
|
8
|
+
// - DEPLOYMENT GATE first: refuse to scatter a config into a repo with no docs/ai/ — STOP loud,
|
|
9
|
+
// pointing at init/bootstrap (a config without a deployment is meaningless + surprising).
|
|
10
|
+
// - refuse a SYMLINKED leaf (orchestration.json itself a symlink) — a rename would silently replace
|
|
11
|
+
// the link target.
|
|
12
|
+
// - guard the dst + the tmp sibling with assertContainedRealPath (fs-safe) — refuses a symlinked
|
|
13
|
+
// docs/ or docs/ai/ PARENT, not just the leaf, and refuses any escape outside cwd.
|
|
14
|
+
// - atomic: write a UNIQUE *.json.<rand>.tmp opened EXCLUSIVE-CREATE (wx), then rename over the dst.
|
|
15
|
+
// - RE-CHECK the parent chain + the leaf immediately before the rename (TOCTOU).
|
|
16
|
+
// - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
|
|
17
|
+
//
|
|
18
|
+
// Dependency-free, Node >= 18. Every fs primitive is injectable (deps.*) so the guards are unit-testable.
|
|
19
|
+
|
|
20
|
+
import { lstatSync, writeFileSync, renameSync, rmSync } from 'node:fs';
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
24
|
+
import { CONFIG_REL, serializeConfig } from './orchestration-config.mjs';
|
|
25
|
+
|
|
26
|
+
// A typed STOP — a deliberate refusal we surface (deployment gate / symlinked leaf), distinct from a
|
|
27
|
+
// native fs error. `Object.assign(new Error(), { code })`, the codebase's typed-error idiom (no classes).
|
|
28
|
+
export const ORCH_WRITE_STOP = 'ORCH_WRITE_STOP';
|
|
29
|
+
const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'OrchWriteStop', code: ORCH_WRITE_STOP });
|
|
30
|
+
|
|
31
|
+
// lstat without following symlinks; null when absent. A non-ENOENT error propagates (never fail open).
|
|
32
|
+
const lstatNoFollow = (path, lstat) => {
|
|
33
|
+
try {
|
|
34
|
+
return lstat(path);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (err && err.code === 'ENOENT') return null;
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// writeConfig(cwd, config, deps) → { writtenPath } on success; THROWS a typed STOP (no deployment /
|
|
42
|
+
// symlinked leaf) or a native fs error otherwise. The tmp is cleaned up on any failure after its
|
|
43
|
+
// creation. config is serialized canonically (serializeConfig: 2-space, _README-first, trailing NL).
|
|
44
|
+
export const writeConfig = (cwd, config, deps = {}) => {
|
|
45
|
+
const lstat = deps.lstat ?? lstatSync;
|
|
46
|
+
const writeFile = deps.writeFile ?? writeFileSync;
|
|
47
|
+
const rename = deps.rename ?? renameSync;
|
|
48
|
+
const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
|
|
49
|
+
const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
|
|
50
|
+
const guard = (target) => assertContainedRealPath(cwd, target, { lstat });
|
|
51
|
+
|
|
52
|
+
// Deployment gate — never scatter a config into a non-deployed repo (mirror velocity / setup). lstat,
|
|
53
|
+
// NOT existsSync: existsSync FOLLOWS a symlink, so a DANGLING `docs/ai` symlink would read as "absent"
|
|
54
|
+
// and mislabel a broken/symlinked deployment as "no deployment". lstat the leaf instead: a true ENOENT
|
|
55
|
+
// → no deployment (STOP, run init); a symlink or non-directory → STOP loud (never write through it).
|
|
56
|
+
const docsAi = join(cwd, 'docs', 'ai');
|
|
57
|
+
const docsAiStat = lstatNoFollow(docsAi, lstat);
|
|
58
|
+
if (docsAiStat === null) {
|
|
59
|
+
throw stop(`no agent-workflow deployment here (docs/ai is absent) — run init/bootstrap before writing ${CONFIG_REL}`);
|
|
60
|
+
}
|
|
61
|
+
if (docsAiStat.isSymbolicLink()) {
|
|
62
|
+
throw stop(`docs/ai is a symlink — refusing to write a config through it (run init/bootstrap in a real deployment)`);
|
|
63
|
+
}
|
|
64
|
+
if (!docsAiStat.isDirectory()) {
|
|
65
|
+
throw stop(`docs/ai exists but is not a directory — refusing to write ${CONFIG_REL}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const dst = join(cwd, CONFIG_REL);
|
|
69
|
+
// Refuse a symlinked leaf with a CLEAR message before the generic traversal guard fires (a rename
|
|
70
|
+
// would silently replace the link rather than the file the user thinks they are editing).
|
|
71
|
+
const leaf = lstatNoFollow(dst, lstat);
|
|
72
|
+
if (leaf && leaf.isSymbolicLink()) {
|
|
73
|
+
throw stop(`${CONFIG_REL} is a symlink — refusing to replace it (a write would clobber the link target)`);
|
|
74
|
+
}
|
|
75
|
+
// Guard the dst + a unique tmp SIBLING: refuses a symlinked docs/ or docs/ai/ parent, and any escape.
|
|
76
|
+
guard(dst);
|
|
77
|
+
const tmp = `${dst}.${rand()}.tmp`;
|
|
78
|
+
guard(tmp);
|
|
79
|
+
|
|
80
|
+
const body = serializeConfig(config);
|
|
81
|
+
// Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
|
|
82
|
+
// overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
|
|
83
|
+
writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
84
|
+
try {
|
|
85
|
+
// TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
|
|
86
|
+
guard(dst);
|
|
87
|
+
const leafAgain = lstatNoFollow(dst, lstat);
|
|
88
|
+
if (leafAgain && leafAgain.isSymbolicLink()) {
|
|
89
|
+
throw stop(`${CONFIG_REL} became a symlink — refusing to replace it`);
|
|
90
|
+
}
|
|
91
|
+
rename(tmp, dst);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
rm(tmp); // never leave a temp file behind on failure
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
return { writtenPath: CONFIG_REL };
|
|
97
|
+
};
|