@sabaiway/agent-workflow-kit 1.12.0 → 1.14.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.
@@ -8,6 +8,8 @@ import {
8
8
  ENGINE_ENV,
9
9
  EXPECTED_ENGINE_NAME,
10
10
  ENGINE_FRAGMENT_REL,
11
+ ORCHESTRATION_FRAGMENT_REL,
12
+ PROCEDURES_FRAGMENT_REL,
11
13
  } from './engine-source.mjs';
12
14
 
13
15
  // A valid engine dir = it exists (dir), the fragment file exists, and the validator reports a
@@ -180,3 +182,99 @@ describe('readEngineFragment — live read or loud throw', () => {
180
182
  );
181
183
  });
182
184
  });
185
+
186
+ // The orchestration fragment is a SECOND bounded fragment (Plan 4), selected via deps.rel /
187
+ // detectEngine({ rel }) — non-breaking: the default rel stays the methodology fragment so every
188
+ // existing methodology call site is unchanged.
189
+ describe('detectEngine / readEngineFragment — orchestration fragment via rel', () => {
190
+ // statType that knows both fragments live in the engine (a current >=1.2.0 engine).
191
+ const bothPresent = (path) =>
192
+ path === ENGINE_DIR
193
+ ? 'dir'
194
+ : path === join(ENGINE_DIR, ENGINE_FRAGMENT_REL) || path === join(ENGINE_DIR, ORCHESTRATION_FRAGMENT_REL)
195
+ ? 'file'
196
+ : null;
197
+
198
+ it('verifies the orchestration fragment when rel is the orchestration path', () => {
199
+ const out = detectEngine(ENGINE_DIR, { source: 'default', rel: ORCHESTRATION_FRAGMENT_REL }, deps({ statType: bothPresent }));
200
+ assert.equal(out.ok, true);
201
+ });
202
+
203
+ it('an older engine (no orchestration fragment) → detect not-ok, the reason names that fragment', () => {
204
+ const out = detectEngine(ENGINE_DIR, { source: 'default', rel: ORCHESTRATION_FRAGMENT_REL }, deps()); // okStatType: only the methodology fragment is a file
205
+ assert.equal(out.ok, false);
206
+ assert.match(out.reason, /orchestration-slot\.md/);
207
+ });
208
+
209
+ it('the methodology read is unaffected by the new rel default (back-compat)', () => {
210
+ const out = detectEngine(ENGINE_DIR, { source: 'default' }, deps({ statType: bothPresent }));
211
+ assert.equal(out.ok, true);
212
+ });
213
+
214
+ it('readEngineFragment reads the orchestration fragment bytes when deps.rel is set', () => {
215
+ const out = readEngineFragment(
216
+ ENGINE_DIR,
217
+ deps({ source: 'default', rel: ORCHESTRATION_FRAGMENT_REL, statType: bothPresent, readFileSync: () => 'ORCH BODY' }),
218
+ );
219
+ assert.equal(out, 'ORCH BODY');
220
+ });
221
+
222
+ it('readEngineFragment STOPs loudly when the orchestration fragment is absent (older engine)', () => {
223
+ assert.throws(
224
+ () => readEngineFragment(ENGINE_DIR, deps({ source: 'default', rel: ORCHESTRATION_FRAGMENT_REL })),
225
+ (err) => {
226
+ assert.match(err.message, /methodology engine not found\/invalid/);
227
+ assert.match(err.message, /orchestration-slot\.md/);
228
+ assert.match(err.message, /npx @sabaiway\/agent-workflow-engine@latest init/);
229
+ return true;
230
+ },
231
+ );
232
+ });
233
+ });
234
+
235
+ // The activity-procedures canon is a THIRD live-read fragment (engine >= 1.3.0), selected the same way
236
+ // (deps.rel / detectEngine({ rel })). An engine too old to ship it must read as not-ok naming the
237
+ // fragment, and readEngineFragment must STOP loudly — the procedures CLI maps that to a clean exit 1.
238
+ describe('detectEngine / readEngineFragment — procedures fragment via rel', () => {
239
+ const proceduresPresent = (path) =>
240
+ path === ENGINE_DIR
241
+ ? 'dir'
242
+ : path === join(ENGINE_DIR, ENGINE_FRAGMENT_REL) || path === join(ENGINE_DIR, PROCEDURES_FRAGMENT_REL)
243
+ ? 'file'
244
+ : null;
245
+
246
+ it('exposes the procedures fragment rel constant', () => {
247
+ assert.equal(PROCEDURES_FRAGMENT_REL, 'references/procedures.md');
248
+ });
249
+
250
+ it('verifies the procedures fragment when rel is the procedures path', () => {
251
+ const out = detectEngine(ENGINE_DIR, { source: 'default', rel: PROCEDURES_FRAGMENT_REL }, deps({ statType: proceduresPresent }));
252
+ assert.equal(out.ok, true);
253
+ });
254
+
255
+ it('an older engine (no procedures fragment) → detect not-ok, the reason names that fragment', () => {
256
+ const out = detectEngine(ENGINE_DIR, { source: 'default', rel: PROCEDURES_FRAGMENT_REL }, deps()); // okStatType: only the methodology fragment is a file
257
+ assert.equal(out.ok, false);
258
+ assert.match(out.reason, /procedures\.md/);
259
+ });
260
+
261
+ it('readEngineFragment reads the procedures fragment bytes when deps.rel is set', () => {
262
+ const out = readEngineFragment(
263
+ ENGINE_DIR,
264
+ deps({ source: 'default', rel: PROCEDURES_FRAGMENT_REL, statType: proceduresPresent, readFileSync: () => 'PROCEDURES BODY' }),
265
+ );
266
+ assert.equal(out, 'PROCEDURES BODY');
267
+ });
268
+
269
+ it('readEngineFragment STOPs loudly when the procedures fragment is absent (engine < 1.3.0)', () => {
270
+ assert.throws(
271
+ () => readEngineFragment(ENGINE_DIR, deps({ source: 'default', rel: PROCEDURES_FRAGMENT_REL })),
272
+ (err) => {
273
+ assert.match(err.message, /methodology engine not found\/invalid/);
274
+ assert.match(err.message, /procedures\.md/);
275
+ assert.match(err.message, /npx @sabaiway\/agent-workflow-engine@latest init/);
276
+ return true;
277
+ },
278
+ );
279
+ });
280
+ });
@@ -23,6 +23,7 @@ import os from 'node:os';
23
23
  import { resolveDir } from './detect-backends.mjs';
24
24
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
25
25
  import { START_MARKER, excludePath } from './hide-footprint.mjs';
26
+ import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
27
 
27
28
  // ── manifestState values (the detect-backends precedence, generalized to any member kind) ──────────
28
29
  export const NOT_INSTALLED = 'not-installed';
@@ -143,7 +144,37 @@ export const classifyMember = (member, deps = {}) => {
143
144
  return { name: member.name, kind: member.kind, installed, skillDir: installed ? skillDir : null, manifestState, version };
144
145
  };
145
146
 
146
- export const surveyFamily = (deps = {}) => FAMILY_MEMBERS.map((member) => classifyMember(member, deps));
147
+ // An installed engine may be a VALID methodology-engine yet too old (or incomplete) to ship one of the
148
+ // kit's live-read fragments: `references/orchestration-slot.md` (the recipes pointer, engine >= 1.2.0)
149
+ // and `references/procedures.md` (the activity-procedures canon, engine >= 1.3.0). Each missing
150
+ // fragment is a DISTINCT, plain-language caveat. They are collected into `row.caveats` (an ARRAY) so an
151
+ // engine missing BOTH surfaces both — a single `row.caveat` would overwrite one with the other. The
152
+ // check mirrors what each consumer actually does — `readEngineFragment(..., { rel })` validates
153
+ // the manifest AND reads the fragment — so an absent, non-file, OR present-but-unreadable fragment all
154
+ // surface (status never claims "ok" for a fragment a reconcile / the procedures CLI would STOP on), and
155
+ // a current, readable fragment never gets the caveat. Read-only, best-effort.
156
+ const ENGINE_FRAGMENT_CAVEATS = [
157
+ { rel: ORCHESTRATION_FRAGMENT_REL, caveat: 'engine present but does not supply the recipes pointer (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
158
+ { rel: PROCEDURES_FRAGMENT_REL, caveat: 'engine present but does not ship the activity-procedures canon (too old / incomplete) — run `npx @sabaiway/agent-workflow-engine@latest init`' },
159
+ ];
160
+
161
+ export const surveyFamily = (deps = {}) =>
162
+ FAMILY_MEMBERS.map((member) => {
163
+ const row = classifyMember(member, deps);
164
+ if (row.kind === 'methodology-engine' && row.manifestState === OK && row.skillDir) {
165
+ const fragmentUsable = (rel) => {
166
+ try {
167
+ readEngineFragment(row.skillDir, { source: 'default', rel, ...deps });
168
+ return true;
169
+ } catch {
170
+ return false; // absent / non-file / unreadable fragment → the engine can't supply it
171
+ }
172
+ };
173
+ const caveats = ENGINE_FRAGMENT_CAVEATS.filter((f) => !fragmentUsable(f.rel)).map((f) => f.caveat);
174
+ if (caveats.length) row.caveats = caveats;
175
+ }
176
+ return row;
177
+ });
147
178
 
148
179
  // ── the DEPLOY axis ──────────────────────────────────────────────────────────────
149
180
  // Read a one-line semver stamp (docs/ai/.workflow-version etc.). Returns the trimmed version or null.
@@ -208,6 +239,7 @@ export const formatStatus = (family, project = null) => {
208
239
  for (const m of family) {
209
240
  const ver = m.version ? `v${m.version}` : '—';
210
241
  lines.push(` ${pad(m.name, 26)}[${pad(m.manifestState, 16)}] ${pad(ver, 10)} ${m.kind}`);
242
+ for (const c of m.caveats ?? []) lines.push(` ↳ ${c}`);
211
243
  }
212
244
  if (project) {
213
245
  lines.push('', `project deployment (${project.dir})`, '');
@@ -18,6 +18,7 @@ import {
18
18
  } from './family-registry.mjs';
19
19
  import { VALID, INVALID, UNSUPPORTED } from './manifest/validate.mjs';
20
20
  import { START_MARKER } from './hide-footprint.mjs';
21
+ import { ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
21
22
 
22
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
24
  const REPO_ROOT = resolve(__dirname, '../..'); // agent-workflow-kit/tools → repo root
@@ -102,6 +103,95 @@ describe('surveyFamily', () => {
102
103
  assert.equal(rows.length, FAMILY_MEMBERS.length);
103
104
  assert.ok(rows.every((r) => r.manifestState === NOT_INSTALLED));
104
105
  });
106
+
107
+ // Only the engine member validates as ok (its name+kind match); the others go FOREIGN under this
108
+ // shared validate stub — so only the engine row is eligible for the orchestration-fragment caveat.
109
+ const engineValidate = (dir) =>
110
+ String(dir).includes('agent-workflow-engine')
111
+ ? { result: VALID, name: 'agent-workflow-engine', kind: 'methodology-engine', available: true }
112
+ : { result: VALID, name: 'x', kind: 'x', available: true };
113
+
114
+ // The caveats mirror the consumers: each reads a live engine fragment (readEngineFragment) — the
115
+ // recipes pointer (orchestration-slot.md, engine >= 1.2.0) AND the activity-procedures canon
116
+ // (procedures.md, engine >= 1.3.0). An absent / non-file / unreadable fragment surfaces as a DISTINCT
117
+ // caveat in `row.caveats` (an array, so missing BOTH surfaces BOTH); a current readable
118
+ // fragment never gets one. Helpers below model each fragment's on-disk state independently.
119
+ const engineDeps = (over) => ({
120
+ exists: () => true, // SKILL.md marker present (classifyMember)
121
+ stat: () => ({ isFile: () => true }),
122
+ getenv: {},
123
+ home: '/home/test',
124
+ validate: engineValidate,
125
+ readVersion: () => ({ version: '1.3.0' }),
126
+ ...over,
127
+ });
128
+ // statType where each fragment is independently a readable file ('file') or absent (null); the engine
129
+ // dir + everything else is a 'dir'. readFileSync returns content for a present fragment.
130
+ // Pin the mock to the AUTHORITATIVE engine-source constants (mirrors engine-source.test.mjs), so a
131
+ // fragment-path rename follows here instead of silently passing against the old basename.
132
+ const fragmentStat = ({ orch = 'file', proc = 'file' }) => (p) => {
133
+ const s = String(p);
134
+ if (s.endsWith(ORCHESTRATION_FRAGMENT_REL)) return orch;
135
+ if (s.endsWith(PROCEDURES_FRAGMENT_REL)) return proc;
136
+ return 'dir';
137
+ };
138
+ const caveatsOf = (rows) => rows.find((r) => r.kind === 'methodology-engine').caveats ?? [];
139
+
140
+ it('a current engine WITH BOTH fragments readable carries NO caveat', () => {
141
+ const rows = surveyFamily(engineDeps({ statType: fragmentStat({}), readFileSync: () => '> a bounded fragment' }));
142
+ const engine = rows.find((r) => r.kind === 'methodology-engine');
143
+ assert.equal(engine.manifestState, OK);
144
+ assert.equal(engine.caveats, undefined, 'no caveats when both live fragments are present + readable');
145
+ });
146
+
147
+ it('an OK engine MISSING the orchestration fragment gets the recipes caveat (only)', () => {
148
+ const rows = surveyFamily(engineDeps({
149
+ readVersion: () => ({ version: '1.2.0' }),
150
+ statType: fragmentStat({ orch: null }), // recipes fragment ABSENT, procedures present
151
+ readFileSync: () => '> a bounded fragment',
152
+ }));
153
+ const caveats = caveatsOf(rows);
154
+ assert.equal(caveats.length, 1);
155
+ assert.match(caveats[0], /recipes pointer/i);
156
+ });
157
+
158
+ it('an OK engine MISSING the procedures canon gets the activity-procedures caveat (only)', () => {
159
+ // The realistic post-release case: an engine at 1.2.0 ships the recipes pointer but not procedures.md.
160
+ const rows = surveyFamily(engineDeps({
161
+ readVersion: () => ({ version: '1.2.0' }),
162
+ statType: fragmentStat({ proc: null }), // procedures canon ABSENT, recipes present
163
+ readFileSync: () => '> a bounded fragment',
164
+ }));
165
+ const caveats = caveatsOf(rows);
166
+ assert.equal(caveats.length, 1);
167
+ assert.match(caveats[0], /activity-procedures|procedures canon/i);
168
+ });
169
+
170
+ it('an engine MISSING BOTH fragments surfaces BOTH caveats (neither overwrites the other)', () => {
171
+ const rows = surveyFamily(engineDeps({
172
+ readVersion: () => ({ version: '1.1.0' }),
173
+ statType: fragmentStat({ orch: null, proc: null }),
174
+ }));
175
+ const caveats = caveatsOf(rows);
176
+ assert.equal(caveats.length, 2, 'both missing fragments are reported');
177
+ assert.ok(caveats.some((c) => /recipes pointer/i.test(c)));
178
+ assert.ok(caveats.some((c) => /activity-procedures|procedures canon/i.test(c)));
179
+ });
180
+
181
+ it('a broken engine whose fragments are DIRECTORIES is NOT a false "ok"', () => {
182
+ const rows = surveyFamily(engineDeps({ statType: () => 'dir' })); // every fragment path is a dir
183
+ assert.equal(caveatsOf(rows).length, 2, 'non-file fragments are caveated');
184
+ });
185
+
186
+ it('a fragment PRESENT but UNREADABLE is NOT a false "ok" (mirrors the consumer STOP)', () => {
187
+ const rows = surveyFamily(engineDeps({
188
+ statType: fragmentStat({}), // both present as files
189
+ readFileSync: () => {
190
+ throw Object.assign(new Error('EACCES'), { code: 'EACCES' }); // but unreadable
191
+ },
192
+ }));
193
+ assert.equal(caveatsOf(rows).length, 2, 'unreadable fragments are caveated, not reported clean');
194
+ });
105
195
  });
106
196
 
107
197
  // ── surveyProject ────────────────────────────────────────────────────────────────