@sabaiway/agent-workflow-kit 1.17.0 → 1.19.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.
@@ -0,0 +1,67 @@
1
+ // family-members.mjs — the pure DATA LEAF: the one authoritative table of agent-workflow family members.
2
+ //
3
+ // Extracted from family-registry.mjs so the two consumers that only need the static table — the npx
4
+ // installer (bin/install.mjs, which derives its init-refresh cascade from it) and family-registry
5
+ // itself — can import the DATA without dragging in the whole status/presenter graph
6
+ // (detect-backends, the manifest validator, hide-footprint, engine-source, recipes, the renderers …).
7
+ // install.mjs runs on the npx cold-start hot path, so the leaner import matters; single-source-of-truth
8
+ // + the drift-guard (family-registry.test.mjs pins FAMILY_MEMBERS to the 5 in-repo capability.json)
9
+ // are preserved — the table just lives in a dependency-free leaf now.
10
+ //
11
+ // Pure data, no imports, no side effects, Node >= 18.
12
+
13
+ // ── the unified registry ───────────────────────────────────────────────────────
14
+ // One entry per family member. `installed` is the detect.installed spec (env + home-relative default
15
+ // + marker file); `deployed` is the project-relative stamp a deploy writes (kit + memory only);
16
+ // `npm` is the install package (null for the bridges, which are placed by `setup`, not npm);
17
+ // `wrapperCmds` is the deduped roles[].cmd set the `setup` linker creates on PATH (bridges only).
18
+ // Kept in lockstep with the 5 in-repo capability.json by the drift-guard test. The two release skills
19
+ // (release-engineering / release-marketing) are deliberately NOT here — they are not family members
20
+ // (AD-013): no capability.json, not in the kit tarball, not in the role vocabulary.
21
+ export const FAMILY_MEMBERS = [
22
+ {
23
+ name: 'agent-workflow-kit',
24
+ kind: 'composition-root',
25
+ installed: { env: 'AGENT_WORKFLOW_KIT_DIR', default: '~/.claude/skills/agent-workflow-kit', file: 'SKILL.md' },
26
+ deployed: { file: 'docs/ai/.workflow-version' },
27
+ npm: '@sabaiway/agent-workflow-kit',
28
+ wrapperCmds: [],
29
+ },
30
+ {
31
+ name: 'agent-workflow-memory',
32
+ kind: 'memory-substrate',
33
+ installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' },
34
+ deployed: { file: 'docs/ai/.memory-version' },
35
+ npm: '@sabaiway/agent-workflow-memory',
36
+ wrapperCmds: [],
37
+ },
38
+ {
39
+ name: 'agent-workflow-engine',
40
+ kind: 'methodology-engine',
41
+ installed: { env: 'AGENT_WORKFLOW_ENGINE_DIR', default: '~/.claude/skills/agent-workflow-engine', file: 'SKILL.md' },
42
+ deployed: null,
43
+ npm: '@sabaiway/agent-workflow-engine',
44
+ wrapperCmds: [],
45
+ },
46
+ {
47
+ name: 'codex-cli-bridge',
48
+ kind: 'execution-backend',
49
+ installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
50
+ deployed: null,
51
+ npm: null,
52
+ wrapperCmds: ['codex-exec', 'codex-review'],
53
+ },
54
+ {
55
+ name: 'antigravity-cli-bridge',
56
+ kind: 'execution-backend',
57
+ installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
58
+ deployed: null,
59
+ npm: null,
60
+ wrapperCmds: ['agy-run'],
61
+ },
62
+ ];
63
+
64
+ // A GLOBAL skill (lives under ~/.claude/skills) may be shared by other projects on the host — the
65
+ // uninstaller warns before removing one (there is no cross-project dependency tracking). All current
66
+ // members are global skills; the field is explicit so the warning is data-driven, not hardcoded.
67
+ export const isGlobalSkill = (member) => member.kind !== undefined; // every member is a global skill today
@@ -25,7 +25,9 @@ import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from
25
25
  import { START_MARKER, excludePath, inferVisibility } from './hide-footprint.mjs';
26
26
  import { readEngineFragment, ORCHESTRATION_FRAGMENT_REL, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
27
27
  import { ACTIVITIES, resolveActivityRecipe } from './recipes.mjs';
28
- import { loadConfig } from './procedures.mjs';
28
+ // The config reader lives in orchestration-config.mjs (the single config contract). The read-only status
29
+ // settings-survey reuses THIS reader (one strict-JSON + loud-on-malformed contract), not a second copy.
30
+ import { loadConfig } from './orchestration-config.mjs';
29
31
  // The deployment-lineage head + the shared settings readers are reused from velocity-profile so the
30
32
  // `--json` envelope/settings-survey has ONE implementation each, never a drifting copy:
31
33
  // EXPECTED_WORKFLOW_VERSION — the head literal (drift-guarded vs memory LINEAGE_HEAD)
@@ -39,73 +41,52 @@ import {
39
41
  readSettingsFile,
40
42
  resolveEffectiveMode,
41
43
  } from './velocity-profile.mjs';
42
-
43
- // ── manifestState values (the detect-backends precedence, generalized to any member kind) ──────────
44
- export const NOT_INSTALLED = 'not-installed';
45
- export const UNSUPPORTED_SCHEMA = 'unsupported-schema';
46
- export const INVALID_MANIFEST = 'invalid-manifest';
47
- export const STUB = 'stub';
48
- export const FOREIGN = 'foreign';
49
- export const OK = 'ok';
50
- // The marker could not be probed (a non-ENOENT fs error — EACCES/EIO). Surfaced explicitly instead of
51
- // being masked as not-installed (no silent failure); uninstall treats it as "do not touch" (skip).
52
- export const UNKNOWN = 'unknown';
44
+ // The status vocabulary (manifestState constants, internal→public maps, display names, the no-leak
45
+ // forbidden set) lives in the frozen labels.mjs LEAF (Plan §4.2 B1) so the import graph is acyclic —
46
+ // nothing imports family-registry for vocabulary. Imported here for internal use; the public subset is
47
+ // re-exported below.
48
+ import {
49
+ NOT_INSTALLED,
50
+ UNSUPPORTED_SCHEMA,
51
+ INVALID_MANIFEST,
52
+ STUB,
53
+ FOREIGN,
54
+ OK,
55
+ UNKNOWN,
56
+ STATE_PUBLIC,
57
+ VISIBILITY_PUBLIC,
58
+ DISPLAY_NAMES,
59
+ displayOf,
60
+ } from './labels.mjs';
61
+ // The capability-adaptive direct-CLI presenter (Plan §4.2/§4.5): the surface detector, the
62
+ // envelope→ViewModel transform, and the plain/ansi renderers. main() composes them; the agent-mediated
63
+ // `status` surface ignores them (it consumes --json). These are leaves — no import cycle.
64
+ import { detectSurface } from './surface.mjs';
65
+ import { toViewModel } from './view-model.mjs';
66
+ import { render } from './renderers.mjs';
67
+
68
+ // ── manifestState values — re-export the EXACT public subset family-registry exported before B1 ─────
69
+ // (the 7 state constants + DISPLAY_NAMES) so every existing importer (uninstall.mjs, the test suites)
70
+ // stays green. STATE_PUBLIC / VISIBILITY_PUBLIC / displayOf were private here and are NOT re-exported.
71
+ export {
72
+ NOT_INSTALLED,
73
+ UNSUPPORTED_SCHEMA,
74
+ INVALID_MANIFEST,
75
+ STUB,
76
+ FOREIGN,
77
+ OK,
78
+ UNKNOWN,
79
+ DISPLAY_NAMES,
80
+ };
53
81
 
54
82
  // ── the unified registry ───────────────────────────────────────────────────────
55
- // One entry per family member. `installed` is the detect.installed spec (env + home-relative default
56
- // + marker file); `deployed` is the project-relative stamp a deploy writes (kit + memory only);
57
- // `npm` is the install package (null for the bridges, which are placed by `setup`, not npm);
58
- // `wrapperCmds` is the deduped roles[].cmd set the `setup` linker creates on PATH (bridges only).
59
- // Kept in lockstep with the 5 in-repo capability.json by the drift-guard test. The two release skills
60
- // (release-engineering / release-marketing) are deliberately NOT here — they are not family members
61
- // (AD-013): no capability.json, not in the kit tarball, not in the role vocabulary.
62
- export const FAMILY_MEMBERS = [
63
- {
64
- name: 'agent-workflow-kit',
65
- kind: 'composition-root',
66
- installed: { env: 'AGENT_WORKFLOW_KIT_DIR', default: '~/.claude/skills/agent-workflow-kit', file: 'SKILL.md' },
67
- deployed: { file: 'docs/ai/.workflow-version' },
68
- npm: '@sabaiway/agent-workflow-kit',
69
- wrapperCmds: [],
70
- },
71
- {
72
- name: 'agent-workflow-memory',
73
- kind: 'memory-substrate',
74
- installed: { env: 'AGENT_WORKFLOW_MEMORY_DIR', default: '~/.claude/skills/agent-workflow-memory', file: 'SKILL.md' },
75
- deployed: { file: 'docs/ai/.memory-version' },
76
- npm: '@sabaiway/agent-workflow-memory',
77
- wrapperCmds: [],
78
- },
79
- {
80
- name: 'agent-workflow-engine',
81
- kind: 'methodology-engine',
82
- installed: { env: 'AGENT_WORKFLOW_ENGINE_DIR', default: '~/.claude/skills/agent-workflow-engine', file: 'SKILL.md' },
83
- deployed: null,
84
- npm: '@sabaiway/agent-workflow-engine',
85
- wrapperCmds: [],
86
- },
87
- {
88
- name: 'codex-cli-bridge',
89
- kind: 'execution-backend',
90
- installed: { env: 'CODEX_CLI_BRIDGE_DIR', default: '~/.claude/skills/codex-cli-bridge', file: 'SKILL.md' },
91
- deployed: null,
92
- npm: null,
93
- wrapperCmds: ['codex-exec', 'codex-review'],
94
- },
95
- {
96
- name: 'antigravity-cli-bridge',
97
- kind: 'execution-backend',
98
- installed: { env: 'ANTIGRAVITY_CLI_BRIDGE_DIR', default: '~/.claude/skills/antigravity-cli-bridge', file: 'SKILL.md' },
99
- deployed: null,
100
- npm: null,
101
- wrapperCmds: ['agy-run'],
102
- },
103
- ];
104
-
105
- // A GLOBAL skill (lives under ~/.claude/skills) may be shared by other projects on the host — the
106
- // uninstaller warns before removing one (there is no cross-project dependency tracking). All current
107
- // members are global skills; the field is explicit so the warning is data-driven, not hardcoded.
108
- export const isGlobalSkill = (member) => member.kind !== undefined; // every member is a global skill today
83
+ // FAMILY_MEMBERS (+ isGlobalSkill) is the one authoritative member table. It moved to the
84
+ // dependency-free DATA LEAF family-members.mjs so the npx installer can derive its init-refresh cascade
85
+ // from the table WITHOUT importing this whole status/presenter graph (a leaner npx cold-start path).
86
+ // Re-exported here so every existing importer (uninstall.mjs, the test suites) stays green, and the
87
+ // drift-guard (family-registry.test.mjs) still pins the table to the 5 in-repo capability.json.
88
+ export { FAMILY_MEMBERS, isGlobalSkill } from './family-members.mjs';
89
+ import { FAMILY_MEMBERS } from './family-members.mjs';
109
90
 
110
91
  // ── pure probes ──────────────────────────────────────────────────────────────────
111
92
  // Wrapped marker probe → 'present' (a regular file) | 'absent' (ENOENT / not a regular file) |
@@ -266,62 +247,10 @@ export const surveyProject = (projectDir, deps = {}) => {
266
247
  };
267
248
 
268
249
  // ── report ───────────────────────────────────────────────────────────────────────
269
- const pad = (s, n) => (s.length >= n ? s : s + ' '.repeat(n - s.length));
270
-
271
- // The human (non-JSON) settings render. A dev view the agent consumes `--json` + renders in plain
272
- // language (Mode: status). Each area shows its `error` field loudly when one fired.
273
- const formatSettings = (s) => {
274
- const out = ['', 'settings'];
275
- if (s.recipes?.error) out.push(` ${pad('recipes', 14)}error: ${s.recipes.error}`);
276
- else {
277
- const parts = [];
278
- for (const [act, slots] of Object.entries(s.recipes?.activities ?? {})) {
279
- for (const [slot, r] of Object.entries(slots)) parts.push(`${act}.${slot}=${r.recipe}`);
280
- }
281
- out.push(` ${pad('recipes', 14)}${parts.join(' · ') || '—'}`);
282
- if (s.recipes?.detectError) out.push(` ${pad('', 14)}↳ couldn't check backends (${s.recipes.detectError}); recipes floored at solo`);
283
- }
284
- if (s.attribution?.error) out.push(` ${pad('attribution', 14)}error: ${s.attribution.error}`);
285
- else out.push(` ${pad('attribution', 14)}includeCoAuthoredBy effective=${String(s.attribution?.effective)}`);
286
- if (s.velocity?.error) out.push(` ${pad('velocity', 14)}error: ${s.velocity.error}`);
287
- else out.push(` ${pad('velocity', 14)}defaultMode=${String(s.velocity?.defaultMode)} · allow project/local=${s.velocity?.allowEntries?.project}/${s.velocity?.allowEntries?.local}`);
288
- return out;
289
- };
290
-
291
- export const formatStatus = (family, project = null, extras = {}) => {
292
- const lines = ['agent-workflow family — installed skills (skill axis)', ''];
293
- for (const m of family) {
294
- const ver = m.version ? `v${m.version}` : '—';
295
- lines.push(` ${pad(m.name, 26)}[${pad(m.manifestState, 16)}] ${pad(ver, 10)} ${m.kind}`);
296
- for (const c of m.caveats ?? []) lines.push(` ↳ ${c}`);
297
- }
298
- if (extras.bridges) {
299
- const WRAP_MARK = { present: '✓', missing: '✗', unknown: '?' };
300
- lines.push('', 'execution backends (host)', '');
301
- for (const b of extras.bridges) {
302
- const w = b.wrappers.map((x) => `${x.cmd} ${WRAP_MARK[x.state] ?? '?'}`).join(', ') || '—';
303
- lines.push(` ${pad(b.display, 20)}${pad(b.readiness, 18)}wrappers: ${w}`);
304
- }
305
- }
306
- if (project) {
307
- lines.push('', `project deployment (${project.dir})`, '');
308
- if (!project.deployed) {
309
- lines.push(' no agent-workflow deployment detected here (no docs/ai, no version stamp).');
310
- } else {
311
- for (const s of project.stamps) {
312
- lines.push(` ${pad(s.file, 26)}${s.version ?? '—'}`);
313
- }
314
- lines.push(` ${pad('docs/ai present', 26)}${project.docsAiPresent ? 'yes' : 'no'}`);
315
- if (extras.visibility) {
316
- lines.push(` ${pad('visibility', 26)}${extras.visibility.error ? `error: ${extras.visibility.error}` : extras.visibility.state}`);
317
- } else {
318
- lines.push(` ${pad('hidden-mode fence', 26)}${project.hiddenFence ? 'present' : 'absent'}`);
319
- }
320
- }
321
- if (extras.settings) lines.push(...formatSettings(extras.settings));
322
- }
323
- return lines.join('\n');
324
- };
250
+ // The direct-CLI human render (formatStatus + formatSettings) was REPLACED by the capability-adaptive
251
+ // presenter pipeline (surface → view-model → renderers, Plan §4.2/§4.5): main() builds the no-leak
252
+ // envelope once, then renders it via toViewModel + render (plain/ansi) or prints it as JSON. ONE data
253
+ // source for both surfaces the agent-mediated `status` consumes `--json`, the direct CLI renders it.
325
254
 
326
255
  // ── the no-leak --json envelope ──────────────────────────────────────────────────
327
256
  // A machine-readable view with USER-SAFE field names only — NEVER the internal manifestState /
@@ -329,28 +258,8 @@ export const formatStatus = (family, project = null, extras = {}) => {
329
258
  // consumes THIS, never the human table verbatim. An envelope-shape test pins its shape so later phases
330
259
  // (the settings/visibility block) can't silently break the Phase-2 version consumer.
331
260
 
332
- // internal manifestState a STABLE, user-safe token (SKILL.md owns the value→plain-language phrasing).
333
- // Deliberately NOT the internal literals (foreign/stub/…): those must never leak past this boundary.
334
- const STATE_PUBLIC = {
335
- [OK]: 'installed',
336
- [NOT_INSTALLED]: 'absent',
337
- [FOREIGN]: 'other-tool',
338
- [STUB]: 'placeholder',
339
- [INVALID_MANIFEST]: 'invalid',
340
- [UNSUPPORTED_SCHEMA]: 'unsupported',
341
- [UNKNOWN]: 'uncheckable',
342
- };
343
-
344
- // Short, user-facing labels for the version block (kit · memory · engine · codex-bridge · …), so the
345
- // render is deterministic and the agent never invents a label.
346
- export const DISPLAY_NAMES = {
347
- 'agent-workflow-kit': 'kit',
348
- 'agent-workflow-memory': 'memory',
349
- 'agent-workflow-engine': 'engine',
350
- 'codex-cli-bridge': 'codex-bridge',
351
- 'antigravity-cli-bridge': 'antigravity-bridge',
352
- };
353
- const displayOf = (name) => DISPLAY_NAMES[name] ?? name;
261
+ // STATE_PUBLIC (internal→public token map) + DISPLAY_NAMES + displayOf now live in labels.mjs (B1)
262
+ // imported at the top of this file. They are used below exactly as before.
354
263
 
355
264
  // ── the settings survey (Phase 3) — read-only, honest, localized-on-error ──────────
356
265
  // Each sub-survey returns a small user-safe object OR a single `{ error }` field (a localized message,
@@ -378,8 +287,8 @@ const detectSafe = (deps) => {
378
287
  };
379
288
 
380
289
  // visibility: the THREE honest states from inferVisibility (NOT the single hiddenFence bit) → user-safe
381
- // words. Never the internal "hidden fence" / marker terms. A git/probe error → a localized error field.
382
- const VISIBILITY_PUBLIC = { visible: 'visible', hidden: 'hidden', ambiguous: 'unclear' };
290
+ // words (VISIBILITY_PUBLIC, from labels.mjs). Never the internal "hidden fence" / marker terms. A
291
+ // git/probe error a localized error field.
383
292
  export const surveyVisibility = (dir, deps = {}) => {
384
293
  try {
385
294
  const vis = inferVisibility(deps, resolve(dir));
@@ -467,6 +376,20 @@ export const surveyBridges = (deps = {}) => {
467
376
  });
468
377
  };
469
378
 
379
+ // INV-2 (structural refresh — additive, derived, never parsed from a caveat string). A member is
380
+ // `behind` when it is a refreshable CORE member (memory / engine) carrying a refresh-recommending
381
+ // caveat — surveyFamily attaches caveats ONLY to those two kinds, and today every such caveat is
382
+ // refresh-recommending. `recommend` is composed from the member's npm package in the ONE registry
383
+ // (FAMILY_MEMBERS[].npm), never extracted from the caveat text. The kit (composition-root) + the two
384
+ // bridges (execution-backend) never carry such a caveat, so they derive { behind:false, recommend:null }
385
+ // — gated by the CAVEAT, not by npm-nullness (the kit's npm is non-null; only the bridges are null).
386
+ const REFRESHABLE_KINDS = new Set(['memory-substrate', 'methodology-engine']);
387
+ const npmOf = (name) => FAMILY_MEMBERS.find((m) => m.name === name)?.npm ?? null;
388
+ const refreshOf = (m) => {
389
+ const behind = REFRESHABLE_KINDS.has(m.kind) && Boolean(m.caveats?.length);
390
+ return { behind, recommend: behind ? `npx ${npmOf(m.name)}@latest init` : null };
391
+ };
392
+
470
393
  export const buildEnvelope = (family, project = null, extras = {}) => {
471
394
  const installed = family.map((m) => {
472
395
  const entry = {
@@ -476,6 +399,7 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
476
399
  state: STATE_PUBLIC[m.manifestState] ?? STATE_PUBLIC[UNKNOWN],
477
400
  };
478
401
  if (m.caveats?.length) entry.notes = m.caveats; // plain-language observations (Steps 2.2 / engine)
402
+ entry.refresh = refreshOf(m); // additive (INV-1): always present; { behind, recommend } (INV-2)
479
403
  return entry;
480
404
  });
481
405
  const envelope = { deploymentHead: EXPECTED_WORKFLOW_VERSION, installed };
@@ -496,27 +420,72 @@ export const buildEnvelope = (family, project = null, extras = {}) => {
496
420
  };
497
421
 
498
422
  // ── CLI ────────────────────────────────────────────────────────────────────────
499
- const parseArgs = (argv) => {
500
- const dirFlag = argv.indexOf('--dir');
501
- return {
502
- help: argv.includes('--help') || argv.includes('-h'),
503
- json: argv.includes('--json'),
504
- dir: dirFlag >= 0 ? argv[dirFlag + 1] : undefined,
505
- };
423
+ // Parse contract (Plan §4.5, grounded): the old parseArgs SILENTLY ignored unknown args + a missing
424
+ // --dir value. With the explicit format surface added, the parse now rejects LOUDLY (no silent
425
+ // failure): an unknown flag, a `--dir` with no value, and an invalid `--format` (validated by
426
+ // resolveFormat) all throw. `--json` vs `--format=*` precedence is deterministic (last-wins, in
427
+ // surface.mjs). Returns only { dir } — help is handled first, the format/mode comes from the surface.
428
+ const HELP_FLAGS = new Set(['--help', '-h']);
429
+ // --dir and --format carry/consume a value, so they are handled explicitly below — only the valueless
430
+ // flags live in KNOWN_FLAGS.
431
+ const KNOWN_FLAGS = new Set(['--help', '-h', '--json']);
432
+ // Single left-to-right pass (functional reduce, no `let`): EVERY `--dir` must carry a value — a
433
+ // trailing or REPEATED `--dir` with no value rejects loudly, never silently (a first-occurrence-only
434
+ // check let `--dir /p --dir` slip through as a "known flag"). The token right after a `--dir` is its
435
+ // value (skipped), so a path is never mistaken for an unknown flag; the last `--dir` wins.
436
+ export const parseArgs = (argv) => {
437
+ const { dir } = argv.reduce(
438
+ (state, a, i) => {
439
+ if (state.skip) return { dir: state.dir, skip: false }; // this token was the preceding --dir value
440
+ if (a === '--dir') {
441
+ const value = argv[i + 1];
442
+ if (value === undefined || value.startsWith('-')) {
443
+ throw new Error('[agent-workflow-kit] --dir needs a value: --dir <project>');
444
+ }
445
+ return { dir: value, skip: true }; // consume the value; last --dir wins
446
+ }
447
+ if (KNOWN_FLAGS.has(a) || a === '--format' || a.startsWith('--format=')) return state;
448
+ throw new Error(`[agent-workflow-kit] unknown argument: ${a}`);
449
+ },
450
+ { dir: undefined, skip: false },
451
+ );
452
+ return { dir };
506
453
  };
507
454
 
508
- const main = (argv) => {
509
- const args = parseArgs(argv);
510
- if (args.help) {
511
- console.log(`family-registry — read-only view of the agent-workflow family.
455
+ const HELP = `family-registry read-only view of the agent-workflow family.
512
456
 
513
457
  Usage:
514
- node family-registry.mjs [--dir <project>] [--json]
515
- # skill axis always; deploy axis when --dir is given; --json = the no-leak machine envelope
458
+ node family-registry.mjs [--dir <project>] [--format=<auto|plain|ansi|json>] [--json]
459
+ # skill axis always; deploy axis when --dir is given.
460
+ # --format: auto (default — ansi on a TTY, plain otherwise) | plain | ansi | json.
461
+ # --json is sugar for --format=json (the no-leak machine envelope). AGENT_WORKFLOW_FORMAT
462
+ # sets the default; a flag beats it. Width/color follow the terminal (NO_COLOR / FORCE_COLOR).
516
463
 
517
- Detection only — never writes, never commits, never runs a subscription CLI.`);
464
+ Detection only — never writes, never commits, never runs a subscription CLI.`;
465
+
466
+ const main = (argv) => {
467
+ if (argv.some((a) => HELP_FLAGS.has(a))) {
468
+ console.log(HELP);
518
469
  return;
519
470
  }
471
+ // Validate args + resolve the output surface BEFORE any survey work; a bad flag/format → loud exit 1.
472
+ const args = (() => {
473
+ try {
474
+ const parsed = parseArgs(argv);
475
+ const surface = detectSurface({
476
+ argv,
477
+ env: process.env,
478
+ isTTY: Boolean(process.stdout.isTTY),
479
+ columns: process.stdout.columns,
480
+ platform: process.platform,
481
+ });
482
+ return { ...parsed, surface };
483
+ } catch (err) {
484
+ console.error(err?.message ?? String(err));
485
+ process.exit(1);
486
+ }
487
+ })();
488
+
520
489
  const family = surveyFamily();
521
490
  const bridges = surveyBridges();
522
491
  const project = args.dir ? surveyProject(args.dir) : null;
@@ -525,7 +494,10 @@ Detection only — never writes, never commits, never runs a subscription CLI.`)
525
494
  extras.visibility = surveyVisibility(args.dir);
526
495
  extras.settings = surveySettings(args.dir);
527
496
  }
528
- console.log(args.json ? JSON.stringify(buildEnvelope(family, project, extras), null, 2) : formatStatus(family, project, extras));
497
+ const envelope = buildEnvelope(family, project, extras);
498
+ // Two surfaces, one data source: JSON prints the envelope verbatim; plain/ansi render it through the
499
+ // capability-adaptive presenter pipeline (view-model → renderers). No-leak inherited from the envelope.
500
+ console.log(args.surface.mode === 'json' ? JSON.stringify(envelope, null, 2) : render(toViewModel(envelope), args.surface));
529
501
  };
530
502
 
531
503
  const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
@@ -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
- const METHODOLOGY_DESCRIPTOR = {
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 — slot was absent, inserted at the anchor, then filled.
164
- // reconciled-filled — slot existed but was empty, now filled.
165
- // present-filled — slot already carried contentpreserved verbatim.
166
- // error malformed slot, 0/>1 anchors, or cap exceeded input unchanged.
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 contentpreserved 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
- return current == null || current.trim() === '';
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 advisory (AD-019 §3.1a): a pre-existing FILLED methodology pointer that predates
327
- // the procedures clause won't be re-rendered (reconcile preserves it verbatim), so surface a hint to
328
- // add the procedures route. No mutation — purely a reported note appended to the success report.
329
- const proceduresNote = methResult.status === 'present-filled' ? methodologyProceduresHint(afterMeth) : null;
330
- const reportNote = () => {
331
- if (proceduresNote) console.log(`[inject-methodology] note: ${proceduresNote}`);
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
- reportNote();
461
+ reportNotes();
388
462
  return;
389
463
  }
390
464
  await writeAtomic(finalText);
391
465
  console.log(`[inject-methodology] reconcile: ${describeMeth}; ${describeOrch}.`);
392
- reportNote();
466
+ reportNotes();
393
467
  return;
394
468
  }
395
469