@hybridlabor-api/aos 4.10.0 → 4.11.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,57 @@
1
+ ---
2
+ description: Run the AOS multi-agent build graph (Architect, TechLead, build nodes, Reviewer, Shipping) with durable state in production_artifacts/state.json
3
+ agent: build
4
+ ---
5
+
6
+ # AOS `/startcycle-graph`
7
+
8
+ Goal: $ARGUMENTS
9
+
10
+ ## Step 0 — bootstrap the graph contract into this project
11
+
12
+ The agents dispatched below are told to read and write
13
+ `production_artifacts/state.json` "per `.agents/state.schema.json`". That path
14
+ resolves against the CURRENT PROJECT, not globally. In a project that has never
15
+ run the graph, those files are absent and every agent will freelance the state
16
+ shape instead of conforming to the schema.
17
+
18
+ ```bash
19
+ mkdir -p .agents
20
+ [ -f .agents/graph.md ] || cp "$HOME/.agents/graph.md" .agents/graph.md
21
+ [ -f .agents/state.schema.json ] || cp "$HOME/.agents/state.schema.json" .agents/state.schema.json
22
+ [ -f .agents/nodes.json ] || cp "$HOME/.agents/nodes.json" .agents/nodes.json
23
+ ```
24
+
25
+ `nodes.json` is not optional — it is the registry the run loads first, and a
26
+ missing or invalid one escalates before any agent runs. If any of the three is
27
+ also missing under `$HOME/.agents/`, stop and tell the user. Do not proceed.
28
+
29
+ ## Step 1 — dispatch
30
+
31
+ Read `.agents/graph.md` for the node/edge table and `.agents/state.schema.json`
32
+ for the state shape, then drive the run:
33
+
34
+ **Architect** → plan (`production_artifacts/00_execution_plan.md`)
35
+
36
+ **TechLead** → approve the plan's capability map, or reject it back to Architect
37
+
38
+ **Build** (parallel) → `production_artifacts/01_frontend_spec.md`,
39
+ `02_backend_schema.md`, and `03_media_pipeline.md` where the goal needs it
40
+
41
+ **Reviewer** → adversarial review of the build artifacts against the plan's
42
+ contract, writing `state.findings[]`. It reads the artifacts, never the
43
+ implementer's claim that it is done.
44
+
45
+ **Shipping** → run the quality gate. Ships only with every gate green and a
46
+ `GO` in `state.approvals`.
47
+
48
+ **The one rule: these agents never invoke each other.** You are the dispatcher.
49
+ After each agent returns, read `production_artifacts/state.json` and decide which
50
+ one runs next. If TechLead rejects, Reviewer has an open `blocking` finding, or
51
+ Shipping's gate fails, increment `state.iteration` and re-invoke the owning
52
+ node. If a repair round reports the exact same blocking finding id Reviewer
53
+ already flagged, escalate instead of repeating the cycle. At
54
+ `state.iteration >= max_iterations`, set `phase: escalated` and hand control back
55
+ to the user.
56
+
57
+ Full contract: `skills/basic/startcycle-graph/SKILL.md`.
@@ -31,6 +31,100 @@ const GUARDED_PATTERNS = [
31
31
 
32
32
  let lastHumanPrompt = '';
33
33
 
34
+ // ---------------------------------------------------------------------------
35
+ // Graph gate (W-5, W-6)
36
+ //
37
+ // The loop-keeper for /startcycle-graph on a harness with no Stop hook. Claude
38
+ // Code blocks the exit in .claude/hooks/graph-gate.mjs; OpenCode has no Stop
39
+ // event, so the same contract is kept by nudging the session awake on
40
+ // session.idle while production_artifacts/state.json still has open work.
41
+ //
42
+ // This is deliberately dumb: it reads state.json and it prompts. It never
43
+ // dispatches a node, never decides who runs next, and never writes state.json --
44
+ // the dispatcher owns that file, and a gate that writes the state it is
45
+ // measured against proves nothing. Everything here fails open.
46
+ // ---------------------------------------------------------------------------
47
+
48
+ const STATE_REL = path.join('production_artifacts', 'state.json');
49
+ const TERMINAL_PHASES = new Set(['done', 'escalated']);
50
+ const GATE_FIELDS = ['lint', 'typecheck', 'tests', 'a11y', 'seo', 'security'];
51
+ // Bounded so a stalled run nudges and then goes quiet instead of looping. The
52
+ // ceiling mirrors state.max_iterations; past it the human is the loop-breaker.
53
+ const MAX_NUDGES = 3;
54
+
55
+ const PIPELINE_COMMANDS = [
56
+ { name: 'startcycle-graph', skill: 'startcycle-graph', goal: '' },
57
+ { name: 'startcycle-graph-user', skill: 'startcycle-graph-user', goal: '' },
58
+ { name: 'startcycle', skill: 'startcycle', goal: '' },
59
+ ];
60
+
61
+ // sessionID -> { signature, nudges }
62
+ const nudgeState = new Map();
63
+
64
+ function readGraphState(directory) {
65
+ if (!directory) return null;
66
+ const p = path.join(directory, STATE_REL);
67
+ try {
68
+ if (!existsSync(p)) return null;
69
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
70
+ return parsed && typeof parsed === 'object' ? parsed : null;
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ // Returns null when nothing blocks, else { signature, text }.
77
+ function openGraphGate(state) {
78
+ if (!state) return null;
79
+
80
+ const reasons = [];
81
+
82
+ const phase = typeof state.phase === 'string' ? state.phase : null;
83
+ if (phase && !TERMINAL_PHASES.has(phase)) {
84
+ reasons.push(`state.phase is "${phase}" (terminal phases: ${[...TERMINAL_PHASES].join(', ')})`);
85
+ }
86
+
87
+ const findings = Array.isArray(state.findings) ? state.findings : [];
88
+ const openBlocking = findings
89
+ .filter(f => f && f.severity === 'blocking' && f.status === 'open')
90
+ .map(f => f.id || '<unnamed>');
91
+ if (openBlocking.length) {
92
+ reasons.push(`${openBlocking.length} open blocking finding(s): ${openBlocking.join(', ')}`);
93
+ }
94
+
95
+ const gate = state.gate && typeof state.gate === 'object' ? state.gate : null;
96
+ if (gate) {
97
+ const failed = GATE_FIELDS.filter(f => gate[f] === 'fail').map(f => `gate.${f}`);
98
+ if (failed.length) reasons.push(`quality gate failing: ${failed.join(', ')}`);
99
+ }
100
+
101
+ if (!reasons.length) return null;
102
+ return {
103
+ signature: `${phase}|${openBlocking.join(',')}|${reasons.length}`,
104
+ text: reasons.join('; '),
105
+ };
106
+ }
107
+
108
+ function buildGraphNudge(gate) {
109
+ return [
110
+ '[AOS Graph Gate] The /startcycle-graph run is not finished: ' + gate.text + '.',
111
+ 'Read production_artifacts/state.json and .agents/graph.md, then dispatch the',
112
+ 'next node the edge table requires. If state.iteration >= state.max_iterations,',
113
+ 'or a repair round reports the same blocking finding id again, set phase to',
114
+ '"escalated" and hand control back to the user instead of looping.',
115
+ ].join(' ');
116
+ }
117
+
118
+ // The pipeline group. /startcycle-graph also resolves natively from
119
+ // ~/.config/opencode/commands/startcycle-graph.md; this is the fallback for the
120
+ // names that have no command file, and it also covers a project that predates
121
+ // the payload.
122
+ function matchPipelineCommand(text) {
123
+ const m = text.match(/^\/(startcycle(?:-graph-user|-graph)?)(?:\s+(.*))?$/is);
124
+ if (!m) return null;
125
+ return PIPELINE_COMMANDS.find(c => c.name === m[1].toLowerCase()) || null;
126
+ }
127
+
34
128
  function isGuardedCommand(cmd) {
35
129
  if (typeof cmd !== 'string') return false;
36
130
  return GUARDED_PATTERNS.some((re) => re.test(cmd));
@@ -158,6 +252,36 @@ export default async function bdbAosPlugin(input) {
158
252
  const directory = input.directory || process.cwd();
159
253
 
160
254
  return {
255
+ // W-6 loop-keeper. Fires whenever a session goes idle. Reads the graph
256
+ // state and, while work is still open, prompts the session to continue.
257
+ // Never throws: a graph that cannot be read must not wedge the session.
258
+ event: async ({ event }) => {
259
+ try {
260
+ if (!event || event.type !== 'session.idle') return;
261
+ const sessionID = event.properties && event.properties.sessionID;
262
+ if (!sessionID || !input.client) return;
263
+
264
+ const gate = openGraphGate(readGraphState(directory));
265
+ if (!gate) return;
266
+
267
+ const prev = nudgeState.get(sessionID);
268
+ const nudges = prev ? prev.nudges : 0;
269
+ // Nudge once per distinct state, and never more than the ceiling. An
270
+ // unchanged gate is a stalled run, not a reason to loop.
271
+ if (prev && prev.signature === gate.signature) return;
272
+ if (nudges >= MAX_NUDGES) return;
273
+
274
+ nudgeState.set(sessionID, { signature: gate.signature, nudges: nudges + 1 });
275
+ await input.client.session.prompt({
276
+ path: { id: sessionID },
277
+ body: { parts: [{ type: 'text', text: buildGraphNudge(gate) }] },
278
+ query: directory ? { directory } : undefined,
279
+ });
280
+ } catch {
281
+ // Fail open. A graph gate that breaks the session is worse than no gate.
282
+ }
283
+ },
284
+
161
285
  'chat.message': async (msgInput, msgOutput) => {
162
286
  // Capture the user prompt text for GO-gate verification
163
287
  const textParts = (msgOutput.parts || []).filter((p) => p && p.type === 'text' && typeof p.text === 'string');
@@ -181,13 +305,14 @@ export default async function bdbAosPlugin(input) {
181
305
  }
182
306
  } catch {}
183
307
 
184
- // Recognize and wire /startcycle-graph workflow
185
- const graphMatch = fullText.match(/^\/startcycle-graph(?:\s+(.*))?$/is);
186
- if (graphMatch) {
187
- const goal = (graphMatch[1] || '').trim();
308
+ // Recognize and wire the AOS pipeline group
309
+ const pipeline = matchPipelineCommand(fullText);
310
+ if (pipeline) {
311
+ const goal = fullText.slice(pipeline.name.length + 1).trim();
188
312
  const graphInstructions = [
189
- `[AOS Autonomous Graph Workflow Engine - Active]`,
313
+ `[AOS Pipeline - ${pipeline.name} - Active]`,
190
314
  `Goal: "${goal || 'Execute planned architecture cycle'}"`,
315
+ `Skill: skills/basic/${pipeline.skill}/SKILL.md`,
191
316
  `State Schema: .agents/state.schema.json`,
192
317
  `Persisted State: production_artifacts/state.json`,
193
318
  `Available Nodes: .agents/nodes.json (Architect -> TechLead -> Build [UI_UX, Engineering, Media] -> Reviewer -> Shipping)`,
package/installer.js CHANGED
@@ -3674,6 +3674,106 @@ function injectHarnessRules() {
3674
3674
  }
3675
3675
  }
3676
3676
 
3677
+ // Copy the OpenCode plugin + command payload and register both in
3678
+ // opencode.jsonc. This is the single copy site: the Quick Update path
3679
+ // (injectHarnessRules -> installGlobalHooks) and the fresh-install path
3680
+ // (universalHarnessSync -> syncOpencodeConfig) both reach it, so a version that
3681
+ // adds or changes the plugin can no longer ship to one path and miss the other.
3682
+ //
3683
+ // `data` is mutated in place when the caller owns the config write (Universal
3684
+ // Sync merges MCP servers in the same pass and writes once). When `data` is
3685
+ // omitted the function loads and saves the config itself, which is what lets the
3686
+ // Quick Update path register the plugin as well as copy it.
3687
+ function installOpencodePlugin({ targetHome = homeDir, configPath = null, data = null } = {}) {
3688
+ const opencodeDir = configPath
3689
+ ? path.dirname(configPath)
3690
+ : (process.platform === 'win32'
3691
+ ? path.join(process.env.APPDATA || targetHome, 'opencode')
3692
+ : path.join(targetHome, '.config', 'opencode'));
3693
+
3694
+ const pluginSrc = path.join(srcDir, '.opencode', 'plugins', 'bdb-aos.js');
3695
+ let pluginInstalled = false;
3696
+ if (fs.existsSync(pluginSrc)) {
3697
+ const pluginDest = path.join(opencodeDir, 'plugins', 'bdb-aos.js');
3698
+ try {
3699
+ fs.mkdirSync(path.dirname(pluginDest), { recursive: true });
3700
+ fs.copyFileSync(pluginSrc, pluginDest);
3701
+ try { fs.chmodSync(pluginDest, 0o644); } catch (e) { logDebug(e, 'chmod opencode plugin'); }
3702
+ pluginInstalled = true;
3703
+ log.step(`Installed OpenCode plugin to ${pluginDest}`);
3704
+ } catch (e) {
3705
+ log.warn(`Could not install OpenCode plugin: ${e.message}`);
3706
+ }
3707
+ }
3708
+
3709
+ // Slash-command payloads. Without these the /startcycle-graph command has no
3710
+ // native resolution and only survives as a raw-text match in the plugin.
3711
+ const commandsSrc = path.join(srcDir, '.opencode', 'commands');
3712
+ if (fs.existsSync(commandsSrc)) {
3713
+ try {
3714
+ copyDirRecursiveSync(commandsSrc, path.join(opencodeDir, 'commands'));
3715
+ log.step(`Installed OpenCode commands to ${path.join(opencodeDir, 'commands')}`);
3716
+ } catch (e) {
3717
+ log.warn(`Could not install OpenCode commands: ${e.message}`);
3718
+ }
3719
+ }
3720
+
3721
+ if (!pluginInstalled) return data;
3722
+
3723
+ const ownsWrite = data === null;
3724
+ let beforeSerialized = null;
3725
+ if (ownsWrite) {
3726
+ if (!configPath) return data;
3727
+ const existing = readJsoncFile(configPath) || {};
3728
+ // Snapshot before mutating: `data` becomes the same object, so
3729
+ // comparing afterwards would always report "unchanged".
3730
+ beforeSerialized = JSON.stringify(existing, null, 2);
3731
+ data = existing;
3732
+ }
3733
+
3734
+ // Register the plugin.
3735
+ if (!Array.isArray(data.plugin)) data.plugin = [];
3736
+ const pluginPathNormalized = path.join(opencodeDir, 'plugins', 'bdb-aos.js').replace(/\\/g, '/');
3737
+ const alreadyRegistered = data.plugin.some(p => {
3738
+ const str = typeof p === 'string' ? p : (Array.isArray(p) ? p[0] : '');
3739
+ return str.includes('bdb-aos');
3740
+ });
3741
+ if (!alreadyRegistered) data.plugin.push(pluginPathNormalized);
3742
+
3743
+ // Skill paths. `.agents/skills` stays project-relative on purpose: it is the
3744
+ // per-project contract, and OpenCode resolves it against each project, so it
3745
+ // is meaningful in a bootstrapped project and inert elsewhere. The absolute
3746
+ // ~/.agents/skills is added only when it is really there -- registering a
3747
+ // path that does not exist is the defect this replaces.
3748
+ const homeAgentsSkills = path.join(targetHome, '.agents', 'skills').replace(/\\/g, '/');
3749
+ const desired = ['.agents/skills'];
3750
+ if (fs.existsSync(homeAgentsSkills)) desired.unshift(homeAgentsSkills);
3751
+ if (!data.skills || typeof data.skills !== 'object' || !Array.isArray(data.skills.paths)) {
3752
+ data.skills = { paths: desired.slice() };
3753
+ } else {
3754
+ for (const p of desired) {
3755
+ if (!data.skills.paths.includes(p)) data.skills.paths.push(p);
3756
+ }
3757
+ }
3758
+
3759
+ if (ownsWrite) {
3760
+ // Write only on a real change. readJsoncFile strips comments, so
3761
+ // serialising an unchanged config would silently eat a user's comments
3762
+ // for no benefit. When a write is genuinely needed the comments in the
3763
+ // file are lost -- acceptable, and worth saying out loud in the PR.
3764
+ const serialized = JSON.stringify(data, null, 2);
3765
+ if (beforeSerialized === serialized) return data;
3766
+ try {
3767
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
3768
+ fs.writeFileSync(configPath, serialized, { mode: 0o600 });
3769
+ try { fs.chmodSync(configPath, 0o600); } catch (e) { logDebug(e, 'chmod opencode config'); }
3770
+ } catch (e) {
3771
+ log.warn(`Could not write ${configPath}: ${e.message}`);
3772
+ }
3773
+ }
3774
+ return data;
3775
+ }
3776
+
3677
3777
  // Deliver the hook scripts to ~/.claude/hooks/ and wire them in settings.json.
3678
3778
  // Both the full install and Quick Update funnel through here. Quick Update used
3679
3779
  // to do neither: it refreshes skills and submodules, but hooks are harness
@@ -3733,22 +3833,15 @@ function installGlobalHooks({ targetHome = homeDir, targetGemini = geminiDir } =
3733
3833
  mergeCodexTomlHooks(path.join(targetHome, '.codex', 'config.toml'));
3734
3834
 
3735
3835
  // 4. OpenCode CLI
3736
- const opencodeDir = process.platform === 'win32'
3737
- ? path.join(process.env.APPDATA || targetHome, 'opencode')
3738
- : path.join(targetHome, '.config', 'opencode');
3739
- const opencodePluginSrc = path.join(srcDir, '.opencode', 'plugins', 'bdb-aos.js');
3740
- if (fs.existsSync(opencodePluginSrc)) {
3741
- const opencodePluginsDir = path.join(opencodeDir, 'plugins');
3742
- const opencodePluginDest = path.join(opencodePluginsDir, 'bdb-aos.js');
3743
- try {
3744
- fs.mkdirSync(opencodePluginsDir, { recursive: true });
3745
- fs.copyFileSync(opencodePluginSrc, opencodePluginDest);
3746
- try { fs.chmodSync(opencodePluginDest, 0o644); } catch (e) { logDebug(e, 'chmod opencode plugin'); }
3747
- log.step(`Installed OpenCode plugin to ${opencodePluginDest}`);
3748
- } catch (e) {
3749
- log.warn(`Could not install OpenCode plugin: ${e.message}`);
3750
- }
3751
- }
3836
+ installOpencodePlugin({
3837
+ targetHome,
3838
+ configPath: path.join(
3839
+ process.platform === 'win32'
3840
+ ? path.join(process.env.APPDATA || targetHome, 'opencode')
3841
+ : path.join(targetHome, '.config', 'opencode'),
3842
+ 'opencode.jsonc'
3843
+ ),
3844
+ });
3752
3845
 
3753
3846
  // 5. Global CLI launcher binaries (aos-config, aos-dashboard, aos-uninstall)
3754
3847
  installGlobalBinaries();
@@ -4778,47 +4871,10 @@ async function universalHarnessSync(primaryMcpConfigPath, installedModules = [])
4778
4871
  }
4779
4872
  }
4780
4873
 
4781
- // Wire BDB AOS Plugin for OpenCode
4782
- const opencodeDir = path.dirname(targetPath);
4783
- const pluginsDir = path.join(opencodeDir, 'plugins');
4784
- const pluginFile = path.join(pluginsDir, 'bdb-aos.js');
4785
- const pluginSrc = path.join(srcDir, '.opencode', 'plugins', 'bdb-aos.js');
4786
-
4787
- try {
4788
- if (fs.existsSync(pluginSrc)) {
4789
- fs.mkdirSync(pluginsDir, { recursive: true });
4790
- fs.copyFileSync(pluginSrc, pluginFile);
4791
- try { fs.chmodSync(pluginFile, 0o644); } catch (e) { logDebug(e, 'chmod pluginFile'); }
4792
- }
4793
- } catch (pluginErr) {
4794
- log.warn(`Could not install OpenCode plugin: ${pluginErr.message}`);
4795
- }
4796
-
4797
- // Register plugin in opencode.jsonc if plugin file exists
4798
- if (fs.existsSync(pluginFile)) {
4799
- if (!Array.isArray(data.plugin)) {
4800
- data.plugin = [];
4801
- }
4802
- const pluginPathNormalized = pluginFile.replace(/\\/g, '/');
4803
- const alreadyRegistered = data.plugin.some(p => {
4804
- const str = typeof p === 'string' ? p : (Array.isArray(p) ? p[0] : '');
4805
- return str.includes('bdb-aos');
4806
- });
4807
- if (!alreadyRegistered) {
4808
- data.plugin.push(pluginPathNormalized);
4809
- }
4810
- }
4811
-
4812
- // Register skills paths for OpenCode
4813
- if (!data.skills || typeof data.skills !== 'object') {
4814
- data.skills = { paths: [".agents/skills"] };
4815
- } else if (Array.isArray(data.skills.paths)) {
4816
- if (!data.skills.paths.includes(".agents/skills")) {
4817
- data.skills.paths.push(".agents/skills");
4818
- }
4819
- } else {
4820
- data.skills.paths = [".agents/skills"];
4821
- }
4874
+ // Wire BDB AOS Plugin for OpenCode. `data` is handed in because this
4875
+ // function owns the write -- it merges MCP servers in the same pass.
4876
+ // homeDir matches how the opencode harness entry builds its path.
4877
+ installOpencodePlugin({ targetHome: homeDir, configPath: targetPath, data });
4822
4878
 
4823
4879
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
4824
4880
  fs.writeFileSync(targetPath, JSON.stringify(data, null, 2), { mode: 0o600 });
@@ -5497,6 +5553,7 @@ module.exports = {
5497
5553
  mergeCodexHooks: mergeCodexTomlHooks,
5498
5554
  mergeCodexTomlMcpServers,
5499
5555
  installGlobalHooks,
5556
+ installOpencodePlugin,
5500
5557
  installProjectHarness,
5501
5558
  promptMcpSelection,
5502
5559
  mirrorMcpServersTo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hybridlabor-api/aos",
3
- "version": "4.10.0",
3
+ "version": "4.11.0",
4
4
  "description": "AOS — A Curated AI AGENT OS. Optimized agent skills and add-ons like memB, OpenWiki, Heimdall Token Saver, and Godmode architectures.",
5
5
  "main": "installer.js",
6
6
  "engines": {
@@ -27,7 +27,7 @@
27
27
  "plugin:build": "node scripts/build-plugin-manifest.mjs",
28
28
  "plugin:check": "node scripts/build-plugin-manifest.mjs --check",
29
29
  "doctor": "node bin/aos-doctor.mjs",
30
- "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node tests/aos-store.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs"
30
+ "test": "node scripts/validate-skills.mjs --selftest && node scripts/validate-skills.mjs && node scripts/build-plugin-manifest.mjs --check && node --test tests/opencode-graph-gate.test.js && node --test tests/cross-harness-hooks.test.js && node tests/aos-store.test.mjs && node tests/aos-doctor.test.mjs && node tests/build-plugin-manifest.test.mjs"
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"