@sabaiway/agent-workflow-kit 5.6.0 → 5.7.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1 -1
  3. package/SKILL.md +1 -1
  4. package/capability.json +1 -1
  5. package/package.json +1 -1
  6. package/references/hooks/gate-approve.mjs +7 -1
  7. package/references/modes/doc-parity.md +1 -1
  8. package/references/modes/gates.md +16 -3
  9. package/references/modes/recommendations.md +3 -0
  10. package/references/modes/review-state.md +1 -1
  11. package/references/modes/setup.md +18 -2
  12. package/references/modes/upgrade.md +38 -18
  13. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  14. package/references/scripts/migrate-gates.mjs +295 -60
  15. package/references/scripts/migrate-gates.test.mjs +206 -14
  16. package/references/shared/deploy-tail.md +1 -1
  17. package/references/templates/gates.json +1 -1
  18. package/tools/ack-write.mjs +20 -11
  19. package/tools/atomic-write.mjs +71 -18
  20. package/tools/checker-claim.mjs +100 -0
  21. package/tools/coverage-producer.mjs +43 -6
  22. package/tools/direct-run.mjs +76 -0
  23. package/tools/doc-parity.mjs +34 -3
  24. package/tools/engine-source.mjs +12 -8
  25. package/tools/ensure-configs.mjs +141 -0
  26. package/tools/ensure-ops.mjs +284 -0
  27. package/tools/ensure-vocabulary.mjs +71 -0
  28. package/tools/gates-declaration.mjs +23 -10
  29. package/tools/gates-init.mjs +6 -3
  30. package/tools/hide-footprint.mjs +21 -3
  31. package/tools/lens-region.mjs +74 -23
  32. package/tools/orchestration-config.mjs +5 -3
  33. package/tools/orchestration-write.mjs +7 -0
  34. package/tools/recommendations.mjs +315 -66
  35. package/tools/refresh-parity.mjs +263 -0
  36. package/tools/run-gates.mjs +8 -5
  37. package/tools/setup-backends.mjs +88 -77
  38. package/tools/source-size-check.mjs +6 -16
  39. package/tools/source-size-core.mjs +7 -1
  40. package/tools/source-size-gate-cmd.mjs +18 -46
  41. package/tools/tracked-tree-census.mjs +102 -0
  42. package/tools/upgrade-runlist.mjs +92 -0
@@ -172,6 +172,58 @@ export const frontmatterMaxLines = (text) => {
172
172
  return null;
173
173
  };
174
174
 
175
+ // ── the outcome lines (pure composers — the CLI's one voice) ──────────────────────
176
+ // Every user-facing outcome line the CLI prints, one pure composer per outcome, so the
177
+ // composed-lines guard (test/composed-lines-ux.test.mjs) can render each against the L2
178
+ // user-grade invariants. runCli only ever prints through this table. Raw diagnostics never ride
179
+ // the human sentence: they land on the ONE machine-formatted detail line (`[lens-region]
180
+ // error=<JSON-encoded>` — one line, reversible, control bytes escaped), the `[tool] key=value`
181
+ // channel the L2 rule exempts by grammar. JSON.stringify leaves DEL/C1 and the U+2028/U+2029
182
+ // separators raw, and a dynamic path can carry any byte — both dynamic parts are therefore made
183
+ // line-safe explicitly: the machine value gains extra JSON escapes (still reversible), and the
184
+ // human line collapses every control/separator byte to one space.
185
+ const LINE_UNSAFE = new RegExp('[\\u007f-\\u009f\\u2028\\u2029]', 'g');
186
+ const HUMAN_UNSAFE = new RegExp('[\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029]+', 'g');
187
+ const escUnsafe = (c) => `\\u${c.codePointAt(0).toString(16).padStart(4, '0')}`;
188
+ const ERROR_DETAIL = (raw) => `[lens-region] error=${JSON.stringify(String(raw)).replace(LINE_UNSAFE, escUnsafe)}`;
189
+ const oneLine = (s) => String(s).replace(HUMAN_UNSAFE, ' ');
190
+
191
+ export const OUTCOME_LINES = Object.freeze({
192
+ errorDetail: ERROR_DETAIL,
193
+ targetAbsent: (target) => `[lens-region] ${target} is absent — skipped (nothing to update; the file is seeded at bootstrap).`,
194
+ commsNoRegion: (target) => [
195
+ `[lens-region] no "${COMMS_LABEL}" section in ${target} — left untouched.`,
196
+ '[lens-region] note: the Communication section is absent or renamed — deployments seeded before it existed simply lack it; add it from the current template to enable refresh. Your file is never rewritten.',
197
+ ],
198
+ commsCurrent: () => '[lens-region] Communication section already current — nothing to do (zero-diff).',
199
+ commsCustom: () => [
200
+ '[lens-region] Communication section carries a custom edit — preserved verbatim.',
201
+ '[lens-region] note: the canonical Communication section has changed since this section was edited — compare it with the current template when convenient; your wording is never overwritten.',
202
+ ],
203
+ capSkipNote: () => '[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.',
204
+ commsCapRefused: (target, count, cap) => `[lens-region] refused — refreshing the Communication section would push ${target} to ${count} lines (cap ${cap}); trim the file and re-run. The Communication section was not changed.`,
205
+ commsRefreshed: () => '[lens-region] refreshed the Communication section to the current canon.',
206
+ templateCanonStop: () => `[lens-region] STOP — the kit's bundled agent_rules.md template canon is unreadable; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`,
207
+ lensNoRegion: (target) => [
208
+ `[lens-region] no "${HEADING_LABEL}" section in ${target} — left untouched.`,
209
+ '[lens-region] note: the planning/review lens section is missing or renamed — it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.',
210
+ ],
211
+ engineTooOld: () => '[lens-region] skipped — the installed engine is too old (or incomplete) to supply the lens canon; refresh it with `npx @sabaiway/agent-workflow-engine@latest init`, then re-run.',
212
+ // The human line keeps the classified "methodology engine not found/invalid" contract; a typed
213
+ // error (engine-source attaches {stable, reason}) splits its raw reason onto the machine line.
214
+ engineStop: (err) => {
215
+ const human = `[lens-region] STOP — ${oneLine(err?.stable ?? err?.message ?? String(err))}`;
216
+ return err?.reason ? [human, ERROR_DETAIL(err.reason)] : [human];
217
+ },
218
+ lensCurrent: () => '[lens-region] lens section already current — nothing to do (zero-diff).',
219
+ lensCustom: () => [
220
+ '[lens-region] lens section carries a custom edit — preserved verbatim.',
221
+ '[lens-region] note: the canonical planning/review lens has changed since this section was edited — compare it with the project methodology canon when convenient; your wording is never overwritten.',
222
+ ],
223
+ lensCapRefused: (target, count, cap) => `[lens-region] refused — refreshing would push ${target} to ${count} lines (cap ${cap}); trim the file and re-run. The planning/review lens section was not changed.`,
224
+ lensRefreshed: () => '[lens-region] refreshed the planning/review lens section to the current canon.',
225
+ });
226
+
175
227
  // ── CLI: `lens-region.mjs reconcile <path/to/agent_rules.md>` ─────────────────────
176
228
  // Outcome lines are the contract the upgrade/bootstrap prose relays in plain language; exit 0 on
177
229
  // every classified outcome (including the soft skips and the cap refusals), exit 1 ONLY on a
@@ -203,7 +255,7 @@ export const runCli = async (argv, deps = {}) => {
203
255
  }
204
256
  })();
205
257
  if (text === null) {
206
- log(`[lens-region] ${argv[1]} is absent — skipped (nothing to reconcile; the substrate seeds it at bootstrap).`);
258
+ log(OUTCOME_LINES.targetAbsent(argv[1]));
207
259
  return 0;
208
260
  }
209
261
 
@@ -229,43 +281,41 @@ export const runCli = async (argv, deps = {}) => {
229
281
  }
230
282
  })();
231
283
  if (!templateRegion.found) {
232
- logError(`[lens-region] reconcile STOP — the kit's bundled agent_rules.md template canon is unreadable${templateRegion.error ? ` (${templateRegion.error})` : ''}; reinstall the kit: npx @sabaiway/agent-workflow-kit@latest init`);
284
+ logError(OUTCOME_LINES.templateCanonStop());
285
+ if (templateRegion.error) logError(OUTCOME_LINES.errorDetail(templateRegion.error));
233
286
  return 1;
234
287
  }
235
288
  const commsResult = reconcileCommsText(text, normalizeCommsBody(templateRegion.body), COMMS_PRIORS);
236
289
  const currentText = await (async () => {
237
290
  if (commsResult.status === 'no-region') {
238
- log(`[lens-region] no "${COMMS_LABEL}" section in ${argv[1]} — left untouched.`);
239
- log('[lens-region] note: the Communication section is absent or renamed — deployments seeded before it existed simply lack it; add it from the current template to enable refresh. Your file is never rewritten.');
291
+ for (const line of OUTCOME_LINES.commsNoRegion(argv[1])) log(line);
240
292
  return text;
241
293
  }
242
294
  if (commsResult.status === 'current') {
243
- log('[lens-region] Communication section already current — nothing to do (zero-diff).');
295
+ log(OUTCOME_LINES.commsCurrent());
244
296
  return text;
245
297
  }
246
298
  if (commsResult.status === 'custom') {
247
- log('[lens-region] Communication section carries a custom edit — preserved verbatim.');
248
- log('[lens-region] note: the canonical Communication section has changed since this section was edited — compare it with the current template when convenient; your wording is never overwritten.');
299
+ for (const line of OUTCOME_LINES.commsCustom()) log(line);
249
300
  return text;
250
301
  }
251
302
  const commsMax = frontmatterMaxLines(text);
252
303
  if (commsMax === null) {
253
- log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
304
+ log(OUTCOME_LINES.capSkipNote());
254
305
  }
255
306
  if (commsMax !== null && lineCount(commsResult.text) > commsMax) {
256
- log(`[lens-region] refused — refreshing the Communication section would push ${argv[1]} to ${lineCount(commsResult.text)} lines (cap ${commsMax}); trim the file and re-run. The Communication section was not changed.`);
307
+ log(OUTCOME_LINES.commsCapRefused(argv[1], lineCount(commsResult.text), commsMax));
257
308
  return text;
258
309
  }
259
310
  await atomicWrite(commsResult.text);
260
- log('[lens-region] refreshed the Communication section to the current canon.');
311
+ log(OUTCOME_LINES.commsRefreshed());
261
312
  return commsResult.text;
262
313
  })();
263
314
 
264
315
  // 3. No matching lens heading → preserve + advise, engine never consulted (the outcome is
265
316
  // preserve regardless, so the lazy contract holds).
266
317
  if (!extractLensRegion(currentText).found) {
267
- log(`[lens-region] no "${HEADING_LABEL}" section in ${argv[1]} — left untouched.`);
268
- log('[lens-region] note: the planning/review lens section is missing or renamed — it cannot be auto-refreshed; restore the canonical heading to re-enable refresh.');
318
+ for (const line of OUTCOME_LINES.lensNoRegion(argv[1])) log(line);
269
319
  return 0;
270
320
  }
271
321
 
@@ -276,14 +326,14 @@ export const runCli = async (argv, deps = {}) => {
276
326
  detectEngine(dir, { source, rel: LENS_FRAGMENT_REL }).ok && detectEngine(dir, { source, rel: LENS_PRIORS_REL }).ok;
277
327
  if (!lensPairPresent) {
278
328
  if (detectEngine(dir, { source }).ok) {
279
- log('[lens-region] skipped — the installed engine is too old (or incomplete) to supply the lens canon; refresh it with `npx @sabaiway/agent-workflow-engine@latest init`, then re-run.');
329
+ log(OUTCOME_LINES.engineTooOld());
280
330
  return 0;
281
331
  }
282
332
  try {
283
333
  readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL }); // throws the canonical install-me error
284
334
  return 1; // defensive: the pair is unusable — never proceed to a read
285
335
  } catch (err) {
286
- logError(`[lens-region] reconcile STOP — ${err.message}`);
336
+ for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
287
337
  return 1;
288
338
  }
289
339
  }
@@ -292,34 +342,35 @@ export const runCli = async (argv, deps = {}) => {
292
342
  let fragment;
293
343
  let priors;
294
344
  try {
295
- fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL });
296
- priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL }));
345
+ // deps.engineRead is the injectable read primitive (tests drive the vanished/unreadable arm
346
+ // deterministically a chmod-based fixture is root- and platform-dependent).
347
+ fragment = readEngineFragment(dir, { source, rel: LENS_FRAGMENT_REL, readFileSync: deps.engineRead });
348
+ priors = parseLensPriors(readEngineFragment(dir, { source, rel: LENS_PRIORS_REL, readFileSync: deps.engineRead }));
297
349
  } catch (err) {
298
- logError(`[lens-region] reconcile STOP — ${err.message}`);
350
+ for (const line of OUTCOME_LINES.engineStop(err)) logError(line);
299
351
  return 1;
300
352
  }
301
353
 
302
354
  // 5. The pure decision + the cap-guard + one atomic write.
303
355
  const result = reconcileLensText(currentText, fragment, priors);
304
356
  if (result.status === 'current') {
305
- log('[lens-region] lens section already current — nothing to do (zero-diff).');
357
+ log(OUTCOME_LINES.lensCurrent());
306
358
  return 0;
307
359
  }
308
360
  if (result.status === 'custom') {
309
- log('[lens-region] lens section carries a custom edit — preserved verbatim.');
310
- log('[lens-region] note: the canonical planning/review lens has changed since this section was edited — compare it with the project methodology canon when convenient; your wording is never overwritten.');
361
+ for (const line of OUTCOME_LINES.lensCustom()) log(line);
311
362
  return 0;
312
363
  }
313
364
  // refreshed → cap-guard from the TARGET's own frontmatter, then atomic write.
314
365
  const maxLines = frontmatterMaxLines(currentText);
315
366
  if (maxLines === null) {
316
- log('[lens-region] note: no `maxLines` frontmatter on the target — the line-cap guard is skipped.');
367
+ log(OUTCOME_LINES.capSkipNote());
317
368
  } else if (lineCount(result.text) > maxLines) {
318
- log(`[lens-region] refused — refreshing would push ${argv[1]} to ${lineCount(result.text)} lines (cap ${maxLines}); trim the file and re-run. The planning/review lens section was not changed.`);
369
+ log(OUTCOME_LINES.lensCapRefused(argv[1], lineCount(result.text), maxLines));
319
370
  return 0;
320
371
  }
321
372
  await atomicWrite(result.text);
322
- log('[lens-region] refreshed the planning/review lens section to the current canon.');
373
+ log(OUTCOME_LINES.lensRefreshed());
323
374
  return 0;
324
375
  };
325
376
 
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env node
2
1
  // orchestration-config.mjs — the schema / read / pure-transform core for the per-project
3
2
  // orchestration config (docs/ai/orchestration.json). It is the SINGLE source of the config contract:
4
3
  //
@@ -16,12 +15,13 @@
16
15
  //
17
16
  // This module performs NO filesystem WRITES — only reads (loadConfig). The single fs-writer lives in
18
17
  // orchestration-write.mjs, which procedures.mjs never imports DIRECTLY (the pinned import-split
19
- // rule). Pure-where-possible (fs injectable), dependency-free, Node >= 22. No side
20
- // effects on import.
18
+ // rule). It has NO CLI while upgrade.md names it — hence the registered refusal at the foot of the
19
+ // file (direct-run.mjs), and no shebang. Fs-injectable, dependency-free, Node >= 22; nothing on import.
21
20
 
22
21
  import { readFileSync, lstatSync } from 'node:fs';
23
22
  import { join } from 'node:path';
24
23
  import { ACTIVITIES, SLOT_RECIPES } from './recipes.mjs';
24
+ import { refuseDirectRun } from './direct-run.mjs';
25
25
 
26
26
  // The hand-editable / agent-writable, per-project config (strict JSON). cwd-relative — the error prefix
27
27
  // uses this rel path so a user sees a path they can open, never an absolute temp/host path.
@@ -396,3 +396,5 @@ export const refreshReadme = (config) => {
396
396
 
397
397
  // The canonical seed file body (what `init` deploys + what serializeConfig round-trips byte-identically).
398
398
  export const SEED_CONFIG = { _README: CANON_README, 'plan-authoring': { review: 'solo' }, 'plan-execution': { execute: 'solo', review: 'solo' } };
399
+
400
+ refuseDirectRun(import.meta.url);
@@ -25,3 +25,10 @@ const stop = (message) => Object.assign(new Error(`[agent-workflow-kit] ${messag
25
25
  // creation. config is serialized canonically (serializeConfig: 2-space, _README-first, trailing NL).
26
26
  export const writeConfig = (cwd, config, deps = {}) =>
27
27
  writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config' });
28
+
29
+ // seedConfig(cwd, config, deps) → { writtenPath, created }. Same writer, CREATE-ONLY: it is the
30
+ // seed-if-missing arm the ensure CLI runs, where a config that appeared between the probe and the
31
+ // write must survive untouched (`created: false` says it did). writeConfig stays the arm for a
32
+ // content update of a file the caller has just read.
33
+ export const seedConfig = (cwd, config, deps = {}) =>
34
+ writeDocsAiFileAtomic(cwd, CONFIG_REL, serializeConfig(config), deps, { stop, noun: 'a config', createOnly: true });