Compare commits
11 Commits
feat/mosai
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a771c8f8 | |||
| bd9527c033 | |||
| aa221bf92e | |||
| 799df40f4e | |||
| b79e9f32c6 | |||
| 89d69eb23b | |||
| 59b611ba8a | |||
| dfa0be42f6 | |||
| bb96a3f23e | |||
| 48b2f28e45 | |||
| 8f09c910a9 |
@@ -114,6 +114,31 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- build
|
- build
|
||||||
|
|
||||||
|
build-appservice:
|
||||||
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
environment:
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: gitea_username
|
||||||
|
REGISTRY_PASS:
|
||||||
|
from_secret: gitea_password
|
||||||
|
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
|
||||||
|
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
|
||||||
|
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
|
||||||
|
commands:
|
||||||
|
- mkdir -p /kaniko/.docker
|
||||||
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
|
- |
|
||||||
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/appservice:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:latest"
|
||||||
|
fi
|
||||||
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/appservice:$CI_COMMIT_TAG"
|
||||||
|
fi
|
||||||
|
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
|
||||||
|
depends_on:
|
||||||
|
- build
|
||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
35
apps/appservice/package.json
Normal file
35
apps/appservice/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "@mosaicstack/mosaic-as",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
|
"directory": "apps/appservice"
|
||||||
|
},
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"bin": {
|
||||||
|
"mosaic-as": "dist/main.js",
|
||||||
|
"mosaic-as-registration": "dist/registration-main.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run --passWithNoTests",
|
||||||
|
"dev": "tsx watch src/main.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mosaicstack/appservice": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.8.0",
|
||||||
|
"vitest": "^2.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
|
}
|
||||||
243
apps/appservice/src/__tests__/server.test.ts
Normal file
243
apps/appservice/src/__tests__/server.test.ts
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppserviceDaemon } from '../server.js';
|
||||||
|
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
||||||
|
|
||||||
|
const cfg: DaemonConfig = {
|
||||||
|
homeserverUrl: 'https://hs.example',
|
||||||
|
domain: 'hs.example',
|
||||||
|
asToken: 'as-secret',
|
||||||
|
hsToken: 'hs-secret',
|
||||||
|
bridgeTokens: ['bridge-secret'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const jsonResponse = (status: number, body: unknown): Response =>
|
||||||
|
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
|
||||||
|
const request = (overrides: Partial<DaemonRequest>): DaemonRequest => ({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/',
|
||||||
|
searchParams: new URLSearchParams(),
|
||||||
|
body: undefined,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeDaemon = () => {
|
||||||
|
const fetchMock = vi.fn(async (_input: URL | string) => jsonResponse(200, { event_id: '$sent' }));
|
||||||
|
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||||
|
return { daemon, fetchMock };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AppserviceDaemon routing', () => {
|
||||||
|
it('serves health unauthenticated', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
expect((await daemon.handle(request({ path: '/health' }))).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('404s unknown paths', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
expect((await daemon.handle(request({ path: '/nope' }))).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('transactions require the hs_token', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
const bad = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/_matrix/app/v1/transactions/t1',
|
||||||
|
authorizationHeader: 'Bearer wrong',
|
||||||
|
body: { events: [] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bad.status).toBe(403);
|
||||||
|
const ok = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'PUT',
|
||||||
|
path: '/_matrix/app/v1/transactions/t1',
|
||||||
|
authorizationHeader: 'Bearer hs-secret',
|
||||||
|
body: { events: [{ type: 'm.room.message', event_id: '$e' }] },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bridge requires a bridge token (hs/as tokens do not work)', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
for (const token of [undefined, 'Bearer hs-secret', 'Bearer as-secret', 'Bearer nope']) {
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/messages',
|
||||||
|
authorizationHeader: token,
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bridge message sends as the agent and returns the event id', async () => {
|
||||||
|
const { daemon, fetchMock } = makeDaemon();
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/messages',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi', thread_root: '$req' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.event_id).toBe('$sent');
|
||||||
|
const sendCall = fetchMock.mock.calls
|
||||||
|
.map((c) => new URL(String(c[0])))
|
||||||
|
.find((u) => u.pathname.includes('/send/m.room.message/'));
|
||||||
|
expect(sendCall).toBeDefined();
|
||||||
|
expect(sendCall!.searchParams.get('user_id')).toBe('@agent-pi0-web1:hs.example');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bridge rejects invalid payloads with 400', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/messages',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: { room_id: 'bad', agent: 'pi0', body: 'x' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bridge typing endpoint works', async () => {
|
||||||
|
const { daemon, fetchMock } = makeDaemon();
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/typing',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: { room_id: '!r:hs.example', agent: 'pi0-web1', typing: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const typingCall = fetchMock.mock.calls
|
||||||
|
.map((c) => new URL(String(c[0])))
|
||||||
|
.find((u) => u.pathname.includes('/typing/'));
|
||||||
|
expect(typingCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('authenticated unknown bridge sub-paths return 405, never fall through', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/bridge/v1/unknown',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provisions a room as the AS sender with space linking', async () => {
|
||||||
|
const calls: Array<{ url: URL; body: unknown }> = [];
|
||||||
|
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
calls.push({ url, body: init?.body ? JSON.parse(String(init.body)) : undefined });
|
||||||
|
if (url.pathname.endsWith('/createRoom'))
|
||||||
|
return jsonResponse(200, { room_id: '!new:hs.example' });
|
||||||
|
return jsonResponse(200, {});
|
||||||
|
});
|
||||||
|
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/provision/rooms',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: {
|
||||||
|
name: 'proj-x',
|
||||||
|
alias: 'mosaic-proj-x',
|
||||||
|
invite: ['@jason.woltje:hs.example'],
|
||||||
|
space_id: '!space:hs.example',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.room_id).toBe('!new:hs.example');
|
||||||
|
expect(res.body.space_linked).toBe(true);
|
||||||
|
const create = calls.find((c) => c.url.pathname.endsWith('/createRoom'));
|
||||||
|
expect(create!.url.searchParams.get('user_id')).toBe('@mosaic-as:hs.example');
|
||||||
|
const body = create!.body as Record<string, unknown>;
|
||||||
|
expect(body.room_alias_name).toBe('mosaic-proj-x');
|
||||||
|
expect((body.power_level_content_override as Record<string, unknown>).users).toEqual({
|
||||||
|
'@mosaic-as:hs.example': 100,
|
||||||
|
});
|
||||||
|
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.child/'))).toBe(true);
|
||||||
|
expect(calls.some((c) => c.url.pathname.includes('/state/m.space.parent/'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('space-link failure still returns the room id (no orphan)', async () => {
|
||||||
|
const fetchMock = vi.fn(async (input: URL | string) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
if (url.pathname.endsWith('/createRoom'))
|
||||||
|
return jsonResponse(200, { room_id: '!new:hs.example' });
|
||||||
|
if (url.pathname.includes('/state/m.space.child/'))
|
||||||
|
return jsonResponse(403, { errcode: 'M_FORBIDDEN', error: 'no PL in space' });
|
||||||
|
return jsonResponse(200, {});
|
||||||
|
});
|
||||||
|
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/provision/rooms',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: { name: 'proj-x', space_id: '!space:hs.example' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.room_id).toBe('!new:hs.example');
|
||||||
|
expect(res.body.space_linked).toBe(false);
|
||||||
|
expect(String(res.body.space_error)).toContain('403');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invite list cap enforced', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/provision/rooms',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: { name: 'x', invite: Array.from({ length: 51 }, (_, i) => `@u${i}:hs`) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('provision rejects bad payloads and requires auth', async () => {
|
||||||
|
const { daemon } = makeDaemon();
|
||||||
|
const noAuth = await daemon.handle(
|
||||||
|
request({ method: 'POST', path: '/bridge/v1/provision/rooms', body: { name: 'x' } }),
|
||||||
|
);
|
||||||
|
expect(noAuth.status).toBe(403);
|
||||||
|
const bad = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/provision/rooms',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: { name: '', alias: 'BAD ALIAS' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(bad.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty bridge token list denies everything', async () => {
|
||||||
|
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/typing',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
});
|
||||||
23
apps/appservice/src/config.ts
Normal file
23
apps/appservice/src/config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { DaemonConfig } from './server.js';
|
||||||
|
|
||||||
|
const required = (name: string): string => {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) throw new Error(`missing required env var ${name}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function configFromEnv(): DaemonConfig & { port: number } {
|
||||||
|
return {
|
||||||
|
homeserverUrl: required('MOSAIC_AS_HOMESERVER_URL'),
|
||||||
|
domain: required('MOSAIC_AS_DOMAIN'),
|
||||||
|
asToken: required('MOSAIC_AS_TOKEN'),
|
||||||
|
hsToken: required('MOSAIC_HS_TOKEN'),
|
||||||
|
userPrefix: process.env.MOSAIC_AS_USER_PREFIX ?? 'agent-',
|
||||||
|
senderLocalpart: process.env.MOSAIC_AS_SENDER_LOCALPART ?? 'mosaic-as',
|
||||||
|
bridgeTokens: (process.env.MOSAIC_AS_BRIDGE_TOKENS ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
port: Number(process.env.MOSAIC_AS_PORT ?? 8008),
|
||||||
|
};
|
||||||
|
}
|
||||||
67
apps/appservice/src/main.ts
Normal file
67
apps/appservice/src/main.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
|
||||||
|
import { configFromEnv } from './config.js';
|
||||||
|
import { AppserviceDaemon } from './server.js';
|
||||||
|
|
||||||
|
const cfg = configFromEnv();
|
||||||
|
const daemon = new AppserviceDaemon(cfg);
|
||||||
|
|
||||||
|
const MAX_BODY_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let received = 0;
|
||||||
|
let rejected = false;
|
||||||
|
req.on('data', (chunk: Buffer) => {
|
||||||
|
received += chunk.length;
|
||||||
|
if (received > MAX_BODY_BYTES) {
|
||||||
|
rejected = true;
|
||||||
|
res.writeHead(413, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ errcode: 'M_TOO_LARGE', error: 'request body too large' }));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (rejected) return;
|
||||||
|
void (async () => {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://localhost');
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
const raw = Buffer.concat(chunks).toString();
|
||||||
|
body = raw ? JSON.parse(raw) : undefined;
|
||||||
|
} catch {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ errcode: 'M_NOT_JSON', error: 'invalid json' }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await daemon.handle({
|
||||||
|
method: req.method ?? 'GET',
|
||||||
|
path: url.pathname,
|
||||||
|
searchParams: url.searchParams,
|
||||||
|
authorizationHeader: req.headers.authorization,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
res.writeHead(result.status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(result.body));
|
||||||
|
})().catch((error: unknown) => {
|
||||||
|
console.error('request failed:', error);
|
||||||
|
if (res.headersSent) {
|
||||||
|
res.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: 'internal error' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(cfg.port, () => {
|
||||||
|
console.log(
|
||||||
|
`mosaic-as listening on :${cfg.port} (homeserver ${cfg.homeserverUrl}, domain ${cfg.domain})`,
|
||||||
|
);
|
||||||
|
if (cfg.bridgeTokens.length === 0) {
|
||||||
|
console.warn('WARNING: MOSAIC_AS_BRIDGE_TOKENS is empty — bridge API will deny all requests');
|
||||||
|
}
|
||||||
|
});
|
||||||
10
apps/appservice/src/registration-main.ts
Normal file
10
apps/appservice/src/registration-main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { buildRegistration, registrationToYaml } from '@mosaicstack/appservice';
|
||||||
|
|
||||||
|
import { configFromEnv } from './config.js';
|
||||||
|
|
||||||
|
// Prints the Synapse registration YAML (mosaic-as.yaml) for the current env.
|
||||||
|
// Usage: MOSAIC_AS_URL=http://mosaic-as:8008 mosaic-as-registration > mosaic-as.yaml
|
||||||
|
const cfg = configFromEnv();
|
||||||
|
const url = process.env.MOSAIC_AS_URL;
|
||||||
|
if (!url) throw new Error('missing required env var MOSAIC_AS_URL');
|
||||||
|
process.stdout.write(registrationToYaml(buildRegistration(cfg, { url })));
|
||||||
146
apps/appservice/src/server.ts
Normal file
146
apps/appservice/src/server.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AppserviceIntent,
|
||||||
|
TransactionHandler,
|
||||||
|
validateBridgeMessage,
|
||||||
|
validateBridgeTyping,
|
||||||
|
validateProvisionRoom,
|
||||||
|
} from '@mosaicstack/appservice';
|
||||||
|
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
||||||
|
|
||||||
|
export interface DaemonConfig extends AppserviceConfig {
|
||||||
|
/** Bearer tokens accepted on /bridge/v1/* (one per agent-comms host daemon). */
|
||||||
|
bridgeTokens: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaemonRequest {
|
||||||
|
method: string;
|
||||||
|
/** URL path without query string. */
|
||||||
|
path: string;
|
||||||
|
searchParams: URLSearchParams;
|
||||||
|
authorizationHeader?: string;
|
||||||
|
body: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DaemonResponse {
|
||||||
|
status: number;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare equal-length HMAC digests so neither content nor LENGTH of the
|
||||||
|
// stored secret is observable through timing.
|
||||||
|
const HMAC_KEY = randomBytes(32);
|
||||||
|
const digest = (value: string): Buffer => createHmac('sha256', HMAC_KEY).update(value).digest();
|
||||||
|
|
||||||
|
const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
|
||||||
|
|
||||||
|
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
||||||
|
* Application Service transactions endpoint (Synapse-facing) plus the
|
||||||
|
* internal bridge API v1 (agent-comms daemon-facing). main.ts binds this to
|
||||||
|
* node:http; tests drive it directly.
|
||||||
|
*/
|
||||||
|
export class AppserviceDaemon {
|
||||||
|
readonly intent: AppserviceIntent;
|
||||||
|
private readonly transactions: TransactionHandler;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly cfg: DaemonConfig,
|
||||||
|
fetchImpl?: typeof fetch,
|
||||||
|
private readonly log: (line: string) => void = (line) => console.log(line),
|
||||||
|
) {
|
||||||
|
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
||||||
|
this.transactions = new TransactionHandler({
|
||||||
|
hsToken: cfg.hsToken,
|
||||||
|
onEvent: (event) => this.onEvent(event),
|
||||||
|
onError: (error, txnId) => this.log(`txn ${txnId} handler error: ${String(error)}`),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1: the daemon only observes; room logic lives in the agent-comms daemons. */
|
||||||
|
private onEvent(event: MatrixEvent): void {
|
||||||
|
if (event.type === 'm.room.message') {
|
||||||
|
this.log(
|
||||||
|
`event ${event.event_id ?? '?'} in ${event.room_id ?? '?'} from ${event.sender ?? '?'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
|
||||||
|
if (!authorizationHeader?.startsWith('Bearer ')) return false;
|
||||||
|
const presented = authorizationHeader.slice('Bearer '.length);
|
||||||
|
return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||||
|
if (req.method === 'GET' && req.path === '/health') {
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const txnMatch = req.method === 'PUT' ? TXN_PATH.exec(req.path) : null;
|
||||||
|
if (txnMatch?.[1] !== undefined) {
|
||||||
|
return this.transactions.handle(txnMatch[1], req.body, {
|
||||||
|
authorizationHeader: req.authorizationHeader,
|
||||||
|
accessTokenParam: req.searchParams.get('access_token') ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.path.startsWith('/bridge/v1/')) {
|
||||||
|
if (!this.bridgeAuthorized(req.authorizationHeader)) {
|
||||||
|
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
||||||
|
validateBridgeMessage(req.body);
|
||||||
|
const eventId = await this.intent.sendAsAgent({
|
||||||
|
roomId: req.body.room_id,
|
||||||
|
agent: req.body.agent,
|
||||||
|
body: req.body.body,
|
||||||
|
threadRoot: req.body.thread_root,
|
||||||
|
msgtype: req.body.msgtype,
|
||||||
|
extraContent: req.body.extra_content,
|
||||||
|
});
|
||||||
|
return { status: 200, body: { event_id: eventId ?? null } };
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
||||||
|
validateBridgeTyping(req.body);
|
||||||
|
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
||||||
|
return { status: 200, body: {} };
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.path === '/bridge/v1/provision/rooms') {
|
||||||
|
validateProvisionRoom(req.body);
|
||||||
|
const result = await this.intent.createRoom({
|
||||||
|
name: req.body.name,
|
||||||
|
alias: req.body.alias,
|
||||||
|
topic: req.body.topic,
|
||||||
|
invite: req.body.invite,
|
||||||
|
spaceId: req.body.space_id,
|
||||||
|
});
|
||||||
|
this.log(
|
||||||
|
`provisioned room ${result.roomId} (${req.body.name}) space_linked=${result.spaceLinked}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
room_id: result.roomId,
|
||||||
|
space_linked: result.spaceLinked,
|
||||||
|
...(result.spaceError ? { space_error: result.spaceError } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
|
||||||
|
return { status: 400, body: { error: message } };
|
||||||
|
}
|
||||||
|
// Explicit: never fall out of the authenticated bridge block, so future
|
||||||
|
// sub-paths cannot accidentally route around the auth guard above.
|
||||||
|
return { status: 405, body: { error: 'unsupported bridge method/path' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 404, body: { error: 'not found' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/appservice/tsconfig.json
Normal file
9
apps/appservice/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
28
docker/appservice.Dockerfile
Normal file
28
docker/appservice.Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
# Copy workspace manifests first for layer-cached install
|
||||||
|
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
|
||||||
|
COPY apps/appservice/package.json ./apps/appservice/
|
||||||
|
COPY packages/ ./packages/
|
||||||
|
COPY plugins/ ./plugins/
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm turbo run build --filter @mosaicstack/mosaic-as...
|
||||||
|
RUN pnpm --filter @mosaicstack/mosaic-as --prod deploy --legacy /deploy
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY --from=builder /deploy/node_modules ./node_modules
|
||||||
|
COPY --from=builder /deploy/package.json ./package.json
|
||||||
|
COPY --from=builder /app/apps/appservice/dist ./dist
|
||||||
|
USER node
|
||||||
|
EXPOSE 8008
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=5 \
|
||||||
|
CMD ["node", "-e", "require('http').get('http://127.0.0.1:8008/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
@@ -39,3 +39,9 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
1. Read [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) for workstream scope
|
1. Read [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) for workstream scope
|
||||||
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
||||||
3. Follow per-task agent + tier guidance from the workstream manifest
|
3. Follow per-task agent + tier guidance from the workstream manifest
|
||||||
|
|
||||||
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|
||||||
|
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||||
|
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
||||||
|
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
||||||
|
|||||||
101
docs/mission-control/BOARD.md
Normal file
101
docs/mission-control/BOARD.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Mission Control Plane — Feature Board
|
||||||
|
|
||||||
|
> Discussion board for the combined PRD / mission / Kanban workflow.
|
||||||
|
> Use this to decide scope before implementation.
|
||||||
|
|
||||||
|
## Board Legend
|
||||||
|
|
||||||
|
- **Must-have** — required for the first usable version
|
||||||
|
- **Should-have** — strongly preferred, but can ship after the core path
|
||||||
|
- **Could-have** — valuable later if time permits
|
||||||
|
- **Won't-have** — explicitly deferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Feature Board
|
||||||
|
|
||||||
|
| Feature Card | Need | Priority | Decision / Notes |
|
||||||
|
| ------------------------------ | ------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------- |
|
||||||
|
| Canonical mission manifest | One durable root object for goal, PRD, board, session | Must-have | Mission manifest becomes the anchor for all downstream state |
|
||||||
|
| PRD generator integration | PRD should be generated from a feature idea and saved in docs | Must-have | Use Mosaic PRDy format and keep the file human-reviewable |
|
||||||
|
| Board atomization | Break PRD into assignable tasks with dependencies | Must-have | Each user story should map to one or more tasks |
|
||||||
|
| Short-cycle detector | Detect compaction churn and repeated tool loops | Must-have | Coordinator should track churn score per session |
|
||||||
|
| Handoff packet | Preserve actionable context across rotations | Must-have | Use a compact structured summary, not a raw transcript |
|
||||||
|
| Auto-resume workers | Let new sessions read mission + board on start | Should-have | Makes overnight autonomy realistic |
|
||||||
|
| Mission status view | Show current phase, blockers, and active session | Should-have | Expose through CLI first, dashboard later |
|
||||||
|
| Worktree root convention | Keep worktrees off `/tmp` and on the larger persistent drive | Should-have | Prefer `/src/<repo>-worktrees` for repo worktrees and long-lived agent work |
|
||||||
|
| Review gate | Prevent autonomous work from shipping unreviewed | Should-have | Use reviewer tasks before mission close |
|
||||||
|
| Rotation policy config | Configure thresholds per mission/profile | Could-have | Keep v1 simple, add tuning later |
|
||||||
|
| Goal decomposition suggestions | Suggest sub-goals from the PRD | Could-have | Good for planning, not necessary for core path |
|
||||||
|
| Cross-channel continuity | Continue a mission across CLI/gateway/remote channels | Could-have | Important later, not required for MVP |
|
||||||
|
| Automatic board sync | Mirror git docs into DB and back | Could-have | Nice-to-have after the file-first flow stabilizes |
|
||||||
|
| Fully autonomous closeout | Let mission finish without human intervention | Won't-have | Keep an operator-visible review step |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Needs Discussion
|
||||||
|
|
||||||
|
### 1) Canonical source of truth
|
||||||
|
|
||||||
|
**Question:** Should the PRD, mission manifest, and board all live in git, or should one be the database source of truth?
|
||||||
|
|
||||||
|
**Proposed answer:** Keep the human-readable artifacts in git and sync the mission runtime state to the database.
|
||||||
|
|
||||||
|
### 2) Scope of automation
|
||||||
|
|
||||||
|
**Question:** Should the first version auto-create the board from the PRD, or require a human/orchestrator to approve the split?
|
||||||
|
|
||||||
|
**Proposed answer:** Auto-create a draft board, then let the orchestrator approve or adjust it.
|
||||||
|
|
||||||
|
### 3) Rotation triggers
|
||||||
|
|
||||||
|
**Question:** What should trigger a forced session rotation?
|
||||||
|
|
||||||
|
**Candidate signals:**
|
||||||
|
|
||||||
|
- repeated compaction
|
||||||
|
- repeated prompts for permission
|
||||||
|
- identical tool loops
|
||||||
|
- no new file/task state after several turns
|
||||||
|
- task blocked on a missing prerequisite
|
||||||
|
|
||||||
|
**Proposed answer:** Use a weighted churn score with a small hard cap on repeated compactions.
|
||||||
|
|
||||||
|
### 4) Handoff format
|
||||||
|
|
||||||
|
**Question:** What should the next session receive?
|
||||||
|
|
||||||
|
**Proposed answer:**
|
||||||
|
|
||||||
|
- Mission ID
|
||||||
|
- PRD path
|
||||||
|
- Active board task
|
||||||
|
- Completed work
|
||||||
|
- Blockers
|
||||||
|
- Next 3 actions
|
||||||
|
- Non-negotiable constraints
|
||||||
|
|
||||||
|
### 5) Operator control
|
||||||
|
|
||||||
|
**Question:** Should the operator be able to force a rotation or pause the mission?
|
||||||
|
|
||||||
|
**Proposed answer:** Yes. Human override should win.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Draft Decisions
|
||||||
|
|
||||||
|
1. File-first artifacts, DB-backed runtime state.
|
||||||
|
2. PRD-first planning, board-second execution.
|
||||||
|
3. Auto-rotation on churn, but human override remains available.
|
||||||
|
4. Structured handoff packets required on every rotation.
|
||||||
|
5. Mission close requires a reviewer task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- What exact data fields belong in the mission manifest?
|
||||||
|
- Should rotation thresholds vary by agent profile?
|
||||||
|
- What is the minimum viable status surface for v1?
|
||||||
|
- Should the board support milestones in addition to tasks?
|
||||||
95
docs/mission-control/MISSION-MANIFEST.md
Normal file
95
docs/mission-control/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# Mission Manifest — Mosaic Mission Control Plane
|
||||||
|
|
||||||
|
> Persistent document tracking scope, status, and handoff history for the combined PRD / mission / Kanban workflow.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
**ID:** mission-control-plane-20260506
|
||||||
|
|
||||||
|
**Statement:** Combine Mosaic PRDy, coord, and Kanban into one durable workflow so an agent can move from feature idea to PRD to mission to task board and keep working across session rotation, compaction, and restarts with minimal context loss.
|
||||||
|
|
||||||
|
**Phase:** planning — MC-01 complete, MC-02 next
|
||||||
|
|
||||||
|
**Current Milestone:** MC-02
|
||||||
|
|
||||||
|
**Progress:** 1 / 6 milestones
|
||||||
|
|
||||||
|
**Status:** active
|
||||||
|
|
||||||
|
**Last Updated:** 2026-05-06
|
||||||
|
|
||||||
|
**Parent Mission:** None — new mission
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
This mission exists because overnight autonomy breaks when the working session short-cycles. The system needs durable artifacts and a mechanical coordinator that can:
|
||||||
|
|
||||||
|
1. keep a canonical PRD,
|
||||||
|
2. atomize the PRD into board tasks,
|
||||||
|
3. track mission state separately from the chat session,
|
||||||
|
4. detect churn or compaction pressure,
|
||||||
|
5. rotate to a fresh session, and
|
||||||
|
6. re-enter from a structured handoff.
|
||||||
|
|
||||||
|
Operational convention: repo worktrees and long-lived working directories should use `/src/<repo>-worktrees` instead of `/tmp`.
|
||||||
|
|
||||||
|
Design references:
|
||||||
|
|
||||||
|
- `docs/mission-control/PRD.md` — product requirements
|
||||||
|
- `docs/mission-control/BOARD.md` — feature discussion board
|
||||||
|
- `docs/mission-control/TASKS.md` — atomized execution plan
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] AC-1: A feature idea can be converted into a PRD, mission, and task board.
|
||||||
|
- [ ] AC-2: The coordinator can load a mission and its board from durable storage.
|
||||||
|
- [ ] AC-3: The coordinator can detect short-cycling and rotate sessions automatically.
|
||||||
|
- [ ] AC-4: A rotated session can resume from a handoff packet without manual re-prompting.
|
||||||
|
- [ ] AC-5: The board remains traceable back to the PRD user stories.
|
||||||
|
- [ ] AC-6: Operators can inspect mission state, task state, and latest handoff from one place.
|
||||||
|
- [ ] AC-7: The system can run overnight without losing the mission goal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| # | ID | Name | Status | Branch | Started | Completed |
|
||||||
|
| --- | ----- | ---------------------------------------- | ----------- | ----------------------- | ---------- | --------- |
|
||||||
|
| 1 | MC-01 | PRD + mission schema foundation | in-progress | docs/mission-control-\* | 2026-05-06 | — |
|
||||||
|
| 2 | MC-02 | Mission runtime model | not-started | — | — | — |
|
||||||
|
| 3 | MC-03 | Board atomization and task linkage | not-started | — | — | — |
|
||||||
|
| 4 | MC-04 | Short-cycle detector and rotation engine | not-started | — | — | — |
|
||||||
|
| 5 | MC-05 | Handoff generation and re-entry | not-started | — | — | — |
|
||||||
|
| 6 | MC-06 | Operator surface and E2E validation | not-started | — | — | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
| Milestone | Est. tokens | Parallelizable? |
|
||||||
|
| --------- | ----------- | ------------------ |
|
||||||
|
| MC-01 | 16K | No |
|
||||||
|
| MC-02 | 20K | No |
|
||||||
|
| MC-03 | 24K | Mostly after MC-01 |
|
||||||
|
| MC-04 | 20K | After MC-02 |
|
||||||
|
| MC-05 | 18K | After MC-04 |
|
||||||
|
| MC-06 | 26K | After MC-04/05 |
|
||||||
|
| **Total** | **~124K** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
| Session | Date | Runtime | Outcome |
|
||||||
|
| ------- | ---------- | ------- | ------------------------------------------------------------------------ |
|
||||||
|
| S1 | 2026-05-06 | hermes | PRD, board, task plan, mission manifest, and worktree convention drafted |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
|
||||||
|
Kick off MC-02: implement the durable mission runtime model and wire the mission state into the coordinator.
|
||||||
205
docs/mission-control/PRD.md
Normal file
205
docs/mission-control/PRD.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# PRD: Mosaic Mission Control Plane
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
- **Owner:** Jason Woltje
|
||||||
|
- **Date:** 2026-05-06
|
||||||
|
- **Status:** draft
|
||||||
|
- **Framework:** Mosaic PRDy + coord + Kanban
|
||||||
|
- **Target Repo:** `git.mosaicstack.dev/mosaic/mosaic-stack`
|
||||||
|
- **Primary Modules:** `packages/prdy`, `packages/coord`, `packages/queue`, `apps/gateway`, `packages/brain`, `packages/cli`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Mosaic already has the ingredients for durable agent work: PRD generation (`prdy`), mission coordination (`coord`), and task execution boards (`Kanban` / `TASKS.md`). Today those systems can still drift apart:
|
||||||
|
|
||||||
|
- A PRD can exist without a mission record.
|
||||||
|
- A mission can exist without a machine-readable execution board.
|
||||||
|
- Agents can short-cycle or compact repeatedly without a durable handoff.
|
||||||
|
- The next session may know the goal, but not the exact next step.
|
||||||
|
|
||||||
|
The result is brittle overnight autonomy: work continues only as long as a single session remains healthy.
|
||||||
|
|
||||||
|
This feature unifies those layers into one durable workflow so a mission can survive session rotation, compaction, and restarts with minimal state loss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Create one canonical pipeline from idea → PRD → mission → board → execution.
|
||||||
|
2. Let `prdy` generate a PRD that is immediately usable as a mission input.
|
||||||
|
3. Let `coord` own mission state, handoffs, and session rotation.
|
||||||
|
4. Let the board hold atomized tasks with dependencies and assignees.
|
||||||
|
5. Let agents read the mission and board to learn the next action without extra prompting.
|
||||||
|
6. Detect short-cycling and rotate sessions before quality degrades.
|
||||||
|
7. Preserve useful context across handoffs with a structured summary packet.
|
||||||
|
8. Give operators a single place to see mission status, task state, and the current session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
1. Replacing the Mosaic agent runtime or gateway architecture.
|
||||||
|
2. Rewriting `prdy` or `coord` from scratch.
|
||||||
|
3. Turning the board into a general project-management system.
|
||||||
|
4. Building a full Gantt/charting product.
|
||||||
|
5. Removing human review or approval gates.
|
||||||
|
6. Allowing agents to create arbitrary mission state without schema.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### US-001: Create a mission from a feature idea
|
||||||
|
|
||||||
|
**Description:** As an orchestrator, I want to turn a feature idea into a PRD and mission so that agents can work from a durable spec instead of a chat transcript.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
|
- [ ] `prdy` can emit a PRD with goals, non-goals, and requirements.
|
||||||
|
- [ ] The PRD is linked to a mission ID.
|
||||||
|
- [ ] The mission manifest references the PRD path.
|
||||||
|
- [ ] The mission is readable by downstream agent sessions.
|
||||||
|
|
||||||
|
### US-002: Atomize work into a board
|
||||||
|
|
||||||
|
**Description:** As an orchestrator, I want to split a PRD into board tasks so that work can be assigned to specialists.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
|
- [ ] Each user story can become one or more tasks.
|
||||||
|
- [ ] Tasks have assignees, dependencies, and estimates.
|
||||||
|
- [ ] Tasks are machine-readable and durable.
|
||||||
|
- [ ] The board can be regenerated from the PRD without ambiguity.
|
||||||
|
|
||||||
|
### US-003: Rotate sessions without losing the mission
|
||||||
|
|
||||||
|
**Description:** As a coordinator, I want to restart or rotate a session when it short-cycles so that the mission continues with minimal loss.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
|
- [ ] The coordinator detects compaction pressure or repeated loops.
|
||||||
|
- [ ] The coordinator writes a handoff summary before rotation.
|
||||||
|
- [ ] A new session can resume from the handoff packet.
|
||||||
|
- [ ] The mission state remains intact across the rotation.
|
||||||
|
|
||||||
|
### US-004: Let workers read the next step automatically
|
||||||
|
|
||||||
|
**Description:** As a worker agent, I want to read the mission and board at startup so I can do the next useful thing without waiting for a human prompt.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
|
- [ ] Startup loads the active mission manifest.
|
||||||
|
- [ ] Startup loads the current board/task row.
|
||||||
|
- [ ] Startup exposes the next action clearly in the prompt.
|
||||||
|
- [ ] The agent can continue after compaction using the same mission context.
|
||||||
|
|
||||||
|
### US-005: Observe mission health from one place
|
||||||
|
|
||||||
|
**Description:** As an operator, I want a single view of mission health so that I can see progress, blocked tasks, and session churn.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
|
||||||
|
- [ ] Mission state shows current phase and progress.
|
||||||
|
- [ ] Board state shows task status by assignee.
|
||||||
|
- [ ] Short-cycle/rotation events are visible.
|
||||||
|
- [ ] Handoffs are inspectable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
FR-1. The system must represent a mission as a durable object with an ID, goal, current phase, PRD path, board path, and active session ID.
|
||||||
|
|
||||||
|
FR-2. The system must represent a PRD as a markdown document with goals, user stories, functional requirements, non-goals, technical considerations, and success metrics.
|
||||||
|
|
||||||
|
FR-3. The system must represent execution work as a board of atomized tasks with status, assignee, dependency, and estimate fields.
|
||||||
|
|
||||||
|
FR-4. The coordinator must be able to derive a task board from a PRD.
|
||||||
|
|
||||||
|
FR-5. The coordinator must be able to write a handoff packet that includes goal, current state, completed work, blocked work, next steps, and constraints.
|
||||||
|
|
||||||
|
FR-6. The coordinator must detect short-cycling signals such as repeated compactions, repeated tool loops, repeated approval prompts, or no progress across several turns.
|
||||||
|
|
||||||
|
FR-7. The coordinator must rotate the session when the short-cycle threshold is exceeded.
|
||||||
|
|
||||||
|
FR-8. The coordinator must preserve mission continuity across session rotation.
|
||||||
|
|
||||||
|
FR-9. The worker session must read the mission state and board state at startup.
|
||||||
|
|
||||||
|
FR-10. The worker session must be able to resume from the last handoff summary without the operator rewriting the goal manually.
|
||||||
|
|
||||||
|
FR-11. The operator must be able to inspect the mission state, PRD, board, and latest handoff from one place.
|
||||||
|
|
||||||
|
FR-12. The mission system must keep a traceable link between PRD requirements and board tasks.
|
||||||
|
|
||||||
|
FR-13. The system must not allow a task to become active without a valid mission context.
|
||||||
|
|
||||||
|
FR-14. The system must keep durable history for rotation and handoff events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Board Discussion: Features and Needs
|
||||||
|
|
||||||
|
This is the feature discussion board that should drive the mission design.
|
||||||
|
|
||||||
|
| Card | Need | Why it matters | Proposed decision |
|
||||||
|
| ------------------------ | -------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------ |
|
||||||
|
| Canonical mission record | One source of truth for goal/state | Prevents drift between chat, docs, and queue | Make mission manifest the durable root object |
|
||||||
|
| PRD → board derivation | Break feature ideas into executable work | Lets the plan be assigned and tracked | Keep PRD as the spec, generate board tasks from user stories |
|
||||||
|
| Session watchdog | Detect churn/short-cycling | Keeps overnight runs productive | Add short-cycle scoring and forced rotation |
|
||||||
|
| Structured handoff | Preserve context across session changes | Minimizes restart loss | Use a compact JSON/MD handoff packet |
|
||||||
|
| Worker auto-read | Let agents resume without human re-prompting | Reduces operator overhead | Load mission + board on session start |
|
||||||
|
| Status surface | Show progress and blockers clearly | Operators need confidence | Expose mission state via CLI and dashboard |
|
||||||
|
| Review gate | Keep quality high on autonomous work | Prevents silent regressions | Require review tasks before close |
|
||||||
|
| Recoverability | Resume after failure or restart | Mission should outlive a process | Persist session and handoff history |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Considerations
|
||||||
|
|
||||||
|
1. The PRD should stay human-readable markdown, because the board and mission references need to be reviewable in git.
|
||||||
|
2. The board should be machine-readable enough for automation but still readable by humans.
|
||||||
|
3. The mission manifest should point to the PRD and board, not duplicate them.
|
||||||
|
4. Handoff packets should be compact and structured so they can be injected into a new session with minimal token cost.
|
||||||
|
5. The coordinator should prefer rotation over forced context growth once the session is near the compaction threshold.
|
||||||
|
6. Existing Mosaic commands should be extended, not replaced, wherever possible.
|
||||||
|
7. The same mission should be resumable across CLI, gateway, and remote channels.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Considerations
|
||||||
|
|
||||||
|
- Likely storage split:
|
||||||
|
- PRD/board/manifest in git-backed docs
|
||||||
|
- mission/session state in the Mosaic data layer
|
||||||
|
- runtime health in queue/session state
|
||||||
|
- Worktrees and long-lived agent working directories should live under `/src/<repo>-worktrees` rather than `/tmp` so they sit on the larger persistent drive and survive longer-running missions.
|
||||||
|
- The coordinator needs a stable session identity, even if the active session changes.
|
||||||
|
- Task dependencies must be enforced so workers do not start early.
|
||||||
|
- The handoff packet should include the top 3 immediate actions and the strongest constraints.
|
||||||
|
- Rotation triggers should be configurable per profile or per mission.
|
||||||
|
- The initial version can be file-first, with dashboard sync added later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- A mission can rotate sessions without losing the active goal.
|
||||||
|
- A new session can resume from the latest handoff in under one turn.
|
||||||
|
- Board tasks remain aligned to PRD user stories.
|
||||||
|
- Short-cycling sessions are replaced before repeated compaction harms quality.
|
||||||
|
- Operators can find mission state without spelunking across multiple chat logs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. What should the canonical mission ID format be?
|
||||||
|
2. Should the board live only in git, or also in the database?
|
||||||
|
3. Should rotation be automatic by default, or opt-in per mission?
|
||||||
|
4. What should the short-cycle threshold be initially?
|
||||||
|
5. Should handoffs be pure text, structured JSON, or both?
|
||||||
|
6. Which CLI command should be the primary mission entrypoint: `mosaic mission`, `mosaic coord`, or `mosaic prdy`?
|
||||||
113
docs/mission-control/TASKS.md
Normal file
113
docs/mission-control/TASKS.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Tasks — Mosaic Mission Control Plane
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
>
|
||||||
|
> **Mission:** mission-control-plane-20260506
|
||||||
|
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||||
|
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||||
|
> **Agent values:** `codex` | `glm-5.1` | `haiku` | `sonnet` | `opus` | `—` (auto)
|
||||||
|
>
|
||||||
|
> Scope: this file decomposes the combined PRD / mission / board workflow into atomized tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 1 — PRD + mission schema foundation
|
||||||
|
|
||||||
|
Goal: create the durable doc structure and the minimal mission metadata needed to keep PRD, board, and mission aligned.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ----------- | -------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------------- | ------------------ | -------- | ------------------------------------------- |
|
||||||
|
| MC-01-01 | not-started | Write `docs/mission-control/PRD.md` with goals, non-goals, functional requirements, and success metrics. | — | sonnet | docs/mission-control-prd | — | 5K | Human-readable PRD becomes the spec anchor. |
|
||||||
|
| MC-01-02 | not-started | Write `docs/mission-control/BOARD.md` as a decision board for scope, priority, and open questions. | — | haiku | docs/mission-control-board | MC-01-01 | 3K | Keeps discussion separate from the spec. |
|
||||||
|
| MC-01-03 | not-started | Write `docs/mission-control/MISSION-MANIFEST.md` linking PRD, board, tasks, and mission identity. | — | sonnet | docs/mission-control-manifest | MC-01-01, MC-01-02 | 4K | Durable mission root object. |
|
||||||
|
| MC-01-04 | not-started | Write `docs/mission-control/TASKS.md` with the atomized execution plan and dependency graph. | — | sonnet | docs/mission-control-tasks | MC-01-03 | 4K | Board-backed execution plan. |
|
||||||
|
|
||||||
|
**Milestone 1 estimate:** ~16K tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 2 — Mission runtime model
|
||||||
|
|
||||||
|
Goal: make missions first-class runtime objects that can survive session restarts and compaction.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ----- | -------------------------------------- | ---------------------------------- | -------- | ------------------------------------------ | ---------------------------------------------------- |
|
||||||
|
| MC-02-01 | not-started | Define mission schema in the data layer: mission ID, goal, phase, PRD path, board path, active session ID, last handoff, and churn score. | — | codex | feat/mission-control-schema | MC-01-03 | 6K | This is the durable root state. |
|
||||||
|
| MC-02-02 | not-started | Add mission read/write services to `packages/coord` so the coordinator can load and persist mission state. | — | codex | feat/mission-control-coord-store | MC-02-01 | 6K | Keep storage simple and explicit. |
|
||||||
|
| MC-02-03 | not-started | Add mission status reporting to `mosaic mission` and `mosaic coord status`. | — | codex | feat/mission-control-status-cli | MC-02-02 | 4K | Operators need one obvious status command. |
|
||||||
|
| MC-02-04 | not-started | Add tests for mission persistence and recovery after restart. | — | haiku | feat/mission-control-persistence-tests | MC-02-02 | 4K | Verify mission survives process churn. |
|
||||||
|
| | MC-02-05 | done | Add a worktree-root convention to the mission runtime notes and startup guidance so agents prefer `/src/<repo>-worktrees` over `/tmp`. | — | haiku | docs/mission-control-worktree-root | MC-01-03 | 3K | Keep long-lived work on the larger persistent drive. |
|
||||||
|
|
||||||
|
**Milestone 2 estimate:** ~20K tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 3 — Board atomization and task linkage
|
||||||
|
|
||||||
|
Goal: derive assignable tasks from the PRD and keep them linked to mission state.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ----------- | ------------------------------------------------------------------------------------------- | ----- | ------ | -------------------------------- | ------------------ | -------- | ------------------------------------------- |
|
||||||
|
| MC-03-01 | not-started | Add a PRD-to-task decomposition rule set: every user story maps to one or more board tasks. | — | sonnet | feat/mission-control-decompose | MC-01-01 | 5K | Start simple and deterministic. |
|
||||||
|
| MC-03-02 | not-started | Implement board generation from the PRD in a machine-readable format. | — | codex | feat/mission-control-board-gen | MC-03-01 | 6K | Output should be usable by the coordinator. |
|
||||||
|
| MC-03-03 | not-started | Add dependency validation so tasks cannot start before parent tasks complete. | — | codex | feat/mission-control-deps | MC-03-02 | 5K | Enforces ordering. |
|
||||||
|
| MC-03-04 | not-started | Add review-task support so a mission cannot close without a reviewer step. | — | sonnet | feat/mission-control-review-gate | MC-03-03 | 4K | Preserves quality. |
|
||||||
|
| MC-03-05 | not-started | Add tests proving the board stays traceable back to the PRD user stories. | — | haiku | feat/mission-control-trace-tests | MC-03-02, MC-03-03 | 4K | Traceability is the point. |
|
||||||
|
|
||||||
|
**Milestone 3 estimate:** ~24K tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 4 — Short-cycle detector and rotation engine
|
||||||
|
|
||||||
|
Goal: detect when a session is stuck and rotate to a fresh session before quality falls off.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------------------- | ---------- | -------- | ---------------------------------------------- |
|
||||||
|
| MC-04-01 | not-started | Define churn signals: repeated compaction, identical tool loops, repeated permission prompts, and no progress across several turns. | — | sonnet | feat/mission-control-churn-signals | MC-02-01 | 4K | Keep the rules explicit. |
|
||||||
|
| MC-04-02 | not-started | Implement churn scoring in the coordinator with configurable thresholds. | — | codex | feat/mission-control-churn-score | MC-04-01 | 6K | Weighted score makes tuning easier. |
|
||||||
|
| MC-04-03 | not-started | Implement automatic session rotation when churn crosses the threshold. | — | codex | feat/mission-control-rotate-session | MC-04-02 | 6K | The session is disposable; the mission is not. |
|
||||||
|
| MC-04-04 | not-started | Add tests for rotation triggers and for avoiding premature rotation. | — | haiku | feat/mission-control-rotation-tests | MC-04-03 | 4K | Prevent flapping. |
|
||||||
|
|
||||||
|
**Milestone 4 estimate:** ~20K tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 5 — Handoff generation and re-entry
|
||||||
|
|
||||||
|
Goal: preserve the best context from the old session and inject it into the new session cleanly.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------- | ----- | ------ | ----------------------------------- | ------------------ | -------- | ---------------------------------------- |
|
||||||
|
| MC-05-01 | not-started | Define the handoff packet schema: mission ID, session ID, completed work, blockers, next 3 actions, and constraints. | — | sonnet | feat/mission-control-handoff-schema | MC-02-01 | 4K | Keep it compact and structured. |
|
||||||
|
| MC-05-02 | not-started | Implement handoff packet writing during rotation. | — | codex | feat/mission-control-handoff-write | MC-05-01, MC-04-03 | 5K | Persist before the old session exits. |
|
||||||
|
| MC-05-03 | not-started | Implement handoff packet loading at session startup. | — | codex | feat/mission-control-handoff-load | MC-05-01, MC-04-03 | 5K | New session should know the next action. |
|
||||||
|
| MC-05-04 | not-started | Add tests proving a rotated session can continue the mission without manual re-prompting. | — | haiku | feat/mission-control-handoff-tests | MC-05-02, MC-05-03 | 4K | Resume quality is the key metric. |
|
||||||
|
|
||||||
|
**Milestone 5 estimate:** ~18K tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 6 — Operator surface and E2E validation
|
||||||
|
|
||||||
|
Goal: expose the whole workflow through commands and verify it end-to-end.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| -------- | ----------- | --------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------------------- | ------------------ | -------- | -------------------------------------------- |
|
||||||
|
| MC-06-01 | not-started | Add a CLI command to inspect the active mission, PRD path, board path, task statuses, and latest handoff. | — | codex | feat/mission-control-inspect-cli | MC-02-03, MC-05-03 | 5K | One place to inspect the whole stack. |
|
||||||
|
| MC-06-02 | not-started | Add a compact dashboard or TUI summary view for mission health. | — | codex | feat/mission-control-summary-ui | MC-06-01 | 6K | Nice to have, but not before the core works. |
|
||||||
|
| MC-06-03 | not-started | Build an E2E harness that simulates compaction / rotation and verifies the mission can continue. | — | sonnet | feat/mission-control-e2e-harness | MC-04-03, MC-05-03 | 8K | This is the proof that the design works. |
|
||||||
|
| MC-06-04 | not-started | Add final docs for operators explaining how PRD, mission, and board fit together. | — | haiku | feat/mission-control-ops-docs | MC-06-03 | 4K | Make it usable by humans. |
|
||||||
|
| MC-06-05 | not-started | Consolidate review findings and close the mission with a release note. | — | sonnet | chore/mission-control-close | MC-06-04 | 3K | Only after the E2E passes. |
|
||||||
|
|
||||||
|
**Milestone 6 estimate:** ~26K tokens
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Notes
|
||||||
|
|
||||||
|
- `sonnet` is best for planning, decomposition, and the review-gate tasks.
|
||||||
|
- `codex` is best for schema, coordinator, and CLI implementation.
|
||||||
|
- `haiku` is best for validation, traceability checks, and docs.
|
||||||
|
- The first implementation pass should stay file-first and keep the runtime state thin.
|
||||||
|
- The mission should not close until the PRD, board, mission manifest, and E2E harness all agree.
|
||||||
238
docs/plans/2026-05-06-hermes-mosaic-alignment.md
Normal file
238
docs/plans/2026-05-06-hermes-mosaic-alignment.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# Hermes-Mosaic Alignment Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Package Mosaic's mechanical coordination primitives as a native Hermes toolset so any Hermes profile gets mission management, task decomposition, handoff, and session continuity without depending on the Mosaic gateway or OpenClaw runtime.
|
||||||
|
|
||||||
|
**Architecture:** Extract the coordination logic from Mosaic's `packages/coord` (TypeScript, file-first) into a Hermes Python toolset that wraps the same file conventions. The Mosaic Stack repo remains the canonical upstream for the file formats (TASKS.md schema, mission.json schema, handoff packet schema). Hermes implements native Python tools that read/write those same files, plus tool-calls for churn detection and handoff generation that have no Mosaic equivalent today.
|
||||||
|
|
||||||
|
**Tech Stack:** Python (Hermes toolset), SQLite (Hermes Kanban), JSON + Markdown (Mosaic file conventions)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Alignment Map
|
||||||
|
|
||||||
|
### What Mosaic has that Hermes needs
|
||||||
|
|
||||||
|
| Mosaic Component | What it does | Natural Hermes home | Why |
|
||||||
|
| -------------------------------- | --------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `packages/coord` (mission.ts) | Mission CRUD, session tracking, milestone state | **Hermes toolset: `mission`** | Mission state is session-scoped, not gateway-scoped. Hermes sessions already have identity, process tracking, and context windows. |
|
||||||
|
| `packages/coord` (tasks-file.ts) | Parse/write TASKS.md tables | **Hermes toolset: `mission`** (same) | Hermes already reads/writes files. The TASKS.md parser is ~300 lines of pure string manipulation — trivial Python port. |
|
||||||
|
| `packages/coord` (runner.ts) | Spawn claude/codex workers with continuation prompts | **Already covered by `delegate_task`** | Hermes delegate_task already does isolated subagent spawning with restricted toolsets. The runner's "find next task and build continuation prompt" logic moves into a tool-call. |
|
||||||
|
| `packages/coord` (status.ts) | Mission health, task progress, next task | **Hermes toolset: `mission`** (same) | Status readout fits naturally as a tool-call. No gateway needed. |
|
||||||
|
| `packages/prdy` | PRD generation wizard | **Hermes skill: `prdy`** | PRD generation is a prompt + template problem, not infrastructure. A Hermes skill with templates is the right fit. |
|
||||||
|
| `plugins/mosaic-framework` | before_agent_start + subagent_spawning hooks | **Hermes system prompt injection** | Hermes already injects system context via skills and config. The framework preamble and worktree rules become standard Hermes skills loaded by the orchestrator profile. |
|
||||||
|
| `plugins/macp` | OpenClaw ACP bridge (spawn codex/claude) | **Already covered by `delegate_task` + ACP** | Hermes already has ACP support and delegate_task. The MACP bridge is redundant when running natively in Hermes. |
|
||||||
|
| Churn detection (planned) | Detect compaction loops, repeated tool calls, no progress | **Hermes middleware** | This needs to live inside Hermes's turn loop where it can observe tool-call patterns. Mosaic can't see this from outside. |
|
||||||
|
| Handoff packet (planned) | Structured context summary for session rotation | **Hermes toolset: `mission`** | Handoff is a serialization of mission + session state. Hermes owns the session, so it should own the handoff. |
|
||||||
|
|
||||||
|
### What Hermes already has that replaces Mosaic infrastructure
|
||||||
|
|
||||||
|
| Mosaic concept | Hermes equivalent | Notes |
|
||||||
|
| -------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Gateway (NestJS) | Hermes gateway | Hermes already has a gateway with WebSocket, Discord, Telegram, CLI. No need for a second one. |
|
||||||
|
| Pi SDK agent runtime | Hermes agent loop | Hermes IS the agent runtime. OpenClaw's Pi SDK is a different runtime that Mosaic targets. |
|
||||||
|
| MACP ACP bridge | `delegate_task` + ACP tools | Same capability, already native. |
|
||||||
|
| Session identity | Hermes session IDs + process_registry | Hermes already tracks session identity, PIDs, and background processes. |
|
||||||
|
| Task execution board | Hermes Kanban | Fully functional SQLite-backed Kanban with dispatcher, triage, events, comments. |
|
||||||
|
| Worker spawning | Hermes dispatcher + cron | Kanban dispatcher + cron already handle this. |
|
||||||
|
| Context injection | Hermes skills + system prompt | Skills are loaded at session start and injected into context. Exactly what mosaic-framework plugin does. |
|
||||||
|
| File checkpoints | Hermes checkpoint_manager | Already tracks file mutations with shadow git. |
|
||||||
|
|
||||||
|
### What Mosaic keeps as its own entity
|
||||||
|
|
||||||
|
| Component | Why it stays in Mosaic |
|
||||||
|
| --------------------- | --------------------------------------------------- |
|
||||||
|
| `apps/gateway` | NestJS API surface — Mosaic's web platform offering |
|
||||||
|
| `apps/web` | Next.js dashboard — Mosaic's UI offering |
|
||||||
|
| `packages/types` | Shared TS contracts for Mosaic gateway plugins |
|
||||||
|
| `packages/db` | Drizzle ORM + PG — Mosaic's data layer |
|
||||||
|
| `packages/auth` | BetterAuth — Mosaic's auth system |
|
||||||
|
| `packages/brain` | PG-backed data layer for Mosaic web app |
|
||||||
|
| `packages/queue` | Valkey task queue for Mosaic gateway |
|
||||||
|
| `plugins/discord` | OpenClaw Discord plugin |
|
||||||
|
| `plugins/telegram` | OpenClaw Telegram plugin |
|
||||||
|
| `packages/mosaic` CLI | The `mosaic` CLI — Mosaic's own command surface |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture: `mission` Toolset for Hermes
|
||||||
|
|
||||||
|
### New files under `/opt/hermes/tools/`
|
||||||
|
|
||||||
|
```
|
||||||
|
mission_tools.py — Tool-call surface (mission_create, mission_status,
|
||||||
|
mission_next_task, mission_update_task, mission_handoff,
|
||||||
|
mission_resume)
|
||||||
|
mission_state.py — State management (read/write mission.json, parse TASKS.md,
|
||||||
|
parse MISSION-MANIFEST.md)
|
||||||
|
mission_churn.py — Churn detection (tool-loop counter, compaction counter,
|
||||||
|
progress scorer)
|
||||||
|
mission_handoff.py — Handoff packet generation and loading
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tool-calls exposed to the agent
|
||||||
|
|
||||||
|
| Tool | What it does | When the agent calls it |
|
||||||
|
| --------------------- | --------------------------------------------------------------------------------- | ------------------------------------------- |
|
||||||
|
| `mission_create` | Initialize mission.json + TASKS.md + MISSION-MANIFEST.md in a project dir | When starting a new mission |
|
||||||
|
| `mission_status` | Read current mission state, milestone progress, next task, active session | At session start, or when checking progress |
|
||||||
|
| `mission_next_task` | Find the next `not-started` task whose dependencies are met, return its full spec | When the agent needs work to do |
|
||||||
|
| `mission_update_task` | Update a task row status in TASKS.md | When completing or blocking a task |
|
||||||
|
| `mission_handoff` | Generate a handoff packet from current session context + mission state | Before session rotation or at session end |
|
||||||
|
| `mission_resume` | Load a handoff packet and inject it as context for the new session | At session start after rotation |
|
||||||
|
|
||||||
|
### Toolset registration
|
||||||
|
|
||||||
|
The `mission` toolset follows the same pattern as `kanban`:
|
||||||
|
|
||||||
|
1. **Gating**: Tools are available when:
|
||||||
|
- The profile has `mission` in its toolsets config, OR
|
||||||
|
- A `HERMES_MISSION_DIR` env var is set (cron/dispatcher spawned workers)
|
||||||
|
2. **File conventions**: The toolset reads/writes the same file formats as Mosaic `packages/coord`:
|
||||||
|
- `.mosaic/orchestrator/mission.json` — mission state
|
||||||
|
- `docs/TASKS.md` — task table
|
||||||
|
- `docs/MISSION-MANIFEST.md` — mission manifest
|
||||||
|
- `docs/scratchpads/<id>.md` — session scratchpad
|
||||||
|
|
||||||
|
3. **Kanban bridge**: Optional bidirectional sync between mission TASKS.md rows and Kanban task cards, so the dashboard sees mission tasks.
|
||||||
|
|
||||||
|
### Churn detection (middleware)
|
||||||
|
|
||||||
|
Churn detection lives in Hermes's turn loop, NOT as a tool-call. It observes:
|
||||||
|
|
||||||
|
- Repeated compaction events (context window pressure)
|
||||||
|
- Identical tool-call sequences (loop detection)
|
||||||
|
- No file state changes across N turns
|
||||||
|
- Repeated permission denials
|
||||||
|
|
||||||
|
When churn score exceeds threshold:
|
||||||
|
|
||||||
|
1. `mission_handoff` is called automatically
|
||||||
|
2. Session is rotated (fresh context window)
|
||||||
|
3. `mission_resume` is called in the new session
|
||||||
|
|
||||||
|
This is new infrastructure that only Hermes can provide (Mosaic runs outside the agent loop).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Phase 1: Core state management (Python port of coord)
|
||||||
|
|
||||||
|
| Task | Files | Estimate |
|
||||||
|
| -------------------------------------------------- | ----------------------------- | -------- |
|
||||||
|
| 1.1 Port mission.json read/write to Python | `mission_state.py` | 2h |
|
||||||
|
| 1.2 Port TASKS.md parser to Python | `mission_state.py` | 2h |
|
||||||
|
| 1.3 Port MISSION-MANIFEST.md reader to Python | `mission_state.py` | 1h |
|
||||||
|
| 1.4 Implement `mission_create` tool-call | `mission_tools.py` | 1h |
|
||||||
|
| 1.5 Implement `mission_status` tool-call | `mission_tools.py` | 1h |
|
||||||
|
| 1.6 Implement `mission_next_task` tool-call | `mission_tools.py` | 1h |
|
||||||
|
| 1.7 Implement `mission_update_task` tool-call | `mission_tools.py` | 1h |
|
||||||
|
| 1.8 Register `mission` toolset in Hermes registry | `tools/registry.py` | 30m |
|
||||||
|
| 1.9 Add `mission` to orchestrator profile toolsets | `config.yaml` | 10m |
|
||||||
|
| 1.10 Write unit tests for mission_state | `tests/test_mission_state.py` | 2h |
|
||||||
|
| 1.11 Write unit tests for TASKS.md parser | `tests/test_tasks_parser.py` | 1h |
|
||||||
|
|
||||||
|
**Phase 1 estimate:** ~13h
|
||||||
|
|
||||||
|
### Phase 2: Handoff and session continuity
|
||||||
|
|
||||||
|
| Task | Files | Estimate |
|
||||||
|
| ------------------------------------------------- | ---------------------------------------- | -------- |
|
||||||
|
| 2.1 Define handoff packet schema (JSON) | `mission_handoff.py` | 1h |
|
||||||
|
| 2.2 Implement `mission_handoff` tool-call | `mission_handoff.py`, `mission_tools.py` | 2h |
|
||||||
|
| 2.3 Implement `mission_resume` tool-call | `mission_handoff.py`, `mission_tools.py` | 2h |
|
||||||
|
| 2.4 Wire handoff into session start (auto-resume) | agent loop hook | 2h |
|
||||||
|
| 2.5 Write tests for handoff round-trip | `tests/test_mission_handoff.py` | 1h |
|
||||||
|
|
||||||
|
**Phase 2 estimate:** ~8h
|
||||||
|
|
||||||
|
### Phase 3: Churn detection
|
||||||
|
|
||||||
|
| Task | Files | Estimate |
|
||||||
|
| -------------------------------------------------------------- | ----------------------------- | -------- |
|
||||||
|
| 3.1 Define churn signal weights and thresholds | `mission_churn.py` | 1h |
|
||||||
|
| 3.2 Implement tool-loop detector (consecutive identical calls) | `mission_churn.py` | 2h |
|
||||||
|
| 3.3 Implement compaction pressure detector | `mission_churn.py` | 1h |
|
||||||
|
| 3.4 Implement progress scorer (file state delta) | `mission_churn.py` | 2h |
|
||||||
|
| 3.5 Wire churn scoring into agent turn loop | agent loop middleware | 2h |
|
||||||
|
| 3.6 Implement auto-rotation trigger | agent loop + handoff | 2h |
|
||||||
|
| 3.7 Write tests for churn scoring | `tests/test_mission_churn.py` | 1h |
|
||||||
|
|
||||||
|
**Phase 3 estimate:** ~11h
|
||||||
|
|
||||||
|
### Phase 4: Kanban bridge + CLI surface
|
||||||
|
|
||||||
|
| Task | Files | Estimate |
|
||||||
|
| ---------------------------------------------------- | ------------------------ | -------- |
|
||||||
|
| 4.1 Implement TASKS.md → Kanban sync (one-way first) | `mission_kanban_sync.py` | 2h |
|
||||||
|
| 4.2 Add `hermes mission` CLI subcommand | `mission_cli.py` | 2h |
|
||||||
|
| 4.3 Add `hermes mission status` command | `mission_cli.py` | 1h |
|
||||||
|
| 4.4 Add `hermes mission init` command | `mission_cli.py` | 1h |
|
||||||
|
| 4.5 Add `hermes mission handoff` command | `mission_cli.py` | 1h |
|
||||||
|
| 4.6 Add `hermes mission resume` command | `mission_cli.py` | 1h |
|
||||||
|
|
||||||
|
**Phase 4 estimate:** ~8h
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Format Compatibility
|
||||||
|
|
||||||
|
The Python implementation MUST read and write the exact same file formats as Mosaic's TypeScript `packages/coord`. This means:
|
||||||
|
|
||||||
|
1. **mission.json** schema is identical to `Mission` type in `packages/coord/src/types.ts`
|
||||||
|
2. **TASKS.md** table format is identical to what `packages/coord/src/tasks-file.ts` parses
|
||||||
|
3. **MISSION-MANIFEST.md** is free-form markdown (no parser needed — just read the file)
|
||||||
|
4. **Handoff packets** are a new JSON format defined in this toolset (Mosaic doesn't have them yet)
|
||||||
|
|
||||||
|
This way a project can use Hermes mission tools OR Mosaic `mosaic coord` commands interchangeably. The files are the contract.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relationship Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Mosaic Stack (TypeScript) Hermes Agent (Python)
|
||||||
|
┌─────────────────────────┐ ┌─────────────────────────┐
|
||||||
|
│ packages/coord │ │ tools/mission_tools.py │
|
||||||
|
│ ├─ mission.ts │◄──────►│ ├─ mission_state.py │
|
||||||
|
│ ├─ tasks-file.ts │ same │ ├─ mission_handoff.py │
|
||||||
|
│ ├─ status.ts │ files │ ├─ mission_churn.py │
|
||||||
|
│ └─ runner.ts │ │ └─ mission_tools.py │
|
||||||
|
│ │ │ │
|
||||||
|
│ packages/prdy │ │ skills/prdy/ │
|
||||||
|
│ └─ templates, wizard │◄──────►│ └─ SKILL.md + templates │
|
||||||
|
│ │ │ │
|
||||||
|
│ plugins/mosaic-framework│ │ skills/ (existing) │
|
||||||
|
│ └─ context injection │◄──────►│ └─ kanban-orchestrator │
|
||||||
|
│ │ │ + mosaic-coding-* │
|
||||||
|
│ plugins/macp │ │ tools/delegate_task.py │
|
||||||
|
│ └─ ACP bridge │◄──────►│ └─ already covers this │
|
||||||
|
│ │ │ │
|
||||||
|
│ (stays in Mosaic) │ │ tools/kanban_tools.py │
|
||||||
|
│ apps/gateway │ │ └─ Hermes Kanban DB │
|
||||||
|
│ apps/web │ │ │
|
||||||
|
│ packages/db │ │ tools/cronjob_tools.py │
|
||||||
|
│ packages/queue │ │ └─ already covers cron │
|
||||||
|
└─────────────────────────┘ └─────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Should the `mission` toolset ship with Hermes core, or as a plugin?**
|
||||||
|
- Recommendation: ship as a **built-in toolset** (like `kanban`) since mission coordination is a core agent capability, not an optional integration. The file formats are stable and the code is small.
|
||||||
|
|
||||||
|
2. **Should churn detection be per-profile configurable?**
|
||||||
|
- Recommendation: yes. Add `mission.churn_threshold` and `mission.churn_weights` to profile config.yaml. Default threshold = 5 consecutive no-progress turns.
|
||||||
|
|
||||||
|
3. **Should handoff packets live in the project dir or in Hermes home?**
|
||||||
|
- Recommendation: **project dir** (`.mosaic/handoffs/<session-id>.json`). This keeps them version-controlled and accessible regardless of which agent runtime picks up the project.
|
||||||
|
|
||||||
|
4. **Bidirectional Kanban sync?**
|
||||||
|
- Recommendation: **one-way first** (TASKS.md → Kanban). Bidirectional adds conflict resolution complexity. Ship one-way, add reverse sync in v2 if needed.
|
||||||
|
|
||||||
|
5. **PRD generation — skill or tool-call?**
|
||||||
|
- Recommendation: **skill** (`prdy`). PRD generation is a prompt engineering problem with templates. Skills already handle this pattern perfectly.
|
||||||
236
docs/plans/2026-05-07-coordination-resilience.md
Normal file
236
docs/plans/2026-05-07-coordination-resilience.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Mosaic Stack ↔ Hermes Coordination Resilience
|
||||||
|
|
||||||
|
> Purpose: document the self-healing coordination patterns that emerged while implementing the Hermes mission toolset, distress-card protocol, and auto-heal watchers, so the same mechanics can be reimplemented in Mosaic Stack or any similar agent platform.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The coordination layer should be treated as a system of mechanical recovery loops rather than a single interactive agent session.
|
||||||
|
|
||||||
|
## SIBKISS operational summary
|
||||||
|
|
||||||
|
- mission on
|
||||||
|
- heartbeat always
|
||||||
|
- resume from packet
|
||||||
|
- block with `[BLOCKED]`
|
||||||
|
- reassign
|
||||||
|
- keep tasks tiny
|
||||||
|
- auto-heal dead workers
|
||||||
|
|
||||||
|
The design has four parts:
|
||||||
|
|
||||||
|
1. Atomic task decomposition — workers operate only within a small, explicit scope.
|
||||||
|
2. Distress signaling — workers create a standardized `[BLOCKED]` card when they encounter a blocker outside their scope.
|
||||||
|
3. Mechanical fallback — if the worker cannot phone home because of rate limits or dead context, a cron-style watcher synthesizes the distress card for them.
|
||||||
|
4. Auto-heal / reassignment — stale workers are reaped, crash-loops are reset, and rate-limited work is reassigned to a different profile/provider.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
Observed failure modes:
|
||||||
|
|
||||||
|
- Scope creep: a worker completes the target fix, then spends the rest of its budget chasing downstream cascade work.
|
||||||
|
- Silent failure / dead worker: the worker PID is gone, but the task remains running or blocked.
|
||||||
|
- Rate-limited worker: the worker is too constrained to create a help card itself, so it spins or fails without a clean handoff.
|
||||||
|
|
||||||
|
The answer is not to raise iteration caps or ask the worker to keep trying longer. The answer is to make the coordination layer self-healing and the work items atomic.
|
||||||
|
|
||||||
|
## Core workflow
|
||||||
|
|
||||||
|
### 1) Atomic task boundaries
|
||||||
|
|
||||||
|
Every task should have:
|
||||||
|
|
||||||
|
- one concern
|
||||||
|
- explicit files/packages in scope
|
||||||
|
- explicit files/packages out of scope
|
||||||
|
- a maximum file count if possible
|
||||||
|
- a stated expected iteration budget
|
||||||
|
|
||||||
|
When a worker discovers work outside scope, it must stop fixing it and hand off.
|
||||||
|
|
||||||
|
### 2) Worker-authored distress card
|
||||||
|
|
||||||
|
If the worker can still report status, it creates a card like:
|
||||||
|
|
||||||
|
- Title: `[BLOCKED] t_<source_id> <blocker_type>`
|
||||||
|
- Assignee: `tuesday` / orchestrator role
|
||||||
|
- Status: `ready`
|
||||||
|
- Body: standardized distress template with source task, blocker type, completed work, cannot-touch scope, and needed action
|
||||||
|
|
||||||
|
The orchestrator receives the card, acts on it, and closes the loop.
|
||||||
|
|
||||||
|
## Routing rules
|
||||||
|
|
||||||
|
### Distress card routing
|
||||||
|
|
||||||
|
- Title: `[BLOCKED] t_<source_id> <blocker_type>`
|
||||||
|
- Assignee: `tuesday` / orchestrator role
|
||||||
|
- Status: `ready`
|
||||||
|
- Body: standardized distress template with source task, blocker type, completed work, cannot-touch scope, and needed action
|
||||||
|
- Source task stays linked to the distress card so the recovery trail is auditable
|
||||||
|
|
||||||
|
The orchestrator receives the card, acts on it, and closes the loop.
|
||||||
|
|
||||||
|
### 3) Mechanical fallback for rate-limited workers
|
||||||
|
|
||||||
|
If the worker is too rate-limited or unstable to create the distress card itself, a no-agent watcher must synthesize the card from the task row and failure metadata.
|
||||||
|
|
||||||
|
That watcher should:
|
||||||
|
|
||||||
|
- inspect running / blocked tasks
|
||||||
|
- detect repeated 429 / 503 / overload errors
|
||||||
|
- create the same standardized `[BLOCKED]` card on behalf of the worker
|
||||||
|
- link the distress card to the source task
|
||||||
|
- add a comment to the source task
|
||||||
|
- allow the dispatcher to pick up the new card immediately
|
||||||
|
|
||||||
|
This is the key fix for the logic issue: the worker does not need to be able to phone home if the watcher can do it mechanically.
|
||||||
|
|
||||||
|
### 4) Auto-heal for dead workers
|
||||||
|
|
||||||
|
A separate no-agent watcher should:
|
||||||
|
|
||||||
|
- reap dead PIDs stuck in `running`
|
||||||
|
- reset crash-loops whose failures are infrastructure-related
|
||||||
|
- escalate tasks that have been reset too many times
|
||||||
|
|
||||||
|
This watcher prevents stale tasks from clogging the board and keeps the dispatch queue moving.
|
||||||
|
|
||||||
|
## Distress card contract
|
||||||
|
|
||||||
|
### Canonical title
|
||||||
|
|
||||||
|
```text
|
||||||
|
[BLOCKED] t_<source_task_id> <blocker_type>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Canonical blocker types
|
||||||
|
|
||||||
|
- `scope_boundary`
|
||||||
|
- `env_blocker`
|
||||||
|
- `credential_failure`
|
||||||
|
- `dependency`
|
||||||
|
- `iteration_budget`
|
||||||
|
- `rate_limited`
|
||||||
|
|
||||||
|
### Canonical body
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Distress Signal
|
||||||
|
|
||||||
|
- Blocked task: t_xxx
|
||||||
|
- Worker: <profile_name>
|
||||||
|
- Branch: <git_branch_name>
|
||||||
|
- Workspace: <path>
|
||||||
|
- Blocker type: <type>
|
||||||
|
- Completed: <what was done>
|
||||||
|
- Cannot touch: <out-of-scope packages/files>
|
||||||
|
- Needs: <what the orchestrator should do>
|
||||||
|
- State: committed | uncommitted | stashed(<stash_name>)
|
||||||
|
|
||||||
|
## Scope Guard
|
||||||
|
|
||||||
|
DO NOT touch: anything outside diagnosing and remediating the blocker described above
|
||||||
|
Only fix: assign, split, reassign, or unblock the source task
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routing rules
|
||||||
|
|
||||||
|
### Distress card routing
|
||||||
|
|
||||||
|
- `[BLOCKED]` title prefix should bypass normal triage.
|
||||||
|
- The card should go directly to the orchestration profile.
|
||||||
|
- The orchestrator should start from a clean session each time.
|
||||||
|
|
||||||
|
### Rate-limit fallback
|
||||||
|
|
||||||
|
When the source task is rate-limited:
|
||||||
|
|
||||||
|
- do not keep retrying in the worker
|
||||||
|
- let the watcher synthesize the distress card
|
||||||
|
- have the orchestrator reassign the source task to a different profile/provider combo
|
||||||
|
|
||||||
|
### Provider fallback principle
|
||||||
|
|
||||||
|
Never reassign rate-limited work back to the same provider if the failure was provider pressure. Use a different provider when possible.
|
||||||
|
|
||||||
|
### Suggested fallback order
|
||||||
|
|
||||||
|
1. Keep the current task body and scope guards intact.
|
||||||
|
2. Reassign to a different profile on a different provider.
|
||||||
|
3. If that is impossible, reassign to a different profile on the same provider only for non-rate-limit blockers.
|
||||||
|
4. If repeated failures continue, split the task into a narrower atomic card.
|
||||||
|
|
||||||
|
## Related recovery docs
|
||||||
|
|
||||||
|
- Mission packet recovery contract: `/opt/hermes/docs/mission-toolset-heartbeat.md`
|
||||||
|
- Hermes mission implementation plan: `/opt/hermes/docs/plans/mission-toolset-implementation.md`
|
||||||
|
- The same packet-first resume rule applies: inspect the latest packet before re-reading mission files.
|
||||||
|
- New-session trigger: when a profile config changes, start a fresh session or `/reset` so the updated toolset is actually loaded.
|
||||||
|
|
||||||
|
## Watchers to implement
|
||||||
|
|
||||||
|
### Auto-heal watcher
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- reap stale workers
|
||||||
|
- reset dead-PID crash loops
|
||||||
|
- track reset counts
|
||||||
|
- escalate after repeated resets
|
||||||
|
|
||||||
|
### Distress synthesizer watcher
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- detect rate-limited / stuck workers
|
||||||
|
- create `[BLOCKED]` cards mechanically
|
||||||
|
- link the card to the source task
|
||||||
|
- leave a comment for traceability
|
||||||
|
|
||||||
|
### Iteration-budget watcher
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- detect long-running tasks and repeated failure patterns
|
||||||
|
- recommend splits when a task is clearly over-scoped
|
||||||
|
- report tasks that need human review after multiple resets
|
||||||
|
|
||||||
|
## Operational principle
|
||||||
|
|
||||||
|
If a task cannot cleanly finish within its atomic scope, the right response is to surface a smaller coordination problem, not to keep burning context.
|
||||||
|
|
||||||
|
This is what makes the system robust across compaction, rate limits, and dead workers.
|
||||||
|
|
||||||
|
## Suggested implementation order
|
||||||
|
|
||||||
|
1. Atomic task metadata in task bodies
|
||||||
|
2. Worker-authored distress card protocol
|
||||||
|
3. Mechanical distress synthesizer watcher
|
||||||
|
4. Auto-heal watcher for dead workers
|
||||||
|
5. Orchestrator routing rules for `[BLOCKED]`
|
||||||
|
6. Rate-limit fallback / model reassignment table
|
||||||
|
|
||||||
|
## Where this fits in Hermes
|
||||||
|
|
||||||
|
- Kanban = durable work graph and status engine
|
||||||
|
- Watchers = mechanical healing and distress synthesis
|
||||||
|
- Orchestrator = split / reassign / unblock decision-maker
|
||||||
|
- Workers = execution inside atomic task boundaries
|
||||||
|
|
||||||
|
## Where this fits in Mosaic Stack
|
||||||
|
|
||||||
|
- PRD / coordination infra should encode the same patterns
|
||||||
|
- Mosaic can use the same distress-card contract and watcher logic
|
||||||
|
- The coordination model should be runtime-agnostic: any agent system can use it if it can write a task card and react to a ready queue
|
||||||
|
|
||||||
|
## Cross-project takeaway
|
||||||
|
|
||||||
|
The important pattern is not the specific tool names. It is the mechanical feedback loop:
|
||||||
|
|
||||||
|
- detect failure without requiring the failing worker to succeed
|
||||||
|
- create a standardized help artifact
|
||||||
|
- route that artifact to a fresh orchestrator context
|
||||||
|
- repair the assignment graph
|
||||||
|
- continue the mission
|
||||||
|
|
||||||
|
That pattern is reusable anywhere.
|
||||||
50
docs/scratchpads/536-wrapper-login-pin.md
Normal file
50
docs/scratchpads/536-wrapper-login-pin.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Issue 536 Wrapper Login Pin Scratchpad
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
- Date: 2026-06-12
|
||||||
|
- Worktree: `/home/hermes/agent-work/536-wrapper-audit`
|
||||||
|
- Branch: `fix/536-wrapper-login-pin`
|
||||||
|
- Coordinator: `mos-claude`
|
||||||
|
- Issue: `mosaicstack/stack#536`
|
||||||
|
- Scope: Audit and fix Gitea git wrappers that hardcode or incorrectly inherit tea login/instance selection.
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Fix the framework git wrappers so Gitea issue/PR operations resolve the tea login from the target repository host instead of pinning `mosaicstack`. The fix must cover the class of bug across `packages/mosaic/framework/tools/git/`, not only `issue-close.sh`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
1. `issue-close.sh` no longer uses `--login mosaicstack` for non-mosaic hosts.
|
||||||
|
2. All wrappers in `packages/mosaic/framework/tools/git/` avoid hardcoded Gitea login fallback where host-specific resolution is available.
|
||||||
|
3. Host-specific resolution works for `git.mosaicstack.dev` and `git.uscllc.com` using configured credentials / tea login data.
|
||||||
|
4. Read-only verification runs against both Gitea instances where possible.
|
||||||
|
5. Queue guard passes before push, PR is opened referencing #536, and merge is left to the coordinator.
|
||||||
|
|
||||||
|
## Progress Log
|
||||||
|
|
||||||
|
- Read required Mosaic hard-gate docs and coordinator briefing.
|
||||||
|
- Read issue #536 via Gitea API with mosaicstack credentials.
|
||||||
|
- Initial audit found hardcoded `${GITEA_LOGIN:-mosaicstack}` in issue and PR wrappers, plus shared `get_gitea_repo_args`.
|
||||||
|
- Added host-aware Gitea login resolution in `detect-platform.sh`, including exact host matching for `tea login list` entries and HTTPS remotes with embedded credentials.
|
||||||
|
- Updated Gitea issue, PR, milestone, and CI wrappers to use resolved host-specific tea login arguments instead of defaulting to `mosaicstack`.
|
||||||
|
- Added authenticated API fallbacks for close/reopen paths so wrappers can still operate when a matching `tea` login is absent but token credentials are available.
|
||||||
|
- Added regression coverage for stale `GITEA_LOGIN`, exact host matching, `--repo` override flows, USC issue close routing, mosaicstack API fallback, and PR metadata/merge fallbacks.
|
||||||
|
- Delta after PR #538 review: extended host-aware login/repo resolution to PowerShell wrappers, Bash milestone wrappers, and API-only `--repo` fallback paths.
|
||||||
|
- Delta after live USC `pr-create.sh` repro: tightened `GITEA_LOGIN` trust so stale login names are ignored unless the tea login itself matches the target host, and added USC API fallback coverage for `pr-create.sh`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `bash -n packages/mosaic/framework/tools/git/*.sh`
|
||||||
|
- `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
||||||
|
- `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
||||||
|
- `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||||
|
- `pwsh -NoProfile` parse check for all `packages/mosaic/framework/tools/git/*.ps1`
|
||||||
|
- `pnpm typecheck`
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm format:check`
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/git-wrapper-redirects.spec.ts`
|
||||||
|
- `pnpm test` progressed past wrapper redirect assertions; local run then stopped on `apps/gateway` Postgres connection refused at `localhost:5433`, which CI provides as a service.
|
||||||
|
- Live read-only: direct Gitea API read of `mosaicstack/stack#536` with `User-Agent: curl/8`.
|
||||||
|
- Live read-only: USC temporary repo remote to `https://git.uscllc.com/USC/uconnect.git`; `issue-list.sh -n 1` resolved the USC login and returned USC issues.
|
||||||
|
- Independent Codex review final verdict: approve, no findings.
|
||||||
@@ -453,6 +453,26 @@ Initialize standard labels and the first pre-MVP milestone:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Secrets Bootstrap (Required for Every New App)
|
||||||
|
|
||||||
|
Every new application MUST complete the following secrets bootstrap before deploying to any non-local environment. This is a hard gate — deployment without completed secrets bootstrap is forbidden.
|
||||||
|
|
||||||
|
### Secrets bootstrap checklist
|
||||||
|
|
||||||
|
- [ ] Vault path created: `vault kv put secret/k3s/<app>/ ...` with all required secret fields
|
||||||
|
- [ ] Required secrets listed in project README under a "Secrets architecture" section, including:
|
||||||
|
- Vault path(s) used
|
||||||
|
- All required secret keys and their purpose
|
||||||
|
- Whether the app uses ESO bridge (default) or Direct-Vault (opt-in, with justification)
|
||||||
|
- [ ] `external-secret.yaml` manifest committed to repo's `deploy/` or `k8s/` directory
|
||||||
|
- [ ] Deployment YAML references the synced k8s Secret via `secretKeyRef` (not raw env vars or `.env` files)
|
||||||
|
- [ ] App startup has schema-based validation for all required env vars (zod / pydantic / envconfig equivalent) that exits non-zero on missing required values
|
||||||
|
- [ ] Direct-Vault opt-in (if applicable): justification documented in README + AppRole provisioned + bootstrap credentials stored in Vault and synced via a separate `ExternalSecret`
|
||||||
|
|
||||||
|
See `~/.config/mosaic/guides/VAULT-SECRETS.md` for full worked examples of the ESO bridge pattern, the Direct-Vault opt-in pattern, and the forbidden antipatterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
After bootstrapping, verify:
|
After bootstrapping, verify:
|
||||||
|
|||||||
@@ -203,3 +203,374 @@ Error: token expired
|
|||||||
3. **Audit logging** - All access is logged; act accordingly
|
3. **Audit logging** - All access is logged; act accordingly
|
||||||
4. **No local copies** - Don't store secrets in files or env vars long-term
|
4. **No local copies** - Don't store secrets in files or env vars long-term
|
||||||
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Architecture Decision Matrix
|
||||||
|
|
||||||
|
Use this table to choose between the ESO bridge (default) and Direct-Vault (opt-in) patterns for every new app or integration.
|
||||||
|
|
||||||
|
| Factor | ESO Bridge (default) | Direct-Vault (opt-in) |
|
||||||
|
| --------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Use-case** | All static secrets (DB creds, API keys, signing keys, OAuth secrets) | Dynamic creds with short TTLs (DB rotation, AWS STS, PKI), per-request audit trails, or lease renewal mid-pod-lifecycle |
|
||||||
|
| **App code change** | None — reads standard env vars via `secretKeyRef` | Requires Vault client (`hvac`, `node-vault`, `vault/api`) in application code |
|
||||||
|
| **Secret rotation** | ESO re-syncs on Vault write; pod restart or secret refresh picks up new value | App manages lease renewal or re-auth within the running process |
|
||||||
|
| **Audit granularity** | Access logged at Vault when ESO syncs; no per-request app audit | Every app request to Vault is a separate audit log entry |
|
||||||
|
| **Operational burden** | Low — ESO handles polling, sync, and k8s Secret lifecycle | Higher — app must handle auth, lease renewal, error paths, and token rotation |
|
||||||
|
| **Justification required?** | No — this is the default | Yes — document in project README under "Secrets architecture" |
|
||||||
|
| **Example use cases** | Web app DB password, OAuth client secret, JWT signing key, API token | HashiCorp DB secrets engine with 15-min TTL leases, AWS STS assume-role, Vault PKI short-lived certs |
|
||||||
|
|
||||||
|
**Decision rule:** If you are unsure, use ESO. Only justify Direct-Vault when the secret cannot be safely stored in a k8s Secret (too short-lived, per-request TTL required, or mid-lifecycle renewal needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESO Bridge Pattern (Default)
|
||||||
|
|
||||||
|
This is the required default for all k8s workloads. Follow this exact pattern unless a documented dynamic-secrets requirement justifies Direct-Vault.
|
||||||
|
|
||||||
|
### 1. Provision Vault path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write the secrets for the app (run once; use IaC/Terraform for repeatable provisioning)
|
||||||
|
vault kv put secret/k3s/<app> \
|
||||||
|
db_password="..." \
|
||||||
|
api_key="..." \
|
||||||
|
jwt_secret="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the canonical path structure: `secret/k3s/<app>` for k3s cluster workloads.
|
||||||
|
|
||||||
|
### 2. ExternalSecret manifest
|
||||||
|
|
||||||
|
Commit this to the repo's `deploy/` or `k8s/` directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-secrets
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 1h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend # ClusterSecretStore name — verify with cluster admin
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-secrets # k8s Secret name that will be created
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: DB_PASSWORD # key in the k8s Secret
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app> # Vault path
|
||||||
|
property: db_password # field within the Vault secret
|
||||||
|
- secretKey: API_KEY
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: api_key
|
||||||
|
- secretKey: JWT_SECRET
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: jwt_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deployment manifest — reference synced k8s Secret
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section)
|
||||||
|
env:
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets # matches ExternalSecret target.name
|
||||||
|
key: DB_PASSWORD
|
||||||
|
- name: API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: API_KEY
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: JWT_SECRET
|
||||||
|
- name: PORT
|
||||||
|
value: '3000' # safe-default: non-secret, no Vault needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. App-side schema validation — TypeScript (zod)
|
||||||
|
|
||||||
|
Validate all required env vars at startup. Exit non-zero on missing values.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/env.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
DB_PASSWORD: z.string().min(1, 'DB_PASSWORD is required'),
|
||||||
|
API_KEY: z.string().min(1, 'API_KEY is required'),
|
||||||
|
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Missing or invalid environment variables:');
|
||||||
|
console.error(result.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = result.data;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. App-side schema validation — Python (pydantic)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/config.py
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
db_password: str
|
||||||
|
api_key: str
|
||||||
|
jwt_secret: str
|
||||||
|
port: int = 3000
|
||||||
|
node_env: str = "production"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=None) # no .env in prod
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = Settings()
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
print(f"Missing or invalid environment variables: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. App-side schema validation — Go (envconfig)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// config/config.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBPassword string `envconfig:"DB_PASSWORD" required:"true"`
|
||||||
|
APIKey string `envconfig:"API_KEY" required:"true"`
|
||||||
|
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||||||
|
Port int `envconfig:"PORT" default:"3000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := envconfig.Process("", &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid environment: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In your `main.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct-Vault Opt-In Pattern
|
||||||
|
|
||||||
|
Use this pattern ONLY when a documented dynamic-secrets requirement applies (DB rotation with short TTLs, AWS STS, PKI, per-request audit). Document the justification in the project README under "Secrets architecture" before implementing.
|
||||||
|
|
||||||
|
### When it is justified
|
||||||
|
|
||||||
|
- Vault DB secrets engine with lease TTLs shorter than a typical pod lifecycle (< 1 hour)
|
||||||
|
- AWS STS assume-role tokens generated per-request
|
||||||
|
- Vault PKI short-lived certificates (< 24 hours) that must be renewed within a running pod
|
||||||
|
- Per-request audit trail requirement (each app call must appear separately in Vault audit log)
|
||||||
|
|
||||||
|
### Provision an AppRole for the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable AppRole auth (if not already enabled)
|
||||||
|
vault auth enable approle
|
||||||
|
|
||||||
|
# Create a Vault policy for the app
|
||||||
|
# Note: KV v2 paths require both the exact path (for the top-level secret) and the
|
||||||
|
# wildcard (for sub-paths). Always include both to avoid permission denied errors.
|
||||||
|
vault policy write <app>-policy - <<EOF
|
||||||
|
path "secret/data/k3s/<app>" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "secret/data/k3s/<app>/*" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "database/creds/<app>-role" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create the AppRole
|
||||||
|
vault write auth/approle/role/<app>-role \
|
||||||
|
token_policies="<app>-policy" \
|
||||||
|
token_ttl=1h \
|
||||||
|
token_max_ttl=4h \
|
||||||
|
secret_id_ttl=0
|
||||||
|
|
||||||
|
# Retrieve role-id and secret-id
|
||||||
|
vault read auth/approle/role/<app>-role/role-id
|
||||||
|
vault write -f auth/approle/role/<app>-role/secret-id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bootstrap AppRole credentials via ESO (solving the chicken-and-egg problem)
|
||||||
|
|
||||||
|
The AppRole `role-id` and `secret-id` are themselves secrets. Store them in Vault at a bootstrap path, then use ESO to sync them into a k8s Secret. The app reads that k8s Secret at startup to authenticate with Vault directly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Store the bootstrap credentials in Vault
|
||||||
|
vault kv put secret/k3s/<app>-bootstrap \
|
||||||
|
role_id="<role-id>" \
|
||||||
|
secret_id="<secret-id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret-bootstrap.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 24h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: VAULT_ROLE_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: role_id
|
||||||
|
- secretKey: VAULT_SECRET_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: secret_id
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section for Direct-Vault app)
|
||||||
|
env:
|
||||||
|
- name: VAULT_ADDR
|
||||||
|
value: 'https://vault.example.com' # safe-default: non-secret cluster address
|
||||||
|
- name: VAULT_ROLE_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_ROLE_ID
|
||||||
|
- name: VAULT_SECRET_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_SECRET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### App-side Vault client pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/vault-client.ts — only exists in Direct-Vault apps
|
||||||
|
import vault from 'node-vault';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const bootstrapSchema = z.object({
|
||||||
|
VAULT_ADDR: z.string().url(),
|
||||||
|
VAULT_ROLE_ID: z.string().min(1),
|
||||||
|
VAULT_SECRET_ID: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootstrap = bootstrapSchema.parse(process.env);
|
||||||
|
|
||||||
|
const client = vault({ endpoint: bootstrap.VAULT_ADDR });
|
||||||
|
|
||||||
|
export async function getVaultClient() {
|
||||||
|
const { auth } = await client.approleLogin({
|
||||||
|
role_id: bootstrap.VAULT_ROLE_ID,
|
||||||
|
secret_id: bootstrap.VAULT_SECRET_ID,
|
||||||
|
});
|
||||||
|
client.token = auth.client_token;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Document in README under "Secrets architecture": the Vault path, why Direct-Vault is required, and the lease/renewal strategy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forbidden Patterns (CI Lint Targets)
|
||||||
|
|
||||||
|
The following patterns are forbidden in all Mosaic projects. CI lint SHOULD catch these automatically (implementation tracked separately). Agents MUST NOT introduce these patterns.
|
||||||
|
|
||||||
|
### 1. Untagged fallback defaults for required values
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# FORBIDDEN — required secret with silent fallback
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-changeme}
|
||||||
|
- API_KEY=${API_KEY:-}
|
||||||
|
|
||||||
|
# REQUIRED — fast-fail on missing required values
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
|
- API_KEY=${API_KEY:?API_KEY is required}
|
||||||
|
|
||||||
|
# ALLOWED — true convenience default, tagged
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:-3000} # safe-default: non-secret, app works at any port
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to: `docker-compose.yml`, k8s manifests, Helm `values.yaml`, any env file committed to git.
|
||||||
|
|
||||||
|
### 2. Vault KV calls in application source code (ESO-default projects)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN in ESO-default apps — direct Vault client in app source
|
||||||
|
import hvac
|
||||||
|
client = hvac.Client(url=os.environ['VAULT_ADDR'])
|
||||||
|
secret = client.secrets.kv.v2.read_secret_version(path='myapp/db')
|
||||||
|
```
|
||||||
|
|
||||||
|
ESO-default apps read env vars only. Direct-Vault clients belong only in apps with a documented dynamic-secrets justification in README.
|
||||||
|
|
||||||
|
### 3. Hardcoded secrets or API keys in committed files
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN — hardcoded credential
|
||||||
|
DB_PASSWORD = "supersecret123"
|
||||||
|
API_KEY = "sk-live-abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
No exceptions. CI lint must flag any string matching common secret patterns (`password`, `secret`, `api_key`, `token` assigned a literal non-env-var value).
|
||||||
|
|
||||||
|
### 4. `.env` files in production deployment paths
|
||||||
|
|
||||||
|
```
|
||||||
|
# FORBIDDEN — .env file in a production deploy path
|
||||||
|
deploy/.env
|
||||||
|
k8s/.env
|
||||||
|
docker/.env
|
||||||
|
|
||||||
|
# ALLOWED — local dev only
|
||||||
|
.env.example # template only, no real values
|
||||||
|
.env # local dev, must be in .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` files are acceptable in local-dev contexts only and MUST be in `.gitignore`. They are forbidden in any path that a CI pipeline or production deployment process reads directly.
|
||||||
|
|||||||
@@ -50,3 +50,34 @@ export function validateBridgeTyping(input: unknown): asserts input is BridgeTyp
|
|||||||
assertAgentSlug(o.agent);
|
assertAgentSlug(o.agent);
|
||||||
if (typeof o.typing !== 'boolean') throw new Error('typing must be a boolean');
|
if (typeof o.typing !== 'boolean') throw new Error('typing must be a boolean');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProvisionRoomDto {
|
||||||
|
name: string;
|
||||||
|
alias?: string;
|
||||||
|
topic?: string;
|
||||||
|
invite?: string[];
|
||||||
|
space_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateProvisionRoom(input: unknown): asserts input is ProvisionRoomDto {
|
||||||
|
const o = input as Partial<ProvisionRoomDto> | null | undefined;
|
||||||
|
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
|
||||||
|
if (typeof o.name !== 'string' || o.name.length === 0) throw new Error('name is required');
|
||||||
|
if (o.alias !== undefined && (!/^[a-z0-9_.-]+$/.test(o.alias) || o.alias.length > 200)) {
|
||||||
|
throw new Error('alias must match [a-z0-9_.-]+ (max 200 chars)');
|
||||||
|
}
|
||||||
|
if (o.invite !== undefined) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(o.invite) ||
|
||||||
|
o.invite.some((u) => typeof u !== 'string' || !u.startsWith('@'))
|
||||||
|
) {
|
||||||
|
throw new Error('invite must be a list of Matrix user ids');
|
||||||
|
}
|
||||||
|
if (o.invite.length > 50) {
|
||||||
|
throw new Error('invite list exceeds maximum of 50');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (o.space_id !== undefined && (typeof o.space_id !== 'string' || !o.space_id.startsWith('!'))) {
|
||||||
|
throw new Error('space_id must be a Matrix room id');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ export { TransactionHandler } from './transactions.js';
|
|||||||
export type { TransactionHandlerOptions } from './transactions.js';
|
export type { TransactionHandlerOptions } from './transactions.js';
|
||||||
export { buildRegistration, registrationToYaml } from './registration.js';
|
export { buildRegistration, registrationToYaml } from './registration.js';
|
||||||
export type { RegistrationOptions } from './registration.js';
|
export type { RegistrationOptions } from './registration.js';
|
||||||
export { validateBridgeMessage, validateBridgeTyping } from './bridge.dto.js';
|
export {
|
||||||
export type { BridgeMessageDto, BridgeTypingDto } from './bridge.dto.js';
|
validateBridgeMessage,
|
||||||
|
validateBridgeTyping,
|
||||||
|
validateProvisionRoom,
|
||||||
|
} from './bridge.dto.js';
|
||||||
|
export type { BridgeMessageDto, BridgeTypingDto, ProvisionRoomDto } from './bridge.dto.js';
|
||||||
export type {
|
export type {
|
||||||
AppserviceConfig,
|
AppserviceConfig,
|
||||||
EventHandler,
|
EventHandler,
|
||||||
|
|||||||
@@ -172,6 +172,58 @@ export class AppserviceIntent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create a room as the AS sender: agents get PL 50 by namespace via the
|
||||||
|
* sender (PL 100); humans invited at default PL. Optionally link into a
|
||||||
|
* space (m.space.child + m.space.parent). Returns the room id. */
|
||||||
|
async createRoom(options: {
|
||||||
|
name: string;
|
||||||
|
alias?: string;
|
||||||
|
topic?: string;
|
||||||
|
invite?: string[];
|
||||||
|
spaceId?: string;
|
||||||
|
}): Promise<{ roomId: string; spaceLinked: boolean; spaceError?: string }> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: options.name,
|
||||||
|
preset: 'private_chat',
|
||||||
|
invite: options.invite ?? [],
|
||||||
|
power_level_content_override: {
|
||||||
|
users: { [this.senderUserId]: 100 },
|
||||||
|
// state_default 50 stays; the AS sender can grant agents as needed.
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (options.alias) body.room_alias_name = options.alias;
|
||||||
|
if (options.topic) body.topic = options.topic;
|
||||||
|
const res = await this.request('POST', '/_matrix/client/v3/createRoom', {
|
||||||
|
userId: this.senderUserId,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
const roomId = res.room_id;
|
||||||
|
if (typeof roomId !== 'string') throw new Error('createRoom returned no room_id');
|
||||||
|
if (!options.spaceId) {
|
||||||
|
return { roomId, spaceLinked: false };
|
||||||
|
}
|
||||||
|
// Space-link failures must NOT throw: the room already exists, and an
|
||||||
|
// exception would hide the room_id (orphaned room, no recovery path).
|
||||||
|
const encodedSpaceId = encodeURIComponent(options.spaceId);
|
||||||
|
const encodedRoomId = encodeURIComponent(roomId);
|
||||||
|
try {
|
||||||
|
await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${encodedSpaceId}/state/m.space.child/${encodedRoomId}`,
|
||||||
|
{ userId: this.senderUserId, body: { via: [this.cfg.domain], suggested: true } },
|
||||||
|
);
|
||||||
|
await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/_matrix/client/v3/rooms/${encodedRoomId}/state/m.space.parent/${encodedSpaceId}`,
|
||||||
|
{ userId: this.senderUserId, body: { via: [this.cfg.domain], canonical: true } },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return { roomId, spaceLinked: false, spaceError: message };
|
||||||
|
}
|
||||||
|
return { roomId, spaceLinked: true };
|
||||||
|
}
|
||||||
|
|
||||||
/** Set display name for an agent's virtual user. */
|
/** Set display name for an agent's virtual user. */
|
||||||
async setDisplayName(agent: string, displayName: string): Promise<void> {
|
async setDisplayName(agent: string, displayName: string): Promise<void> {
|
||||||
const userId = await this.ensureRegistered(agent);
|
const userId = await this.ensureRegistered(agent);
|
||||||
|
|||||||
@@ -1,28 +1,24 @@
|
|||||||
# Mosaic Global Agent Contract
|
# Mosaic Global Agent Contract
|
||||||
|
|
||||||
Canonical file: `~/.config/mosaic/AGENTS.md`
|
Canonical file: `~/.config/mosaic/AGENTS.md`. Mandatory behavior for all Mosaic agent runtimes.
|
||||||
|
|
||||||
This file defines the mandatory behavior for all Mosaic agent runtimes.
|
This is the THIN CORE — the launcher injects it (plus USER.md, the TOOLS index, and the runtime
|
||||||
|
contract) into every session. It carries only what must be resident to avoid violating a gate.
|
||||||
|
Depth lives in guides, read on demand (see Conditional Guide Loading).
|
||||||
|
|
||||||
## MANDATORY Load Order (No Exceptions)
|
## Session Start — Load Order
|
||||||
|
|
||||||
Before responding to any user message, you MUST read these files in order:
|
The core contract is ALREADY in your context (injected by `mosaic` launch). Do not re-read it.
|
||||||
|
At session start, additionally:
|
||||||
|
|
||||||
1. `~/.config/mosaic/SOUL.md`
|
1. Read `~/.config/mosaic/SOUL.md` (agent identity — small, once).
|
||||||
2. `~/.config/mosaic/USER.md`
|
2. Read project-local `AGENTS.md` / `CLAUDE.md` if present.
|
||||||
3. `~/.config/mosaic/STANDARDS.md`
|
3. Read guides ONLY as triggered by the Conditional Guide Loading table below. Do NOT pre-load
|
||||||
4. `~/.config/mosaic/AGENTS.md`
|
guides you do not need — role-relevant detail is pulled on demand, not up front.
|
||||||
5. `~/.config/mosaic/TOOLS.md`
|
4. When you begin implementation work, read `~/.config/mosaic/guides/E2E-DELIVERY.md` (the full
|
||||||
6. `~/.config/mosaic/guides/E2E-DELIVERY.md`
|
delivery procedure: PRD/tracking gates, execution cycle, testing, review, completion).
|
||||||
7. `~/.config/mosaic/guides/MEMORY.md`
|
5. `~/.config/mosaic/STANDARDS.md` is available for reference; load it only if the task requires
|
||||||
8. Project-local `AGENTS.md` (if present)
|
standards validation (do NOT halt if missing).
|
||||||
9. Runtime-specific reference:
|
|
||||||
- Pi: `~/.config/mosaic/runtime/pi/RUNTIME.md`
|
|
||||||
- Claude: `~/.config/mosaic/runtime/claude/RUNTIME.md`
|
|
||||||
- Codex: `~/.config/mosaic/runtime/codex/RUNTIME.md`
|
|
||||||
- OpenCode: `~/.config/mosaic/runtime/opencode/RUNTIME.md`
|
|
||||||
|
|
||||||
If any required file is missing, you MUST stop and report the missing file.
|
|
||||||
|
|
||||||
## CRITICAL HARD GATES (Read First)
|
## CRITICAL HARD GATES (Read First)
|
||||||
|
|
||||||
@@ -37,56 +33,40 @@ If any required file is missing, you MUST stop and report the missing file.
|
|||||||
9. Do NOT stop at "PR created". Do NOT ask "should I merge?" Do NOT ask "should I close the issue?".
|
9. Do NOT stop at "PR created". Do NOT ask "should I merge?" Do NOT ask "should I close the issue?".
|
||||||
10. Manual `docker build` / `docker push` for deployment is FORBIDDEN when CI/CD pipelines exist in the repository. CI is the ONLY canonical build path for container images.
|
10. Manual `docker build` / `docker push` for deployment is FORBIDDEN when CI/CD pipelines exist in the repository. CI is the ONLY canonical build path for container images.
|
||||||
11. Before ANY build or deployment action, you MUST check for existing CI/CD pipeline configuration (`.woodpecker/`, `.woodpecker.yml`, `.github/workflows/`, etc.). If pipelines exist, use them — do not build locally.
|
11. Before ANY build or deployment action, you MUST check for existing CI/CD pipeline configuration (`.woodpecker/`, `.woodpecker.yml`, `.github/workflows/`, etc.). If pipelines exist, use them — do not build locally.
|
||||||
12. The mandatory load order and intake procedure are NOT conditional on perceived task complexity. A "simple" commit-push-deploy task has the same procedural requirements as a multi-file feature. Skipping intake because a task "seems simple" is the most common framework violation.
|
12. The mandatory intake procedure is NOT conditional on perceived task complexity. A "simple" commit-push-deploy task has the same procedural requirements as a multi-file feature. Skipping intake because a task "seems simple" is the most common framework violation.
|
||||||
|
13. **Merge authority (coordinated work):** when a coordinator/orchestrator session is active for the work, the post-review MERGE GO-AHEAD is the coordinator's to give — once code has passed the required review gates, request the coordinator's go-ahead and merge on their confirmation; do NOT wait on the human owner personally. Solo (uncoordinated) delivery keeps the default: merge without routine confirmation per gates 2 and 9. A "No self-merge" note on a PR means no UNREVIEWED self-merge — it does not suspend coordinator-authorized merges. (Policy: Jason, 2026-06-11.)
|
||||||
|
|
||||||
## Non-Negotiable Operating Rules
|
## Non-Negotiable Operating Rules (condensed — full detail in `guides/E2E-DELIVERY.md`)
|
||||||
|
|
||||||
1. You MUST create and maintain a task-specific scratchpad for every non-trivial task.
|
- **Source of requirements:** `docs/PRD.md`/`docs/PRD.json` MUST exist before coding. In steered autonomy, make best-guess PRD decisions, mark each `ASSUMPTION:` with rationale, continue. (`guides/PRD.md`)
|
||||||
2. You MUST follow the end-to-end procedure in `E2E-DELIVERY.md`.
|
- **Tracking:** create/maintain a scratchpad and `docs/TASKS.md` for every non-trivial task; keep current through completion.
|
||||||
3. You MUST execute this cycle for implementation work: `plan -> code -> test -> review -> remediate -> review -> commit -> push -> greenfield situational test -> repeat`.
|
- **Execution cycle:** `plan → code → test → review → remediate → review → commit → push → greenfield situational test → repeat`. On failure, remediate and re-run from the failed step.
|
||||||
4. Before coding begins, `docs/PRD.md` or `docs/PRD.json` MUST exist and be treated as the source of requirements.
|
- **Testing:** run baseline tests before any completion claim. Situational testing is the PRIMARY gate. Risk-based TDD is REQUIRED for bug fixes, security/auth/permission logic, and critical data mutations. (`guides/QA-TESTING.md`)
|
||||||
5. The main agent MUST prepare or update the PRD using user objectives, constraints, and available project context before implementation starts.
|
- **Review:** if you modify source code, an independent code review MUST pass before completion. (`guides/CODE-REVIEW.md`)
|
||||||
6. In steered autonomy mode, the agent MUST make best-guess PRD decisions when needed, mark each with `ASSUMPTION:` and rationale, and continue without waiting for routine user approval.
|
- **Evidence:** provide explicit verification evidence before any completion claim. Never use workarounds that bypass quality gates.
|
||||||
7. You MUST run baseline tests before claiming completion.
|
- **Secrets & deps:** never hardcode secrets (`guides/VAULT-SECRETS.md`); never use deprecated/unsupported dependencies.
|
||||||
8. Situational testing is the PRIMARY validation gate. You MUST run situational tests based on the change surface.
|
- **Git strategy:** trunk-based — branch from `main`, merge to `main` via PR only (squash merge), never push directly to `main`.
|
||||||
9. TDD is risk-based and REQUIRED for bug fixes, security/auth/permission logic, and critical business logic/data mutations (see `~/.config/mosaic/guides/QA-TESTING.md`).
|
- **Provider work:** detect platform first, then use `~/.config/mosaic/tools/git/*.sh` wrappers before any raw `gh`/`tea`/`glab`. Create/link issue(s) in `docs/TASKS.md` before coding; if no provider, use `TASKS:<id>` refs.
|
||||||
10. If you modify source code, you MUST run an independent code review before completion.
|
- **Deployment:** own it when in scope and access is configured. Use immutable image tags (`sha-*`, `vX.Y.Z-rc.N`) with digest-first promotion; `latest` is forbidden as a deployment reference. (`guides/INFRASTRUCTURE.md`)
|
||||||
11. You MUST update required documentation for code/API/auth/infra changes per `~/.config/mosaic/guides/DOCUMENTATION.md`.
|
- **Release:** on milestone completion, create + push a release tag and publish a repository release.
|
||||||
12. You MUST provide verification evidence before completion claims.
|
- **Documentation:** update required docs for code/API/auth/infra changes; keep `docs/` root clean (scoped folders). (`guides/DOCUMENTATION.md`)
|
||||||
13. You MUST NOT use workarounds that bypass quality gates.
|
- **TypeScript:** DTO files (`*.dto.ts`) REQUIRED for module/API boundaries. (`guides/TYPESCRIPT.md`)
|
||||||
14. You MUST NOT hardcode secrets.
|
- **Ownership:** own execution end-to-end (plan→deploy). Human intervention is escalation-only — do not ask the human to do routine coding, review, or repo work.
|
||||||
15. You MUST NOT use deprecated or unsupported dependencies.
|
- **Budget:** honor user plan/token budgets; adjust execution strategy to stay within limits.
|
||||||
16. When a milestone is completed, you MUST create and push a release tag and publish a repository release.
|
|
||||||
17. For every non-trivial implementation task, you MUST create or update `docs/TASKS.md` before coding and keep it current through completion.
|
|
||||||
18. You MUST keep `docs/` root clean and place reports/artifacts in scoped folders per `~/.config/mosaic/guides/DOCUMENTATION.md`.
|
|
||||||
19. For TypeScript codebases, DTO files are REQUIRED for module/API boundaries (`*.dto.ts`).
|
|
||||||
20. You MUST honor user plan/token budgets: monitor estimated vs used tokens and adjust execution strategy to stay within limits.
|
|
||||||
21. You MUST use trunk merge strategy: branch from `main`, merge to `main` via PR only, never push directly to `main`, and use squash merge only.
|
|
||||||
22. You MUST own project execution end-to-end: planning, coding, testing, review, remediation, PR/repo operations, release/tag, and deployment when in scope.
|
|
||||||
23. Human intervention is escalation-only; do not ask the human to perform routine coding, review, or repository management work.
|
|
||||||
24. Deployment ownership is REQUIRED when deployment is in scope and target access is configured.
|
|
||||||
25. For container deployments, you MUST use immutable image tags (`sha-*`, `vX.Y.Z-rc.N`) with digest-first promotion; `latest` is forbidden as a deployment reference.
|
|
||||||
26. If an external git provider is available (Gitea/GitHub/GitLab), you MUST create or update issue(s) and link them in `docs/TASKS.md` before coding; if unavailable, use `TASKS:<id>` internal refs in `docs/TASKS.md`.
|
|
||||||
27. For provider operations (issue/PR/milestone), you MUST detect platform first and use `~/.config/mosaic/tools/git/*.sh` wrappers before any raw provider CLI/API calls.
|
|
||||||
28. Direct `gh`/`tea`/`glab` commands are forbidden as first choice when a Mosaic wrapper exists; use raw commands only as documented fallback.
|
|
||||||
29. If the mission is orchestration-oriented (contains "orchestrate", issue/milestone coordination, or multi-task execution), you MUST load and follow `~/.config/mosaic/guides/ORCHESTRATOR.md` before taking action.
|
|
||||||
30. At session start, you MUST declare the operating mode in your first response before any tool calls or implementation steps.
|
|
||||||
31. For orchestration-oriented missions, the first line MUST be exactly: `Now initiating Orchestrator mode...`
|
|
||||||
32. For non-orchestrator implementation missions, the first line MUST be exactly: `Now initiating Delivery mode...`
|
|
||||||
33. For explicit review-only missions, the first line MUST be exactly: `Now initiating Review mode...`
|
|
||||||
34. For source-code delivery through PR workflow, completion is forbidden until the PR is merged to `main`, CI/pipeline status is terminal green, and linked issue/internal task is closed.
|
|
||||||
35. If merge/CI/issue-closure operations fail, you MUST report a blocker with the exact failed wrapper command and stop instead of declaring completion.
|
|
||||||
36. Before push or PR merge, you MUST run CI queue guard and wait if the project has running/queued pipelines: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge`.
|
|
||||||
37. When an active mission is detected at session start (MISSION-MANIFEST.md, TASKS.md, or scratchpads/ present), you MUST load `~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md` and follow the Session Resume Protocol before taking any action.
|
|
||||||
|
|
||||||
## Mode Declaration Protocol (Hard Rule)
|
## Mode Declaration Protocol (Hard Rule)
|
||||||
|
|
||||||
At session start, declare one mode before any actions:
|
At session start, declare exactly one mode as the first line, before any tool call or step:
|
||||||
|
|
||||||
1. Orchestration mission: `Now initiating Orchestrator mode...`
|
1. Orchestration mission: `Now initiating Orchestrator mode...`
|
||||||
2. Implementation mission: `Now initiating Delivery mode...`
|
2. Implementation mission: `Now initiating Delivery mode...`
|
||||||
3. Review-only mission: `Now initiating Review mode...`
|
3. Review-only mission: `Now initiating Review mode...`
|
||||||
|
|
||||||
|
Orchestration-oriented = contains "orchestrate", issue/milestone coordination, or multi-task
|
||||||
|
execution → also load `guides/ORCHESTRATOR.md` before acting. If an active mission is detected at
|
||||||
|
session start (MISSION-MANIFEST.md, TASKS.md, or scratchpads/ present) → load
|
||||||
|
`guides/ORCHESTRATOR-PROTOCOL.md` and follow the Session Resume Protocol before any action.
|
||||||
|
|
||||||
## Steered Autonomy Escalation Triggers
|
## Steered Autonomy Escalation Triggers
|
||||||
|
|
||||||
Only interrupt the human when one of these is true:
|
Only interrupt the human when one of these is true:
|
||||||
@@ -97,136 +77,69 @@ Only interrupt the human when one of these is true:
|
|||||||
4. Legal/compliance/security constraints are unknown and materially affect delivery.
|
4. Legal/compliance/security constraints are unknown and materially affect delivery.
|
||||||
5. Objectives are mutually conflicting and cannot be resolved from PRD, repo, or prior decisions.
|
5. Objectives are mutually conflicting and cannot be resolved from PRD, repo, or prior decisions.
|
||||||
|
|
||||||
## Conditional Guide Loading
|
## Conditional Guide Loading (role/task-driven — load only what the task needs)
|
||||||
|
|
||||||
Load additional guides when the task requires them.
|
| Task | Guide |
|
||||||
|
| -------------------------------------------------- | ---------------------------------- |
|
||||||
|
| Project bootstrap | `guides/BOOTSTRAP.md` |
|
||||||
|
| PRD creation / requirements | `guides/PRD.md` |
|
||||||
|
| Orchestration flow | `guides/ORCHESTRATOR.md` |
|
||||||
|
| Mission lifecycle / multi-session orchestration | `guides/ORCHESTRATOR-PROTOCOL.md` |
|
||||||
|
| Orchestrator estimation heuristics | `guides/ORCHESTRATOR-LEARNINGS.md` |
|
||||||
|
| Frontend changes | `guides/FRONTEND.md` |
|
||||||
|
| Backend/API changes | `guides/BACKEND.md` |
|
||||||
|
| Auth/authorization | `guides/AUTHENTICATION.md` |
|
||||||
|
| CI/CD changes | `guides/CI-CD-PIPELINES.md` |
|
||||||
|
| Infrastructure/DevOps/deployment | `guides/INFRASTRUCTURE.md` |
|
||||||
|
| Code review work | `guides/CODE-REVIEW.md` |
|
||||||
|
| TypeScript strict typing | `guides/TYPESCRIPT.md` |
|
||||||
|
| QA / test strategy | `guides/QA-TESTING.md` |
|
||||||
|
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
|
||||||
|
| Secrets / vault usage | `guides/VAULT-SECRETS.md` |
|
||||||
|
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
|
||||||
|
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
|
||||||
|
|
||||||
| Task | Required Guide |
|
## Subagent Model Selection (Cost — Hard Rule)
|
||||||
| ------------------------------------------------------- | --------------------------------------------------- |
|
|
||||||
| Project bootstrap | `~/.config/mosaic/guides/BOOTSTRAP.md` |
|
|
||||||
| PRD creation and requirements definition | `~/.config/mosaic/guides/PRD.md` |
|
|
||||||
| Orchestration flow | `~/.config/mosaic/guides/ORCHESTRATOR.md` |
|
|
||||||
| Frontend changes | `~/.config/mosaic/guides/FRONTEND.md` |
|
|
||||||
| Backend/API changes | `~/.config/mosaic/guides/BACKEND.md` |
|
|
||||||
| Documentation changes or any code/API/auth/infra change | `~/.config/mosaic/guides/DOCUMENTATION.md` |
|
|
||||||
| Authentication/authorization | `~/.config/mosaic/guides/AUTHENTICATION.md` |
|
|
||||||
| CI/CD changes | `~/.config/mosaic/guides/CI-CD-PIPELINES.md` |
|
|
||||||
| Infrastructure/DevOps | `~/.config/mosaic/guides/INFRASTRUCTURE.md` |
|
|
||||||
| Code review work | `~/.config/mosaic/guides/CODE-REVIEW.md` |
|
|
||||||
| TypeScript strict typing | `~/.config/mosaic/guides/TYPESCRIPT.md` |
|
|
||||||
| QA and test strategy | `~/.config/mosaic/guides/QA-TESTING.md` |
|
|
||||||
| Secrets and vault usage | `~/.config/mosaic/guides/VAULT-SECRETS.md` |
|
|
||||||
| Orchestrator estimation heuristics | `~/.config/mosaic/guides/ORCHESTRATOR-LEARNINGS.md` |
|
|
||||||
| Mission lifecycle / multi-session orchestration | `~/.config/mosaic/guides/ORCHESTRATOR-PROTOCOL.md` |
|
|
||||||
|
|
||||||
## Embedded Delivery Cycle (Hard Rule)
|
Select the cheapest model capable of the task; do NOT default to the most expensive. Omitting the
|
||||||
|
tier defaults to the parent (usually opus) and wastes budget.
|
||||||
|
|
||||||
- Implementation work MUST follow the embedded execution cycle:
|
- **haiku** — search/grep/glob, codebase exploration, status/health checks, one-line mechanical fixes.
|
||||||
- `plan -> code -> test -> review -> remediate -> review -> commit -> push -> greenfield situational test -> repeat`
|
- **sonnet** — code review, lint, test writing/fixing, standard feature implementation.
|
||||||
- If a step fails, you MUST remediate and re-run from the relevant step before proceeding.
|
- **opus** — complex architecture / multi-file refactors, security/auth logic, ambiguous design decisions.
|
||||||
|
|
||||||
## Sequential-Thinking MCP (Hard Requirement)
|
Start cheapest; escalate only when the task genuinely needs deeper reasoning. Runtime syntax for
|
||||||
|
specifying tier is in the runtime contract.
|
||||||
- `sequential-thinking` MCP server is REQUIRED for Mosaic operation.
|
|
||||||
- Installation and configuration are managed by Mosaic bootstrap and runtime linking.
|
|
||||||
- If sequential-thinking is unavailable, you MUST report the failure and stop planning-intensive execution.
|
|
||||||
|
|
||||||
## Subagent Model Selection (Cost Optimization — Hard Rule)
|
|
||||||
|
|
||||||
When delegating work to subagents, you MUST select the cheapest model capable of completing the task. Do NOT default to the most expensive model for every delegation.
|
|
||||||
|
|
||||||
| Task Type | Model Tier | Rationale |
|
|
||||||
| --------------------------------------------- | ---------- | ------------------------------------------------------- |
|
|
||||||
| File search, grep, glob, codebase exploration | **haiku** | Read-only, pattern matching, no reasoning depth needed |
|
|
||||||
| Status checks, health monitoring, heartbeat | **haiku** | Structured API calls, pass/fail output |
|
|
||||||
| Simple code fixes (typos, rename, one-liner) | **haiku** | Minimal reasoning, mechanical changes |
|
|
||||||
| Code review, lint, style checks | **sonnet** | Needs judgment but not deep architectural reasoning |
|
|
||||||
| Test writing, test fixes | **sonnet** | Pattern-based, moderate complexity |
|
|
||||||
| Standard feature implementation | **sonnet** | Good balance of capability and cost for most coding |
|
|
||||||
| Complex architecture, multi-file refactors | **opus** | Requires deep reasoning, large context, design judgment |
|
|
||||||
| Security review, auth logic | **opus** | High-stakes reasoning where mistakes are costly |
|
|
||||||
| Ambiguous requirements, design decisions | **opus** | Needs nuanced judgment and tradeoff analysis |
|
|
||||||
|
|
||||||
**Decision rule**: Start with the cheapest viable tier. Only escalate if the task genuinely requires deeper reasoning — not as a safety default. Most coding tasks are sonnet-tier. Reserve opus for work where wrong answers are expensive.
|
|
||||||
|
|
||||||
**Runtime-specific syntax**: See the runtime reference for how to specify model tier when spawning subagents (e.g., Claude Code Task tool `model` parameter).
|
|
||||||
|
|
||||||
## Superpowers Enforcement (Hard Rule)
|
## Superpowers Enforcement (Hard Rule)
|
||||||
|
|
||||||
Mosaic provides capabilities beyond basic code editing: **skills**, **hooks**, **MCP tools**, and **plugins**. These are not optional extras — they are force multipliers that agents MUST actively use when applicable. Under-utilization of superpowers is a framework violation.
|
Skills, hooks, MCP tools, and plugins are force multipliers you MUST use when applicable;
|
||||||
|
under-utilization is a framework violation.
|
||||||
|
|
||||||
### Skills
|
- **Skills:** before implementation, scan `~/.config/mosaic/skills/` and load any matching the task
|
||||||
|
domain (e.g. `nestjs-best-practices` for NestJS). Include skill loading in worker kickstarts. Do
|
||||||
|
not load unrelated skills.
|
||||||
|
- **Hooks:** never bypass or suppress hook output; treat hook failures like failing tests and fix
|
||||||
|
them. If a hook is wrong, report it as a framework issue — do not work around it.
|
||||||
|
- **MCP:** sequential-thinking is REQUIRED for planning/architecture/multi-step reasoning. OpenBrain
|
||||||
|
(`capture`/`search`/`recent`) is the cross-agent memory layer — search at session start, capture
|
||||||
|
what you learn. Use web/browser/research MCP tools instead of asking the user to look things up.
|
||||||
|
- **Plugins:** use code-review / pr-review / architecture plugins proactively after significant
|
||||||
|
changes and before opening a PR — do not wait to be asked.
|
||||||
|
- **Self-evolution:** capture recurring patterns (`framework-improvement`), missing tooling
|
||||||
|
(`tooling-gap`), and value-less friction (`framework-friction`) to OpenBrain.
|
||||||
|
|
||||||
Skills are domain-specific instruction sets in `~/.config/mosaic/skills/` that encode best practices, patterns, and guardrails. They are loaded into agents via the runtime's skill mechanism (e.g., Claude Code slash commands, Pi `--skill` flag).
|
## Other Hard Rules
|
||||||
|
|
||||||
**Rules:**
|
- **Sequential-thinking MCP** is REQUIRED. If unavailable, report the failure and stop planning-intensive execution.
|
||||||
|
- **Missing core file:** if `AGENTS.md`, `SOUL.md`, or the runtime contract is missing, stop and report it.
|
||||||
|
|
||||||
1. Before starting implementation, scan available skills (`ls ~/.config/mosaic/skills/`) and load any that match the task domain.
|
## Session Closure
|
||||||
2. When a skill exists for the technology being used (e.g., `nestjs-best-practices` for NestJS work), you MUST load it.
|
|
||||||
3. When spawning workers, include skill loading in the kickstart prompt.
|
|
||||||
4. If you complete a task without loading a relevant available skill, that is a quality gap.
|
|
||||||
|
|
||||||
### Hooks
|
Before closing an implementation task, confirm: required + situational tests passed (primary gate);
|
||||||
|
aligned to `docs/PRD.md`; acceptance criteria mapped to evidence; independent code review passed (if
|
||||||
Hooks provide automated quality gates (lint, format, typecheck) that fire on file edits. They are configured in the runtime settings and run automatically.
|
code changed); required docs updated; scratchpad updated with decisions/results/risks; explicit
|
||||||
|
completion evidence provided. For PR-workflow delivery: confirm merged PR number + merge commit on
|
||||||
**Rules:**
|
`main`, terminal-green CI, and linked issue closed (or `docs/TASKS.md` equivalent). If any of those
|
||||||
|
are blocked by access/tooling failure, return `blocked` with the exact failed wrapper command — do
|
||||||
1. Do NOT bypass or suppress hook output. If a hook reports errors, fix them before proceeding.
|
not claim completion. Full checklist: `guides/E2E-DELIVERY.md`.
|
||||||
2. Hook failures are immediate feedback — treat them like failing tests.
|
|
||||||
3. If a hook is consistently failing on valid code, report it as a framework issue rather than working around it.
|
|
||||||
|
|
||||||
### MCP Tools
|
|
||||||
|
|
||||||
MCP servers extend agent capabilities with external integrations (sequential-thinking, web search, memory, browser automation, etc.). Available MCP tools are listed at session start.
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
|
|
||||||
1. **sequential-thinking** is REQUIRED for planning, architecture, and multi-step reasoning. Use it — do not skip structured thinking for complex decisions.
|
|
||||||
2. **OpenBrain** (`capture`, `search`, `recent`) is the cross-agent memory layer. Capture discoveries and search for prior context at session start.
|
|
||||||
3. When a task involves web research, browser testing, or external data, use the available MCP tools (web-search, chrome-devtools, web-reader) rather than asking the user to look things up.
|
|
||||||
4. Check available MCP tools at session start and use them proactively throughout the session.
|
|
||||||
|
|
||||||
### Plugins (Runtime-Specific)
|
|
||||||
|
|
||||||
Runtime plugins (e.g., Claude Code's `feature-dev`, `pr-review-toolkit`, `code-review`) provide specialized agent capabilities like code review, architecture analysis, and test coverage analysis.
|
|
||||||
|
|
||||||
**Rules:**
|
|
||||||
|
|
||||||
1. After completing a significant code change, use code review plugins proactively — do not wait for the user to ask.
|
|
||||||
2. Before creating a PR, use PR review plugins to catch issues early.
|
|
||||||
3. When designing architecture, use planning/architecture plugins for structured analysis.
|
|
||||||
|
|
||||||
### Self-Evolution
|
|
||||||
|
|
||||||
The Mosaic framework should improve over time based on usage patterns:
|
|
||||||
|
|
||||||
1. When you discover a recurring pattern that should be codified, capture it to OpenBrain with `type: "framework-improvement"`.
|
|
||||||
2. When a hook, skill, or tool is missing for a common task, capture the gap to OpenBrain with `type: "tooling-gap"`.
|
|
||||||
3. When a framework rule causes friction without adding value, capture the observation to OpenBrain with `type: "framework-friction"`.
|
|
||||||
|
|
||||||
These captures feed the framework's continuous improvement cycle.
|
|
||||||
|
|
||||||
## Skills Policy
|
|
||||||
|
|
||||||
- Load skills that match the active task domain before starting implementation.
|
|
||||||
- Do not load unrelated skills.
|
|
||||||
- Follow skill trigger rules from the active runtime instruction layer.
|
|
||||||
- Actively check `~/.config/mosaic/skills/` for applicable skills rather than passively waiting for them to be mentioned.
|
|
||||||
|
|
||||||
## Session Closure Requirement
|
|
||||||
|
|
||||||
Before closing any implementation task:
|
|
||||||
|
|
||||||
1. Confirm required tests passed.
|
|
||||||
2. Confirm situational tests passed (primary gate).
|
|
||||||
3. Confirm implementation is aligned to the active `docs/PRD.md` or `docs/PRD.json`.
|
|
||||||
4. Confirm acceptance criteria are mapped to verification evidence.
|
|
||||||
5. If source code changed, confirm independent code review passed.
|
|
||||||
6. Confirm required documentation updates were completed and reviewed.
|
|
||||||
7. Update scratchpad with decisions, results, and open risks.
|
|
||||||
8. Provide explicit completion evidence.
|
|
||||||
9. If source code changed and external provider is available, confirm merged PR number and merge commit on `main`.
|
|
||||||
10. Confirm CI/pipeline status is terminal green for the merged change (or merged PR head when equivalent).
|
|
||||||
11. Confirm linked issue is closed (or internal `docs/TASKS.md` equivalent is closed when no provider exists).
|
|
||||||
12. If any of items 9-11 are blocked by access/tooling failure, return `blocked` status with exact failed wrapper command and do not claim completion.
|
|
||||||
|
|||||||
@@ -27,6 +27,16 @@ Master/slave model:
|
|||||||
- Do not perform destructive git/file actions without explicit instruction.
|
- Do not perform destructive git/file actions without explicit instruction.
|
||||||
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
|
||||||
|
|
||||||
|
### Secrets handling (HARD RULE)
|
||||||
|
|
||||||
|
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
|
||||||
|
- For k8s workloads, the default read path is **External Secrets Operator → k8s Secret → env var** (`secretKeyRef`). The app reads standard env vars; no Vault client in app code.
|
||||||
|
- Direct-Vault clients in application code are **opt-in only**, justified per-app by a documented dynamic-secrets requirement (e.g., DB rotation, AWS STS). Default to ESO. Document the justification in the project's README under "Secrets architecture".
|
||||||
|
- `${VAR:-default}` fallback syntax in any deployment configuration (compose, k8s manifests, Helm values, env files committed to git) is **forbidden** for required values. Use `${VAR:?VAR is required}` to fast-fail. Defaults are allowed only for true conveniences (e.g. `${PORT:-3000}`) and MUST be tagged `# safe-default: <reason>` so a reviewer can confirm the intent.
|
||||||
|
- `.env` files in production deployment paths are **forbidden**. `.env.example` and `.env` in local-dev paths are fine.
|
||||||
|
- App startup MUST validate required secrets against a schema (zod / pydantic / equivalent) and exit non-zero on missing required values. Never run with defaulted weak fallbacks.
|
||||||
|
- New apps: bootstrap checklist (see `~/.config/mosaic/guides/BOOTSTRAP.md`) MUST include Vault path provisioning + `ExternalSecret` manifest + README declaring the Vault path and required keys.
|
||||||
|
|
||||||
## Session Lifecycle Contract
|
## Session Lifecycle Contract
|
||||||
|
|
||||||
- Start: `scripts/agent/session-start.sh`
|
- Start: `scripts/agent/session-start.sh`
|
||||||
|
|||||||
@@ -1,257 +1,58 @@
|
|||||||
# Machine-Level Tool Reference
|
# Machine Tools — Index
|
||||||
|
|
||||||
Centralized reference for tools, credentials, and CLI patterns available across all projects.
|
Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
|
||||||
|
**Full CLI signatures, flags, and examples: `~/.config/mosaic/guides/TOOLS-REFERENCE.md`** —
|
||||||
|
read it (or the relevant service guide) when your task actually touches that service.
|
||||||
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||||
|
|
||||||
All tool suites are located at `~/.config/mosaic/tools/`.
|
## Suites (use wrappers first)
|
||||||
|
|
||||||
## Tool Suites
|
| Suite | Path | Purpose |
|
||||||
|
| ---------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||||
### Git Wrappers (Use First)
|
| git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) |
|
||||||
|
| woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) |
|
||||||
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
| portainer | `tools/portainer/*.sh` | Docker Swarm stacks (status/redeploy/list) |
|
||||||
|
| coolify | `tools/coolify/*.sh` | **DEPRECATED** — superseded by Portainer; do not use for new deployments |
|
||||||
```bash
|
| authentik | `tools/authentik/*.sh` | identity (users/groups/apps/flows) |
|
||||||
# Issues
|
| cloudflare | `tools/cloudflare/*.sh` | DNS (zones/records; `-a` instance) |
|
||||||
~/.config/mosaic/tools/git/issue-create.sh
|
| glpi | `tools/glpi/*.sh` | IT tickets/computers/users |
|
||||||
~/.config/mosaic/tools/git/issue-close.sh
|
| health | `tools/health/stack-health.sh` | service health checks |
|
||||||
|
| codex | `tools/codex/*.sh` | code/security review (`--uncommitted`) |
|
||||||
# PRs
|
| openbrain | `tools/openbrain/*`, `tools/openbrain_client.py` | semantic memory (see below) |
|
||||||
~/.config/mosaic/tools/git/pr-create.sh
|
| excalidraw | MCP `mcp__excalidraw__*` | diagram export/generation |
|
||||||
~/.config/mosaic/tools/git/pr-merge.sh
|
|
||||||
|
Git wrappers are MANDATORY-first for issue/PR/milestone ops (see AGENTS.md hard gates 6–8).
|
||||||
# Milestones
|
Queue guard before push/merge: `tools/git/ci-queue-wait.sh --purpose push|merge`.
|
||||||
~/.config/mosaic/tools/git/milestone-create.sh
|
|
||||||
|
|
||||||
# CI queue guard (required before push/merge)
|
|
||||||
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Review (Codex)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
|
||||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
|
||||||
```
|
|
||||||
|
|
||||||
### Infrastructure — Portainer
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name>
|
|
||||||
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name>
|
|
||||||
~/.config/mosaic/tools/portainer/stack-list.sh
|
|
||||||
~/.config/mosaic/tools/portainer/endpoint-list.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Infrastructure — Coolify (DEPRECATED)
|
|
||||||
|
|
||||||
> Coolify has been superseded by Portainer Docker Swarm in this stack.
|
|
||||||
> Tools remain for reference but should not be used for new deployments.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# DEPRECATED — do not use for new deployments
|
|
||||||
~/.config/mosaic/tools/coolify/project-list.sh
|
|
||||||
~/.config/mosaic/tools/coolify/service-list.sh
|
|
||||||
~/.config/mosaic/tools/coolify/service-status.sh -u <uuid>
|
|
||||||
~/.config/mosaic/tools/coolify/deploy.sh -u <uuid>
|
|
||||||
~/.config/mosaic/tools/coolify/env-set.sh -u <uuid> -k KEY -v VALUE
|
|
||||||
```
|
|
||||||
|
|
||||||
### Identity — Authentik
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/authentik/user-list.sh
|
|
||||||
~/.config/mosaic/tools/authentik/user-create.sh -u <username> -n <name> -e <email>
|
|
||||||
~/.config/mosaic/tools/authentik/group-list.sh
|
|
||||||
~/.config/mosaic/tools/authentik/app-list.sh
|
|
||||||
~/.config/mosaic/tools/authentik/flow-list.sh
|
|
||||||
~/.config/mosaic/tools/authentik/admin-status.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### CI/CD — Woodpecker
|
|
||||||
|
|
||||||
Multi-instance support: `-a <instance>` selects a named instance. Omit `-a` to use the default from `woodpecker.default` in credentials.json.
|
|
||||||
|
|
||||||
| Instance | URL | Serves |
|
|
||||||
| ------------------ | ------------------ | ---------------------------------- |
|
|
||||||
| `mosaic` (default) | ci.mosaicstack.dev | Mosaic repos (git.mosaicstack.dev) |
|
|
||||||
| `usc` | ci.uscllc.com | USC repos (git.uscllc.com) |
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List recent pipelines
|
|
||||||
~/.config/mosaic/tools/woodpecker/pipeline-list.sh [-r owner/repo] [-a instance]
|
|
||||||
|
|
||||||
# Check latest or specific pipeline status
|
|
||||||
~/.config/mosaic/tools/woodpecker/pipeline-status.sh [-r owner/repo] [-n number] [-a instance]
|
|
||||||
|
|
||||||
# Trigger a build
|
|
||||||
~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh [-r owner/repo] [-b branch] [-a instance]
|
|
||||||
```
|
|
||||||
|
|
||||||
Instance selection rule: match `-a` to the git remote host of the target repo. If the repo is on `git.uscllc.com`, use `-a usc`. If on `git.mosaicstack.dev`, use `-a mosaic` (or omit, since it's the default).
|
|
||||||
|
|
||||||
### DNS — Cloudflare
|
|
||||||
|
|
||||||
Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal`, `work`). Omit `-a` to use the default from `cloudflare.default` in credentials.json.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List zones (domains)
|
|
||||||
~/.config/mosaic/tools/cloudflare/zone-list.sh [-a instance]
|
|
||||||
|
|
||||||
# List DNS records (zone by name or ID)
|
|
||||||
~/.config/mosaic/tools/cloudflare/record-list.sh -z <zone> [-a instance] [-t type] [-n name]
|
|
||||||
|
|
||||||
# Create DNS record
|
|
||||||
~/.config/mosaic/tools/cloudflare/record-create.sh -z <zone> -t <type> -n <name> -c <content> [-a instance] [-p] [-l ttl] [-P priority]
|
|
||||||
|
|
||||||
# Update DNS record
|
|
||||||
~/.config/mosaic/tools/cloudflare/record-update.sh -z <zone> -r <record-id> -t <type> -n <name> -c <content> [-a instance] [-p] [-l ttl]
|
|
||||||
|
|
||||||
# Delete DNS record
|
|
||||||
~/.config/mosaic/tools/cloudflare/record-delete.sh -z <zone> -r <record-id> [-a instance]
|
|
||||||
```
|
|
||||||
|
|
||||||
### IT Service — GLPI
|
|
||||||
|
|
||||||
```bash
|
|
||||||
~/.config/mosaic/tools/glpi/ticket-list.sh
|
|
||||||
~/.config/mosaic/tools/glpi/ticket-create.sh -t <title> -c <content>
|
|
||||||
~/.config/mosaic/tools/glpi/computer-list.sh
|
|
||||||
~/.config/mosaic/tools/glpi/user-list.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Check
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check all configured services
|
|
||||||
~/.config/mosaic/tools/health/stack-health.sh
|
|
||||||
|
|
||||||
# Check a specific service
|
|
||||||
~/.config/mosaic/tools/health/stack-health.sh -s portainer
|
|
||||||
|
|
||||||
# JSON output for automation
|
|
||||||
~/.config/mosaic/tools/health/stack-health.sh -f json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Shared Credential Loader
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Source in any script to load service credentials
|
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh
|
|
||||||
load_credentials <service-name>
|
|
||||||
# Supported: portainer, coolify, authentik, glpi, github, gitea-mosaicstack, gitea-usc, woodpecker, cloudflare, turbo-cache, openbrain
|
|
||||||
```
|
|
||||||
|
|
||||||
### OpenBrain — Semantic Memory (PRIMARY)
|
|
||||||
|
|
||||||
Self-hosted semantic brain backed by pgvector. Primary shared memory layer for all agents across all sessions and harnesses. Stores and retrieves decisions, context, and observations via semantic search.
|
|
||||||
|
|
||||||
**MANDATORY jarvis-brain rule:** When working in `~/src/jarvis-brain`, NEVER capture project data, meeting notes, status updates, timeline decisions, or task completions to OpenBrain. The flat files (`data/projects/*.json`, `data/tasks/*.json`) are the SSOT — use `tools/brain.py` and direct JSON edits. OpenBrain is for agent meta-observations ONLY (tooling gotchas, framework learnings, cross-project patterns). Violating this creates duplicate, divergent data.
|
|
||||||
|
|
||||||
**Credentials:** `load_credentials openbrain` → exports `OPENBRAIN_URL`, `OPENBRAIN_TOKEN`
|
|
||||||
|
|
||||||
Configure in your credentials.json:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"openbrain": {
|
|
||||||
"url": "https://<your-openbrain-host>",
|
|
||||||
"api_key": "<your-api-key>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**REST API** (any language, any harness):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials openbrain
|
|
||||||
|
|
||||||
# Search by meaning
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $OPENBRAIN_TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d '{"query": "your search", "limit": 5}' "$OPENBRAIN_URL/v1/search"
|
|
||||||
|
|
||||||
# Capture a thought
|
|
||||||
curl -s -X POST -H "Authorization: Bearer $OPENBRAIN_TOKEN" -H "Content-Type: application/json" \
|
|
||||||
-d '{"content": "...", "source": "agent-name", "metadata": {}}' "$OPENBRAIN_URL/v1/thoughts"
|
|
||||||
|
|
||||||
# Recent activity
|
|
||||||
curl -s -H "Authorization: Bearer $OPENBRAIN_TOKEN" "$OPENBRAIN_URL/v1/thoughts/recent?limit=5"
|
|
||||||
|
|
||||||
# Stats
|
|
||||||
curl -s -H "Authorization: Bearer $OPENBRAIN_TOKEN" "$OPENBRAIN_URL/v1/stats"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Python client** (if jarvis-brain is available on PYTHONPATH):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python tools/openbrain_client.py search "topic"
|
|
||||||
python tools/openbrain_client.py capture "decision or observation" --source agent-name
|
|
||||||
python tools/openbrain_client.py recent --limit 5
|
|
||||||
python tools/openbrain_client.py stats
|
|
||||||
```
|
|
||||||
|
|
||||||
**MCP (Claude Code sessions):** When connected, `mcp__openbrain__capture/search/recent/stats` tools are available natively — prefer those over CLI when in a Claude session.
|
|
||||||
|
|
||||||
**Rule: capture when you LEARN something. Never when you DO something.**
|
|
||||||
|
|
||||||
| Trigger | Action | Retention |
|
|
||||||
| ----------------------------------------- | ----------------------------------------- | --------------------- |
|
|
||||||
| Session start | `search` + `recent` to load prior context | — |
|
|
||||||
| Architectural or tooling decision made | Capture with rationale | `long` or `permanent` |
|
|
||||||
| Gotcha or non-obvious behavior discovered | Capture immediately | `medium` |
|
|
||||||
| User preference stated or confirmed | Capture | `permanent` |
|
|
||||||
| Cross-project pattern identified | Capture | `permanent` |
|
|
||||||
| Prior decision superseded | UPDATE existing thought | (keep tier) |
|
|
||||||
|
|
||||||
**Never capture:** task started, commit pushed, PR opened, test results, file edits, CI status.
|
|
||||||
|
|
||||||
Full protocol and cleanup tools: `~/.config/mosaic/guides/MEMORY.md`
|
|
||||||
Smart capture wrapper (enforces schema + dedup): `~/.config/mosaic/tools/openbrain/capture.sh`
|
|
||||||
|
|
||||||
### Excalidraw — Diagram Export (MCP)
|
|
||||||
|
|
||||||
Headless `.excalidraw` → SVG export via `@excalidraw/excalidraw`. Available as MCP tools in Claude Code sessions.
|
|
||||||
|
|
||||||
**MCP tools (when connected):**
|
|
||||||
|
|
||||||
| Tool | Input | Output |
|
|
||||||
| ----------------------------------------- | --------------------------------------------- | ---------------------------------------------------- |
|
|
||||||
| `mcp__excalidraw__excalidraw_to_svg` | `elements` JSON string + optional `app_state` | SVG string |
|
|
||||||
| `mcp__excalidraw__excalidraw_file_to_svg` | `file_path` to `.excalidraw` | SVG string + writes `.svg` alongside |
|
|
||||||
| `mcp__excalidraw__list_diagrams` | (none) | Available templates (requires `EXCALIDRAW_GEN_PATH`) |
|
|
||||||
| `mcp__excalidraw__generate_diagram` | `name`, optional `output_path` | Path to generated `.excalidraw` |
|
|
||||||
| `mcp__excalidraw__generate_and_export` | `name`, optional `output_path` | Paths to `.excalidraw` and `.svg` |
|
|
||||||
|
|
||||||
**Diagram generation** (`list_diagrams`, `generate_diagram`, `generate_and_export`) requires `EXCALIDRAW_GEN_PATH` env var pointing to `excalidraw_gen.py`. Set in environment or shell profile:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export EXCALIDRAW_GEN_PATH="$HOME/src/jarvis-brain/tools/excalidraw_export/excalidraw_gen.py"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Manual registration:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mosaic-ensure-excalidraw # install deps + register with Claude
|
|
||||||
mosaic-ensure-excalidraw --check # verify registration
|
|
||||||
```
|
|
||||||
|
|
||||||
## Git Providers
|
|
||||||
|
|
||||||
| Instance | URL | CLI | Purpose |
|
|
||||||
| ----------------------------- | --- | --- | ------- |
|
|
||||||
| (add your git providers here) | | | |
|
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
**Location:** (configure your credential file path)
|
`source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials <service>`
|
||||||
**Loader:** `source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials <service>`
|
Supported: portainer, coolify (deprecated), authentik, glpi, github, gitea-mosaicstack,
|
||||||
|
gitea-usc, woodpecker, cloudflare, turbo-cache, openbrain. Never expose or commit values.
|
||||||
|
|
||||||
**Never expose actual values. Never commit credential files.**
|
## OpenBrain — Semantic Memory (PRIMARY) — capture when you LEARN, never when you DO
|
||||||
|
|
||||||
## CLI Gotchas
|
Primary cross-agent memory (pgvector). Capture decisions/gotchas/preferences/patterns; never task
|
||||||
|
starts, commits, PRs, test results, or file edits. At session start, `search` + `recent` to load
|
||||||
|
prior context. MCP (`mcp__openbrain__capture/search/recent/stats`) preferred when connected; else
|
||||||
|
REST/`tools/openbrain_client.py`. Full protocol: `guides/MEMORY.md`.
|
||||||
|
|
||||||
(Add platform-specific CLI gotchas as you discover them.)
|
**MANDATORY jarvis-brain rule:** when working in `~/src/jarvis-brain`, NEVER capture project data,
|
||||||
|
meeting notes, status, timelines, or task completions to OpenBrain — the flat files
|
||||||
|
(`data/projects/*.json`, `data/tasks/*.json`) are the SSOT (use `tools/brain.py` + direct JSON
|
||||||
|
edits). OpenBrain there is for agent meta-observations ONLY (tooling gotchas, framework learnings,
|
||||||
|
cross-project patterns). Violating this creates duplicate, divergent data.
|
||||||
|
|
||||||
|
## Git Providers
|
||||||
|
|
||||||
|
| Host | Instance | CI |
|
||||||
|
| ------------------- | ---------------- | -------------------------------- |
|
||||||
|
| git.mosaicstack.dev | mosaic (default) | ci.mosaicstack.dev (`-a mosaic`) |
|
||||||
|
| git.uscllc.com | usc | ci.uscllc.com (`-a usc`) |
|
||||||
|
|
||||||
|
Match Woodpecker `-a` and credential instance to the target repo's git remote host.
|
||||||
|
|
||||||
## Safety Defaults
|
## Safety Defaults
|
||||||
|
|
||||||
- Prefer `trash` over `rm` when available — recoverable beats gone forever
|
- Prefer `trash` over `rm` when available — recoverable beats gone forever.
|
||||||
- Never run destructive commands without explicit instruction
|
- Never run destructive commands without explicit instruction.
|
||||||
- Write it down — "mental notes" don't survive session restarts; files do
|
|
||||||
|
|||||||
@@ -453,6 +453,26 @@ Initialize standard labels and the first pre-MVP milestone:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Secrets Bootstrap (Required for Every New App)
|
||||||
|
|
||||||
|
Every new application MUST complete the following secrets bootstrap before deploying to any non-local environment. This is a hard gate — deployment without completed secrets bootstrap is forbidden.
|
||||||
|
|
||||||
|
### Secrets bootstrap checklist
|
||||||
|
|
||||||
|
- [ ] Vault path created: `vault kv put secret/k3s/<app>/ ...` with all required secret fields
|
||||||
|
- [ ] Required secrets listed in project README under a "Secrets architecture" section, including:
|
||||||
|
- Vault path(s) used
|
||||||
|
- All required secret keys and their purpose
|
||||||
|
- Whether the app uses ESO bridge (default) or Direct-Vault (opt-in, with justification)
|
||||||
|
- [ ] `external-secret.yaml` manifest committed to repo's `deploy/` or `k8s/` directory
|
||||||
|
- [ ] Deployment YAML references the synced k8s Secret via `secretKeyRef` (not raw env vars or `.env` files)
|
||||||
|
- [ ] App startup has schema-based validation for all required env vars (zod / pydantic / envconfig equivalent) that exits non-zero on missing required values
|
||||||
|
- [ ] Direct-Vault opt-in (if applicable): justification documented in README + AppRole provisioned + bootstrap credentials stored in Vault and synced via a separate `ExternalSecret`
|
||||||
|
|
||||||
|
See `~/.config/mosaic/guides/VAULT-SECRETS.md` for full worked examples of the ESO bridge pattern, the Direct-Vault opt-in pattern, and the forbidden antipatterns.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
After bootstrapping, verify:
|
After bootstrapping, verify:
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ For implementation work, you MUST run this cycle in order:
|
|||||||
|
|
||||||
### Post-PR Hard Gate (Execute Sequentially, No Exceptions)
|
### Post-PR Hard Gate (Execute Sequentially, No Exceptions)
|
||||||
|
|
||||||
|
> **Merge authority:** if a coordinator/orchestrator session is active for this
|
||||||
|
> work, obtain the coordinator's merge go-ahead after review passes, then run
|
||||||
|
> the gate (AGENTS.md hard gate "Merge authority"). Solo delivery proceeds
|
||||||
|
> without asking.
|
||||||
|
|
||||||
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
1. `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`
|
||||||
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
2. `~/.config/mosaic/tools/git/pr-merge.sh -n <PR_NUMBER> -m squash`
|
||||||
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
3. `~/.config/mosaic/tools/git/pr-ci-wait.sh -n <PR_NUMBER>`
|
||||||
|
|||||||
257
packages/mosaic/framework/guides/TOOLS-REFERENCE.md
Normal file
257
packages/mosaic/framework/guides/TOOLS-REFERENCE.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Machine-Level Tool Reference
|
||||||
|
|
||||||
|
Centralized reference for tools, credentials, and CLI patterns available across all projects.
|
||||||
|
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||||
|
|
||||||
|
All tool suites are located at `~/.config/mosaic/tools/`.
|
||||||
|
|
||||||
|
## Tool Suites
|
||||||
|
|
||||||
|
### Git Wrappers (Use First)
|
||||||
|
|
||||||
|
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Issues
|
||||||
|
~/.config/mosaic/tools/git/issue-create.sh
|
||||||
|
~/.config/mosaic/tools/git/issue-close.sh
|
||||||
|
|
||||||
|
# PRs
|
||||||
|
~/.config/mosaic/tools/git/pr-create.sh
|
||||||
|
~/.config/mosaic/tools/git/pr-merge.sh
|
||||||
|
|
||||||
|
# Milestones
|
||||||
|
~/.config/mosaic/tools/git/milestone-create.sh
|
||||||
|
|
||||||
|
# CI queue guard (required before push/merge)
|
||||||
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Review (Codex)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||||
|
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure — Portainer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/portainer/stack-status.sh -n <stack-name>
|
||||||
|
~/.config/mosaic/tools/portainer/stack-redeploy.sh -n <stack-name>
|
||||||
|
~/.config/mosaic/tools/portainer/stack-list.sh
|
||||||
|
~/.config/mosaic/tools/portainer/endpoint-list.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Infrastructure — Coolify (DEPRECATED)
|
||||||
|
|
||||||
|
> Coolify has been superseded by Portainer Docker Swarm in this stack.
|
||||||
|
> Tools remain for reference but should not be used for new deployments.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DEPRECATED — do not use for new deployments
|
||||||
|
~/.config/mosaic/tools/coolify/project-list.sh
|
||||||
|
~/.config/mosaic/tools/coolify/service-list.sh
|
||||||
|
~/.config/mosaic/tools/coolify/service-status.sh -u <uuid>
|
||||||
|
~/.config/mosaic/tools/coolify/deploy.sh -u <uuid>
|
||||||
|
~/.config/mosaic/tools/coolify/env-set.sh -u <uuid> -k KEY -v VALUE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Identity — Authentik
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/authentik/user-list.sh
|
||||||
|
~/.config/mosaic/tools/authentik/user-create.sh -u <username> -n <name> -e <email>
|
||||||
|
~/.config/mosaic/tools/authentik/group-list.sh
|
||||||
|
~/.config/mosaic/tools/authentik/app-list.sh
|
||||||
|
~/.config/mosaic/tools/authentik/flow-list.sh
|
||||||
|
~/.config/mosaic/tools/authentik/admin-status.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### CI/CD — Woodpecker
|
||||||
|
|
||||||
|
Multi-instance support: `-a <instance>` selects a named instance. Omit `-a` to use the default from `woodpecker.default` in credentials.json.
|
||||||
|
|
||||||
|
| Instance | URL | Serves |
|
||||||
|
| ------------------ | ------------------ | ---------------------------------- |
|
||||||
|
| `mosaic` (default) | ci.mosaicstack.dev | Mosaic repos (git.mosaicstack.dev) |
|
||||||
|
| `usc` | ci.uscllc.com | USC repos (git.uscllc.com) |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List recent pipelines
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-list.sh [-r owner/repo] [-a instance]
|
||||||
|
|
||||||
|
# Check latest or specific pipeline status
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-status.sh [-r owner/repo] [-n number] [-a instance]
|
||||||
|
|
||||||
|
# Trigger a build
|
||||||
|
~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh [-r owner/repo] [-b branch] [-a instance]
|
||||||
|
```
|
||||||
|
|
||||||
|
Instance selection rule: match `-a` to the git remote host of the target repo. If the repo is on `git.uscllc.com`, use `-a usc`. If on `git.mosaicstack.dev`, use `-a mosaic` (or omit, since it's the default).
|
||||||
|
|
||||||
|
### DNS — Cloudflare
|
||||||
|
|
||||||
|
Multi-instance support: `-a <instance>` selects a named instance (e.g. `personal`, `work`). Omit `-a` to use the default from `cloudflare.default` in credentials.json.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List zones (domains)
|
||||||
|
~/.config/mosaic/tools/cloudflare/zone-list.sh [-a instance]
|
||||||
|
|
||||||
|
# List DNS records (zone by name or ID)
|
||||||
|
~/.config/mosaic/tools/cloudflare/record-list.sh -z <zone> [-a instance] [-t type] [-n name]
|
||||||
|
|
||||||
|
# Create DNS record
|
||||||
|
~/.config/mosaic/tools/cloudflare/record-create.sh -z <zone> -t <type> -n <name> -c <content> [-a instance] [-p] [-l ttl] [-P priority]
|
||||||
|
|
||||||
|
# Update DNS record
|
||||||
|
~/.config/mosaic/tools/cloudflare/record-update.sh -z <zone> -r <record-id> -t <type> -n <name> -c <content> [-a instance] [-p] [-l ttl]
|
||||||
|
|
||||||
|
# Delete DNS record
|
||||||
|
~/.config/mosaic/tools/cloudflare/record-delete.sh -z <zone> -r <record-id> [-a instance]
|
||||||
|
```
|
||||||
|
|
||||||
|
### IT Service — GLPI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
~/.config/mosaic/tools/glpi/ticket-list.sh
|
||||||
|
~/.config/mosaic/tools/glpi/ticket-create.sh -t <title> -c <content>
|
||||||
|
~/.config/mosaic/tools/glpi/computer-list.sh
|
||||||
|
~/.config/mosaic/tools/glpi/user-list.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check all configured services
|
||||||
|
~/.config/mosaic/tools/health/stack-health.sh
|
||||||
|
|
||||||
|
# Check a specific service
|
||||||
|
~/.config/mosaic/tools/health/stack-health.sh -s portainer
|
||||||
|
|
||||||
|
# JSON output for automation
|
||||||
|
~/.config/mosaic/tools/health/stack-health.sh -f json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Credential Loader
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source in any script to load service credentials
|
||||||
|
source ~/.config/mosaic/tools/_lib/credentials.sh
|
||||||
|
load_credentials <service-name>
|
||||||
|
# Supported: portainer, coolify, authentik, glpi, github, gitea-mosaicstack, gitea-usc, woodpecker, cloudflare, turbo-cache, openbrain
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenBrain — Semantic Memory (PRIMARY)
|
||||||
|
|
||||||
|
Self-hosted semantic brain backed by pgvector. Primary shared memory layer for all agents across all sessions and harnesses. Stores and retrieves decisions, context, and observations via semantic search.
|
||||||
|
|
||||||
|
**MANDATORY jarvis-brain rule:** When working in `~/src/jarvis-brain`, NEVER capture project data, meeting notes, status updates, timeline decisions, or task completions to OpenBrain. The flat files (`data/projects/*.json`, `data/tasks/*.json`) are the SSOT — use `tools/brain.py` and direct JSON edits. OpenBrain is for agent meta-observations ONLY (tooling gotchas, framework learnings, cross-project patterns). Violating this creates duplicate, divergent data.
|
||||||
|
|
||||||
|
**Credentials:** `load_credentials openbrain` → exports `OPENBRAIN_URL`, `OPENBRAIN_TOKEN`
|
||||||
|
|
||||||
|
Configure in your credentials.json:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"openbrain": {
|
||||||
|
"url": "https://<your-openbrain-host>",
|
||||||
|
"api_key": "<your-api-key>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**REST API** (any language, any harness):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials openbrain
|
||||||
|
|
||||||
|
# Search by meaning
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $OPENBRAIN_TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"query": "your search", "limit": 5}' "$OPENBRAIN_URL/v1/search"
|
||||||
|
|
||||||
|
# Capture a thought
|
||||||
|
curl -s -X POST -H "Authorization: Bearer $OPENBRAIN_TOKEN" -H "Content-Type: application/json" \
|
||||||
|
-d '{"content": "...", "source": "agent-name", "metadata": {}}' "$OPENBRAIN_URL/v1/thoughts"
|
||||||
|
|
||||||
|
# Recent activity
|
||||||
|
curl -s -H "Authorization: Bearer $OPENBRAIN_TOKEN" "$OPENBRAIN_URL/v1/thoughts/recent?limit=5"
|
||||||
|
|
||||||
|
# Stats
|
||||||
|
curl -s -H "Authorization: Bearer $OPENBRAIN_TOKEN" "$OPENBRAIN_URL/v1/stats"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python client** (if jarvis-brain is available on PYTHONPATH):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/openbrain_client.py search "topic"
|
||||||
|
python tools/openbrain_client.py capture "decision or observation" --source agent-name
|
||||||
|
python tools/openbrain_client.py recent --limit 5
|
||||||
|
python tools/openbrain_client.py stats
|
||||||
|
```
|
||||||
|
|
||||||
|
**MCP (Claude Code sessions):** When connected, `mcp__openbrain__capture/search/recent/stats` tools are available natively — prefer those over CLI when in a Claude session.
|
||||||
|
|
||||||
|
**Rule: capture when you LEARN something. Never when you DO something.**
|
||||||
|
|
||||||
|
| Trigger | Action | Retention |
|
||||||
|
| ----------------------------------------- | ----------------------------------------- | --------------------- |
|
||||||
|
| Session start | `search` + `recent` to load prior context | — |
|
||||||
|
| Architectural or tooling decision made | Capture with rationale | `long` or `permanent` |
|
||||||
|
| Gotcha or non-obvious behavior discovered | Capture immediately | `medium` |
|
||||||
|
| User preference stated or confirmed | Capture | `permanent` |
|
||||||
|
| Cross-project pattern identified | Capture | `permanent` |
|
||||||
|
| Prior decision superseded | UPDATE existing thought | (keep tier) |
|
||||||
|
|
||||||
|
**Never capture:** task started, commit pushed, PR opened, test results, file edits, CI status.
|
||||||
|
|
||||||
|
Full protocol and cleanup tools: `~/.config/mosaic/guides/MEMORY.md`
|
||||||
|
Smart capture wrapper (enforces schema + dedup): `~/.config/mosaic/tools/openbrain/capture.sh`
|
||||||
|
|
||||||
|
### Excalidraw — Diagram Export (MCP)
|
||||||
|
|
||||||
|
Headless `.excalidraw` → SVG export via `@excalidraw/excalidraw`. Available as MCP tools in Claude Code sessions.
|
||||||
|
|
||||||
|
**MCP tools (when connected):**
|
||||||
|
|
||||||
|
| Tool | Input | Output |
|
||||||
|
| ----------------------------------------- | --------------------------------------------- | ---------------------------------------------------- |
|
||||||
|
| `mcp__excalidraw__excalidraw_to_svg` | `elements` JSON string + optional `app_state` | SVG string |
|
||||||
|
| `mcp__excalidraw__excalidraw_file_to_svg` | `file_path` to `.excalidraw` | SVG string + writes `.svg` alongside |
|
||||||
|
| `mcp__excalidraw__list_diagrams` | (none) | Available templates (requires `EXCALIDRAW_GEN_PATH`) |
|
||||||
|
| `mcp__excalidraw__generate_diagram` | `name`, optional `output_path` | Path to generated `.excalidraw` |
|
||||||
|
| `mcp__excalidraw__generate_and_export` | `name`, optional `output_path` | Paths to `.excalidraw` and `.svg` |
|
||||||
|
|
||||||
|
**Diagram generation** (`list_diagrams`, `generate_diagram`, `generate_and_export`) requires `EXCALIDRAW_GEN_PATH` env var pointing to `excalidraw_gen.py`. Set in environment or shell profile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export EXCALIDRAW_GEN_PATH="$HOME/src/jarvis-brain/tools/excalidraw_export/excalidraw_gen.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual registration:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mosaic-ensure-excalidraw # install deps + register with Claude
|
||||||
|
mosaic-ensure-excalidraw --check # verify registration
|
||||||
|
```
|
||||||
|
|
||||||
|
## Git Providers
|
||||||
|
|
||||||
|
| Instance | URL | CLI | Purpose |
|
||||||
|
| ----------------------------- | --- | --- | ------- |
|
||||||
|
| (add your git providers here) | | | |
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
**Location:** (configure your credential file path)
|
||||||
|
**Loader:** `source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials <service>`
|
||||||
|
|
||||||
|
**Never expose actual values. Never commit credential files.**
|
||||||
|
|
||||||
|
## CLI Gotchas
|
||||||
|
|
||||||
|
(Add platform-specific CLI gotchas as you discover them.)
|
||||||
|
|
||||||
|
## Safety Defaults
|
||||||
|
|
||||||
|
- Prefer `trash` over `rm` when available — recoverable beats gone forever
|
||||||
|
- Never run destructive commands without explicit instruction
|
||||||
|
- Write it down — "mental notes" don't survive session restarts; files do
|
||||||
@@ -203,3 +203,374 @@ Error: token expired
|
|||||||
3. **Audit logging** - All access is logged; act accordingly
|
3. **Audit logging** - All access is logged; act accordingly
|
||||||
4. **No local copies** - Don't store secrets in files or env vars long-term
|
4. **No local copies** - Don't store secrets in files or env vars long-term
|
||||||
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
5. **Rotate on compromise** - Immediately rotate any exposed secrets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Architecture Decision Matrix
|
||||||
|
|
||||||
|
Use this table to choose between the ESO bridge (default) and Direct-Vault (opt-in) patterns for every new app or integration.
|
||||||
|
|
||||||
|
| Factor | ESO Bridge (default) | Direct-Vault (opt-in) |
|
||||||
|
| --------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Use-case** | All static secrets (DB creds, API keys, signing keys, OAuth secrets) | Dynamic creds with short TTLs (DB rotation, AWS STS, PKI), per-request audit trails, or lease renewal mid-pod-lifecycle |
|
||||||
|
| **App code change** | None — reads standard env vars via `secretKeyRef` | Requires Vault client (`hvac`, `node-vault`, `vault/api`) in application code |
|
||||||
|
| **Secret rotation** | ESO re-syncs on Vault write; pod restart or secret refresh picks up new value | App manages lease renewal or re-auth within the running process |
|
||||||
|
| **Audit granularity** | Access logged at Vault when ESO syncs; no per-request app audit | Every app request to Vault is a separate audit log entry |
|
||||||
|
| **Operational burden** | Low — ESO handles polling, sync, and k8s Secret lifecycle | Higher — app must handle auth, lease renewal, error paths, and token rotation |
|
||||||
|
| **Justification required?** | No — this is the default | Yes — document in project README under "Secrets architecture" |
|
||||||
|
| **Example use cases** | Web app DB password, OAuth client secret, JWT signing key, API token | HashiCorp DB secrets engine with 15-min TTL leases, AWS STS assume-role, Vault PKI short-lived certs |
|
||||||
|
|
||||||
|
**Decision rule:** If you are unsure, use ESO. Only justify Direct-Vault when the secret cannot be safely stored in a k8s Secret (too short-lived, per-request TTL required, or mid-lifecycle renewal needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ESO Bridge Pattern (Default)
|
||||||
|
|
||||||
|
This is the required default for all k8s workloads. Follow this exact pattern unless a documented dynamic-secrets requirement justifies Direct-Vault.
|
||||||
|
|
||||||
|
### 1. Provision Vault path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write the secrets for the app (run once; use IaC/Terraform for repeatable provisioning)
|
||||||
|
vault kv put secret/k3s/<app> \
|
||||||
|
db_password="..." \
|
||||||
|
api_key="..." \
|
||||||
|
jwt_secret="..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the canonical path structure: `secret/k3s/<app>` for k3s cluster workloads.
|
||||||
|
|
||||||
|
### 2. ExternalSecret manifest
|
||||||
|
|
||||||
|
Commit this to the repo's `deploy/` or `k8s/` directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-secrets
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 1h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend # ClusterSecretStore name — verify with cluster admin
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-secrets # k8s Secret name that will be created
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: DB_PASSWORD # key in the k8s Secret
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app> # Vault path
|
||||||
|
property: db_password # field within the Vault secret
|
||||||
|
- secretKey: API_KEY
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: api_key
|
||||||
|
- secretKey: JWT_SECRET
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>
|
||||||
|
property: jwt_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Deployment manifest — reference synced k8s Secret
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section)
|
||||||
|
env:
|
||||||
|
- name: DB_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets # matches ExternalSecret target.name
|
||||||
|
key: DB_PASSWORD
|
||||||
|
- name: API_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: API_KEY
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-secrets
|
||||||
|
key: JWT_SECRET
|
||||||
|
- name: PORT
|
||||||
|
value: '3000' # safe-default: non-secret, no Vault needed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. App-side schema validation — TypeScript (zod)
|
||||||
|
|
||||||
|
Validate all required env vars at startup. Exit non-zero on missing values.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/env.ts
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
DB_PASSWORD: z.string().min(1, 'DB_PASSWORD is required'),
|
||||||
|
API_KEY: z.string().min(1, 'API_KEY is required'),
|
||||||
|
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 chars'),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('production'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
console.error('Missing or invalid environment variables:');
|
||||||
|
console.error(result.error.flatten().fieldErrors);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = result.data;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. App-side schema validation — Python (pydantic)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# src/config.py
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
db_password: str
|
||||||
|
api_key: str
|
||||||
|
jwt_secret: str
|
||||||
|
port: int = 3000
|
||||||
|
node_env: str = "production"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=None) # no .env in prod
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings = Settings()
|
||||||
|
except Exception as e:
|
||||||
|
import sys
|
||||||
|
print(f"Missing or invalid environment variables: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. App-side schema validation — Go (envconfig)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// config/config.go
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/kelseyhightower/envconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DBPassword string `envconfig:"DB_PASSWORD" required:"true"`
|
||||||
|
APIKey string `envconfig:"API_KEY" required:"true"`
|
||||||
|
JWTSecret string `envconfig:"JWT_SECRET" required:"true"`
|
||||||
|
Port int `envconfig:"PORT" default:"3000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
var cfg Config
|
||||||
|
if err := envconfig.Process("", &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid environment: %w", err)
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In your `main.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Direct-Vault Opt-In Pattern
|
||||||
|
|
||||||
|
Use this pattern ONLY when a documented dynamic-secrets requirement applies (DB rotation with short TTLs, AWS STS, PKI, per-request audit). Document the justification in the project README under "Secrets architecture" before implementing.
|
||||||
|
|
||||||
|
### When it is justified
|
||||||
|
|
||||||
|
- Vault DB secrets engine with lease TTLs shorter than a typical pod lifecycle (< 1 hour)
|
||||||
|
- AWS STS assume-role tokens generated per-request
|
||||||
|
- Vault PKI short-lived certificates (< 24 hours) that must be renewed within a running pod
|
||||||
|
- Per-request audit trail requirement (each app call must appear separately in Vault audit log)
|
||||||
|
|
||||||
|
### Provision an AppRole for the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enable AppRole auth (if not already enabled)
|
||||||
|
vault auth enable approle
|
||||||
|
|
||||||
|
# Create a Vault policy for the app
|
||||||
|
# Note: KV v2 paths require both the exact path (for the top-level secret) and the
|
||||||
|
# wildcard (for sub-paths). Always include both to avoid permission denied errors.
|
||||||
|
vault policy write <app>-policy - <<EOF
|
||||||
|
path "secret/data/k3s/<app>" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "secret/data/k3s/<app>/*" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "database/creds/<app>-role" {
|
||||||
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Create the AppRole
|
||||||
|
vault write auth/approle/role/<app>-role \
|
||||||
|
token_policies="<app>-policy" \
|
||||||
|
token_ttl=1h \
|
||||||
|
token_max_ttl=4h \
|
||||||
|
secret_id_ttl=0
|
||||||
|
|
||||||
|
# Retrieve role-id and secret-id
|
||||||
|
vault read auth/approle/role/<app>-role/role-id
|
||||||
|
vault write -f auth/approle/role/<app>-role/secret-id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Bootstrap AppRole credentials via ESO (solving the chicken-and-egg problem)
|
||||||
|
|
||||||
|
The AppRole `role-id` and `secret-id` are themselves secrets. Store them in Vault at a bootstrap path, then use ESO to sync them into a k8s Secret. The app reads that k8s Secret at startup to authenticate with Vault directly.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Store the bootstrap credentials in Vault
|
||||||
|
vault kv put secret/k3s/<app>-bootstrap \
|
||||||
|
role_id="<role-id>" \
|
||||||
|
secret_id="<secret-id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/external-secret-bootstrap.yaml
|
||||||
|
apiVersion: external-secrets.io/v1beta1
|
||||||
|
kind: ExternalSecret
|
||||||
|
metadata:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
namespace: <namespace>
|
||||||
|
spec:
|
||||||
|
refreshInterval: 24h
|
||||||
|
secretStoreRef:
|
||||||
|
name: vault-backend
|
||||||
|
kind: ClusterSecretStore
|
||||||
|
target:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
creationPolicy: Owner
|
||||||
|
data:
|
||||||
|
- secretKey: VAULT_ROLE_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: role_id
|
||||||
|
- secretKey: VAULT_SECRET_ID
|
||||||
|
remoteRef:
|
||||||
|
key: secret/k3s/<app>-bootstrap
|
||||||
|
property: secret_id
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# deploy/deployment.yaml (env section for Direct-Vault app)
|
||||||
|
env:
|
||||||
|
- name: VAULT_ADDR
|
||||||
|
value: 'https://vault.example.com' # safe-default: non-secret cluster address
|
||||||
|
- name: VAULT_ROLE_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_ROLE_ID
|
||||||
|
- name: VAULT_SECRET_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: <app>-vault-auth
|
||||||
|
key: VAULT_SECRET_ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### App-side Vault client pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/vault-client.ts — only exists in Direct-Vault apps
|
||||||
|
import vault from 'node-vault';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const bootstrapSchema = z.object({
|
||||||
|
VAULT_ADDR: z.string().url(),
|
||||||
|
VAULT_ROLE_ID: z.string().min(1),
|
||||||
|
VAULT_SECRET_ID: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const bootstrap = bootstrapSchema.parse(process.env);
|
||||||
|
|
||||||
|
const client = vault({ endpoint: bootstrap.VAULT_ADDR });
|
||||||
|
|
||||||
|
export async function getVaultClient() {
|
||||||
|
const { auth } = await client.approleLogin({
|
||||||
|
role_id: bootstrap.VAULT_ROLE_ID,
|
||||||
|
secret_id: bootstrap.VAULT_SECRET_ID,
|
||||||
|
});
|
||||||
|
client.token = auth.client_token;
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Document in README under "Secrets architecture": the Vault path, why Direct-Vault is required, and the lease/renewal strategy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forbidden Patterns (CI Lint Targets)
|
||||||
|
|
||||||
|
The following patterns are forbidden in all Mosaic projects. CI lint SHOULD catch these automatically (implementation tracked separately). Agents MUST NOT introduce these patterns.
|
||||||
|
|
||||||
|
### 1. Untagged fallback defaults for required values
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# FORBIDDEN — required secret with silent fallback
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-changeme}
|
||||||
|
- API_KEY=${API_KEY:-}
|
||||||
|
|
||||||
|
# REQUIRED — fast-fail on missing required values
|
||||||
|
environment:
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||||
|
- API_KEY=${API_KEY:?API_KEY is required}
|
||||||
|
|
||||||
|
# ALLOWED — true convenience default, tagged
|
||||||
|
environment:
|
||||||
|
- PORT=${PORT:-3000} # safe-default: non-secret, app works at any port
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to: `docker-compose.yml`, k8s manifests, Helm `values.yaml`, any env file committed to git.
|
||||||
|
|
||||||
|
### 2. Vault KV calls in application source code (ESO-default projects)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN in ESO-default apps — direct Vault client in app source
|
||||||
|
import hvac
|
||||||
|
client = hvac.Client(url=os.environ['VAULT_ADDR'])
|
||||||
|
secret = client.secrets.kv.v2.read_secret_version(path='myapp/db')
|
||||||
|
```
|
||||||
|
|
||||||
|
ESO-default apps read env vars only. Direct-Vault clients belong only in apps with a documented dynamic-secrets justification in README.
|
||||||
|
|
||||||
|
### 3. Hardcoded secrets or API keys in committed files
|
||||||
|
|
||||||
|
```python
|
||||||
|
# FORBIDDEN — hardcoded credential
|
||||||
|
DB_PASSWORD = "supersecret123"
|
||||||
|
API_KEY = "sk-live-abc123"
|
||||||
|
```
|
||||||
|
|
||||||
|
No exceptions. CI lint must flag any string matching common secret patterns (`password`, `secret`, `api_key`, `token` assigned a literal non-env-var value).
|
||||||
|
|
||||||
|
### 4. `.env` files in production deployment paths
|
||||||
|
|
||||||
|
```
|
||||||
|
# FORBIDDEN — .env file in a production deploy path
|
||||||
|
deploy/.env
|
||||||
|
k8s/.env
|
||||||
|
docker/.env
|
||||||
|
|
||||||
|
# ALLOWED — local dev only
|
||||||
|
.env.example # template only, no real values
|
||||||
|
.env # local dev, must be in .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
`.env` files are acceptable in local-dev contexts only and MUST be in `.gitignore`. They are forbidden in any path that a CI pipeline or production deployment process reads directly.
|
||||||
|
|||||||
@@ -1,131 +1,61 @@
|
|||||||
# Claude Runtime Reference
|
# Claude Runtime Reference
|
||||||
|
|
||||||
## Runtime Scope
|
Claude-runtime behavior only. Global rules win if anything here conflicts.
|
||||||
|
|
||||||
This file applies only to Claude runtime behavior.
|
|
||||||
|
|
||||||
## Required Actions
|
## Required Actions
|
||||||
|
|
||||||
1. Follow global load order in `~/.config/mosaic/AGENTS.md`.
|
1. Follow the Session Start load order in `~/.config/mosaic/AGENTS.md`.
|
||||||
2. Use `~/.claude/settings.json` and `~/.claude/hooks-config.json` as runtime config sources.
|
2. Runtime config lives in `~/.claude/settings.json` (hooks, model, plugins, permissions) and
|
||||||
3. Treat sequential-thinking MCP as required.
|
`~/.claude/hooks-config.json`.
|
||||||
4. If runtime config conflicts with global rules, global rules win.
|
3. sequential-thinking MCP is required.
|
||||||
5. Documentation rules are inherited from `~/.config/mosaic/AGENTS.md` and `~/.config/mosaic/guides/DOCUMENTATION.md`.
|
4. First response MUST declare mode per the global contract.
|
||||||
6. For issue/PR/milestone actions, run Mosaic git wrappers first (`~/.config/mosaic/tools/git/*.sh`) and do not call raw `gh`/`tea`/`glab` first.
|
5. Git wrappers first for issue/PR/milestone ops; runtime-default confirmation prompts do NOT
|
||||||
7. For orchestration-oriented missions, load `~/.config/mosaic/guides/ORCHESTRATOR.md` before acting.
|
override Mosaic hard gates (push/merge/issue-close without routine confirmation).
|
||||||
8. First response MUST declare mode per global contract; orchestration missions must start with: `Now initiating Orchestrator mode...`
|
|
||||||
9. Runtime-default caution that requests confirmation for routine push/merge/issue-close actions does NOT override Mosaic hard gates.
|
|
||||||
|
|
||||||
## Subagent Model Selection (Claude Code Syntax)
|
## Subagent Model Selection (Claude Code syntax)
|
||||||
|
|
||||||
Claude Code's Task tool accepts a `model` parameter: `"haiku"`, `"sonnet"`, or `"opus"`.
|
The Task tool takes `model`: `"haiku"` | `"sonnet"` | `"opus"`. You MUST set it per the tier rule
|
||||||
|
in AGENTS.md — omitting it defaults to the parent (usually opus) and wastes budget.
|
||||||
You MUST set this parameter according to the model selection table in `~/.config/mosaic/AGENTS.md`. Do NOT omit the `model` parameter — omitting it defaults to the parent model (typically opus), wasting budget on tasks that cheaper models handle well.
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
|
|
||||||
```
|
```
|
||||||
# Codebase exploration — haiku
|
|
||||||
Task(subagent_type="Explore", model="haiku", prompt="Find all API route handlers")
|
Task(subagent_type="Explore", model="haiku", prompt="Find all API route handlers")
|
||||||
|
Task(subagent_type="feature-dev:code-reviewer", model="sonnet", prompt="Review src/auth/ changes")
|
||||||
# Code review — sonnet
|
|
||||||
Task(subagent_type="feature-dev:code-reviewer", model="sonnet", prompt="Review the changes in src/auth/")
|
|
||||||
|
|
||||||
# Standard feature work — sonnet
|
|
||||||
Task(subagent_type="general-purpose", model="sonnet", prompt="Add validation to the user input form")
|
|
||||||
|
|
||||||
# Complex architecture — opus (only when justified)
|
|
||||||
Task(subagent_type="Plan", model="opus", prompt="Design the multi-tenant isolation strategy")
|
Task(subagent_type="Plan", model="opus", prompt="Design the multi-tenant isolation strategy")
|
||||||
```
|
```
|
||||||
|
|
||||||
**Quick reference (from global AGENTS.md):**
|
|
||||||
|
|
||||||
| haiku | sonnet | opus |
|
|
||||||
| ---------------------- | ----------------- | -------------------------- |
|
|
||||||
| Search, grep, glob | Code review | Complex architecture |
|
|
||||||
| Status/health checks | Test writing | Security/auth logic |
|
|
||||||
| Simple one-liner fixes | Standard features | Ambiguous design decisions |
|
|
||||||
|
|
||||||
## Memory Policy (Hard Gate)
|
## Memory Policy (Hard Gate)
|
||||||
|
|
||||||
**OpenBrain is the primary cross-agent memory layer.** All agent learnings, gotchas, decisions, and project state MUST be captured to OpenBrain via the `capture` MCP tool or REST API.
|
OpenBrain is the primary cross-agent memory layer — capture learnings/gotchas/decisions there
|
||||||
|
(`capture` MCP tool or REST). `~/.claude/projects/*/memory/MEMORY.md` is **write-blocked** by the
|
||||||
|
`prevent-memory-write.sh` PreToolUse hook (the rule alone proved insufficient — the hook is the
|
||||||
|
hard gate). At session start, `search(topic)` + `recent()` to load prior context. Full protocol:
|
||||||
|
`~/.config/mosaic/guides/MEMORY.md`.
|
||||||
|
|
||||||
`~/.claude/projects/*/memory/MEMORY.md` files are **write-blocked by PreToolUse hook** (`prevent-memory-write.sh`). Any attempt to write agent learnings there will be rejected with an error directing you to OpenBrain.
|
Quick placement: discoveries/decisions → OpenBrain; active task state → `docs/TASKS.md` or
|
||||||
|
`docs/scratchpads/`; Mosaic framework notes → `~/.config/mosaic/memory/`.
|
||||||
### What belongs where
|
|
||||||
|
|
||||||
| Content | Location |
|
|
||||||
| ----------------------------------------------- | ---------------------------------------------------------------------- |
|
|
||||||
| Discoveries, gotchas, decisions, observations | OpenBrain `capture` — searchable by all agents |
|
|
||||||
| Active task state | `docs/TASKS.md` or `docs/scratchpads/` |
|
|
||||||
| Behavioral guardrails that MUST be in load-path | `MEMORY.md` (read-mostly; write only for genuine behavioral overrides) |
|
|
||||||
| Mosaic framework technical notes | `~/.config/mosaic/memory/` |
|
|
||||||
|
|
||||||
### Using OpenBrain
|
|
||||||
|
|
||||||
At session start, load prior context:
|
|
||||||
|
|
||||||
```
|
|
||||||
search("topic or project name") # semantic search
|
|
||||||
recent(limit=5) # what's been happening
|
|
||||||
```
|
|
||||||
|
|
||||||
When you discover something:
|
|
||||||
|
|
||||||
```
|
|
||||||
capture("The thing you learned", source="project/context", metadata={"type": "gotcha", ...})
|
|
||||||
```
|
|
||||||
|
|
||||||
### Why the hook exists
|
|
||||||
|
|
||||||
Instructions in RUNTIME.md, CLAUDE.md, and MEMORY.md are insufficient — agents default to writing local MEMORY.md regardless of written rules. The PreToolUse hook is a hard technical gate that makes the correct behavior the only possible behavior.
|
|
||||||
|
|
||||||
## MCP Configuration
|
## MCP Configuration
|
||||||
|
|
||||||
**MCPs are configured in `~/.claude.json` — NOT `~/.claude/settings.json`.**
|
MCP servers are configured in `~/.claude.json` (key `mcpServers`) — NOT `~/.claude/settings.json`,
|
||||||
|
where that key is ignored. `settings.json` controls hooks/model/plugins/permissions.
|
||||||
`settings.json` controls hooks, model, plugins, and allowed commands.
|
|
||||||
`~/.claude.json` is the global Claude Code state file where `mcpServers` lives.
|
|
||||||
|
|
||||||
To register an MCP server that persists across all sessions:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# HTTP MCP (e.g. OpenBrain)
|
|
||||||
claude mcp add --scope user --transport http <name> <url> --header "Authorization: Bearer <token>"
|
claude mcp add --scope user --transport http <name> <url> --header "Authorization: Bearer <token>"
|
||||||
|
claude mcp add --scope user <name> -- npx -y <package> # stdio
|
||||||
# stdio MCP
|
|
||||||
claude mcp add --scope user <name> -- npx -y <package>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`--scope user` = writes to `~/.claude.json` (global, all projects).
|
`--scope user` → `~/.claude.json` (global); `project` → `.claude/settings.json`; `local` (default)
|
||||||
`--scope project` = writes to `.claude/settings.json` in project root.
|
→ not committed.
|
||||||
`--scope local` = default, local-only (not committed).
|
|
||||||
|
|
||||||
Do NOT add `mcpServers` to `~/.claude/settings.json` — that key is ignored for MCP loading.
|
## Required Settings (launcher-audited, advisory)
|
||||||
|
|
||||||
## Required Claude Code Settings (Enforced by Launcher)
|
`mosaic claude` warns if `~/.claude/settings.json` is missing these (session still launches):
|
||||||
|
|
||||||
The `mosaic claude` launcher validates that `~/.claude/settings.json` contains the required Mosaic configuration. Missing or outdated settings trigger a warning at launch.
|
- **Hooks** — PreToolUse `prevent-memory-write.sh` (Write|Edit|MultiEdit); PostToolUse
|
||||||
|
`qa-hook-stdin.sh` + `typecheck-hook.sh` (Edit|MultiEdit|Write).
|
||||||
|
- **Plugins** — `feature-dev`, `pr-review-toolkit`, `code-review`.
|
||||||
|
- **Settings** — `enableAllMcpTools: true`; `model: "opus"` (orchestrator default; workers use
|
||||||
|
tiered models via the Task `model` param).
|
||||||
|
|
||||||
**Required hooks:**
|
Note: PostToolUse hook plain stdout on exit 0 goes to the debug log, not model context — only
|
||||||
|
`hookSpecificOutput.additionalContext` (or exit-2 stderr) enters context.
|
||||||
| Event | Matcher | Script | Purpose |
|
|
||||||
| ----------- | ------------------------ | ------------------------- | ---------------------------------------------- |
|
|
||||||
| PreToolUse | `Write\|Edit\|MultiEdit` | `prevent-memory-write.sh` | Block writes to `~/.claude/projects/*/memory/` |
|
|
||||||
| PostToolUse | `Edit\|MultiEdit\|Write` | `qa-hook-stdin.sh` | QA report generation after code edits |
|
|
||||||
| PostToolUse | `Edit\|MultiEdit\|Write` | `typecheck-hook.sh` | Inline TypeScript type checking |
|
|
||||||
|
|
||||||
**Required plugins:**
|
|
||||||
|
|
||||||
| Plugin | Purpose |
|
|
||||||
| ------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `feature-dev` | Subagent architecture: code-reviewer, code-architect, code-explorer |
|
|
||||||
| `pr-review-toolkit` | PR review: code-simplifier, comment-analyzer, test-analyzer, silent-failure-hunter, type-design-analyzer |
|
|
||||||
| `code-review` | Standalone code review capabilities |
|
|
||||||
|
|
||||||
**Required settings:**
|
|
||||||
|
|
||||||
- `enableAllMcpTools: true` — Allow all configured MCP tools without per-tool approval
|
|
||||||
- `model: "opus"` — Default to opus for orchestrator-level sessions (workers use tiered models via Task tool)
|
|
||||||
|
|
||||||
If `mosaic claude` detects missing hooks or plugins, it will print a warning with the exact settings to add. The session will still launch — enforcement is advisory, not blocking — but agents operating without these settings are running degraded.
|
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ gitea_get_branch_head_sha() {
|
|||||||
local branch="$3"
|
local branch="$3"
|
||||||
local token="$4"
|
local token="$4"
|
||||||
local url="https://${host}/api/v1/repos/${repo}/branches/${branch}"
|
local url="https://${host}/api/v1/repos/${repo}/branches/${branch}"
|
||||||
curl -fsSL -H "Authorization: token ${token}" "$url" | python3 -c '
|
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||||
import json, sys
|
import json, sys
|
||||||
data = json.load(sys.stdin)
|
data = json.load(sys.stdin)
|
||||||
commit = data.get("commit") or {}
|
commit = data.get("commit") or {}
|
||||||
@@ -151,7 +151,7 @@ gitea_get_commit_status_json() {
|
|||||||
local sha="$3"
|
local sha="$3"
|
||||||
local token="$4"
|
local token="$4"
|
||||||
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
||||||
curl -fsSL -H "Authorization: token ${token}" "$url"
|
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|||||||
@@ -55,6 +55,154 @@ function Get-GitRepoInfo {
|
|||||||
return $repoPath
|
return $repoPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-GitRemoteHost {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$remoteUrl = git remote get-url origin 2>$null
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($remoteUrl)) {
|
||||||
|
Write-Error "Not a git repository or no origin remote"
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteUrl -match "^https?://([^/]+)/") {
|
||||||
|
$remoteHost = $Matches[1]
|
||||||
|
return ($remoteHost -replace "^.*@", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remoteUrl -match "^git@([^:]+):") {
|
||||||
|
return $Matches[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TeaLoginList {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$json = tea login list --output json 2>$null
|
||||||
|
if (-not $json) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$items = $json | ConvertFrom-Json
|
||||||
|
} catch {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($null -eq $items) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
return @($items)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-GiteaUrlMatchesHost {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$Url,
|
||||||
|
[string]$GiteaHost
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($Url) -or [string]::IsNullOrEmpty($GiteaHost)) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uri = [Uri]$Url
|
||||||
|
return $uri.Host -eq $GiteaHost
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Find-TeaLoginForHost {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([Parameter(Mandatory=$true)][string]$GiteaHost)
|
||||||
|
|
||||||
|
foreach ($login in Get-TeaLoginList) {
|
||||||
|
$name = if ($login.name) { [string]$login.name } elseif ($login.Name) { [string]$login.Name } else { "" }
|
||||||
|
$url = if ($login.url) { [string]$login.url } elseif ($login.URL) { [string]$login.URL } else { "" }
|
||||||
|
if ([string]::IsNullOrEmpty($name) -or [string]::IsNullOrEmpty($url)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uri = [Uri]$url
|
||||||
|
if ($uri.Host -eq $GiteaHost) {
|
||||||
|
return $name
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-TeaLoginMatchesHost {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory=$true)][string]$LoginName,
|
||||||
|
[Parameter(Mandatory=$true)][string]$GiteaHost
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($login in Get-TeaLoginList) {
|
||||||
|
$name = if ($login.name) { [string]$login.name } elseif ($login.Name) { [string]$login.Name } else { "" }
|
||||||
|
$url = if ($login.url) { [string]$login.url } elseif ($login.URL) { [string]$login.URL } else { "" }
|
||||||
|
if ($name -ne $LoginName -or [string]::IsNullOrEmpty($url)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$uri = [Uri]$url
|
||||||
|
return $uri.Host -eq $GiteaHost
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-GiteaLoginForHost {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([string]$GiteaHost)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($GiteaHost)) {
|
||||||
|
$GiteaHost = Get-GitRemoteHost
|
||||||
|
}
|
||||||
|
if ([string]::IsNullOrEmpty($GiteaHost)) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($env:GITEA_LOGIN) {
|
||||||
|
if (Test-TeaLoginMatchesHost -LoginName $env:GITEA_LOGIN -GiteaHost $GiteaHost) {
|
||||||
|
return $env:GITEA_LOGIN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Find-TeaLoginForHost -GiteaHost $GiteaHost
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-GiteaRepoArgs {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$repo = Get-GitRepoInfo
|
||||||
|
$hostName = Get-GitRemoteHost
|
||||||
|
$login = Get-GiteaLoginForHost -GiteaHost $hostName
|
||||||
|
|
||||||
|
if ([string]::IsNullOrEmpty($repo) -or [string]::IsNullOrEmpty($login)) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
return @("--repo", $repo, "--login", $login)
|
||||||
|
}
|
||||||
|
|
||||||
function Get-GitRepoOwner {
|
function Get-GitRepoOwner {
|
||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param()
|
param()
|
||||||
|
|||||||
@@ -78,10 +78,211 @@ get_repo_slug() {
|
|||||||
get_repo_info
|
get_repo_info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gitea_url_matches_host() {
|
||||||
|
local url="${1:-}" host="${2:-}"
|
||||||
|
[[ -n "$url" && -n "$host" ]] || return 1
|
||||||
|
[[ "${url%/}" == "https://$host" || "${url%/}" == "http://$host" || "${url%/}" == *"//$host" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
get_gitea_service_for_host() {
|
||||||
|
local host="$1"
|
||||||
|
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
|
||||||
|
|
||||||
|
case "$host" in
|
||||||
|
git.mosaicstack.dev)
|
||||||
|
echo "mosaicstack"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
git.uscllc.com)
|
||||||
|
echo "usc"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
[[ -f "$cred_file" ]] || return 1
|
||||||
|
command -v jq >/dev/null 2>&1 || return 1
|
||||||
|
|
||||||
|
jq -r --arg host "$host" '
|
||||||
|
.gitea // {}
|
||||||
|
| to_entries[]
|
||||||
|
| select((.value.url // "" | sub("/+$"; "")) | test("https?://" + $host + "$"))
|
||||||
|
| .key
|
||||||
|
' "$cred_file" | head -n 1
|
||||||
|
}
|
||||||
|
|
||||||
|
find_tea_login_for_host() {
|
||||||
|
local host="$1"
|
||||||
|
local logins_json
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || return 1
|
||||||
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||||
|
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
host = sys.argv[1]
|
||||||
|
try:
|
||||||
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
for login in logins if isinstance(logins, list) else []:
|
||||||
|
url = str(login.get("url") or login.get("URL") or "")
|
||||||
|
name = str(login.get("name") or login.get("Name") or "")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.hostname == host and name:
|
||||||
|
print(name)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
tea_login_matches_host() {
|
||||||
|
local login_name="$1" host="$2"
|
||||||
|
local logins_json
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || return 1
|
||||||
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||||
|
TEA_LOGINS_JSON="$logins_json" python3 - "$login_name" "$host" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
login_name, host = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
for login in logins if isinstance(logins, list) else []:
|
||||||
|
url = str(login.get("url") or login.get("URL") or "")
|
||||||
|
name = str(login.get("name") or login.get("Name") or "")
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if name == login_name and parsed.hostname == host:
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
get_gitea_login_for_host() {
|
||||||
|
local host="${1:-}"
|
||||||
|
local login
|
||||||
|
|
||||||
|
if [[ -z "$host" ]]; then
|
||||||
|
host=$(get_remote_host) || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||||
|
if tea_login_matches_host "$GITEA_LOGIN" "$host"; then
|
||||||
|
echo "$GITEA_LOGIN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
login=$(find_tea_login_for_host "$host" || true)
|
||||||
|
if [[ -n "$login" ]]; then
|
||||||
|
echo "$login"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get_default_tea_login() {
|
||||||
|
local logins_json
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || return 1
|
||||||
|
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
||||||
|
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
if not isinstance(logins, list) or not logins:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
for login in logins:
|
||||||
|
if not isinstance(login, dict):
|
||||||
|
continue
|
||||||
|
is_default = str(login.get("default") or login.get("Default") or "").lower()
|
||||||
|
name = str(login.get("name") or login.get("Name") or "")
|
||||||
|
if name and is_default == "true":
|
||||||
|
print(name)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
for login in logins:
|
||||||
|
if not isinstance(login, dict):
|
||||||
|
continue
|
||||||
|
name = str(login.get("name") or login.get("Name") or "")
|
||||||
|
if name:
|
||||||
|
print(name)
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
get_gitea_login_for_repo_override() {
|
||||||
|
local login
|
||||||
|
|
||||||
|
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||||
|
echo "$GITEA_LOGIN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
login=$(get_default_tea_login || true)
|
||||||
|
if [[ -n "$login" ]]; then
|
||||||
|
echo "$login"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
get_host_from_url() {
|
||||||
|
local url="${1:-}"
|
||||||
|
[[ -n "$url" ]] || return 1
|
||||||
|
|
||||||
|
python3 - "$url" <<'PY'
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
parsed = urlparse(sys.argv[1])
|
||||||
|
if parsed.hostname:
|
||||||
|
print(parsed.hostname)
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
get_gitea_api_host_for_repo_override() {
|
||||||
|
if [[ -n "${GITEA_HOST:-}" ]]; then
|
||||||
|
echo "$GITEA_HOST"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
get_host_from_url "${GITEA_URL:-}"
|
||||||
|
}
|
||||||
|
|
||||||
get_gitea_repo_args() {
|
get_gitea_repo_args() {
|
||||||
local repo
|
local repo host login
|
||||||
repo=$(get_repo_slug) || return 1
|
repo=$(get_repo_slug) || return 1
|
||||||
printf -- '--repo %q --login %q' "$repo" "${GITEA_LOGIN:-mosaicstack}"
|
host=$(get_remote_host) || return 1
|
||||||
|
login=$(get_gitea_login_for_host "$host") || return 1
|
||||||
|
printf -- '--repo %q --login %q' "$repo" "$login"
|
||||||
|
}
|
||||||
|
|
||||||
|
get_gitea_login() {
|
||||||
|
get_gitea_login_for_host "$(get_remote_host)"
|
||||||
}
|
}
|
||||||
|
|
||||||
get_remote_host() {
|
get_remote_host() {
|
||||||
@@ -91,7 +292,8 @@ get_remote_host() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
|
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
|
||||||
echo "${BASH_REMATCH[1]}"
|
local host="${BASH_REMATCH[1]}"
|
||||||
|
echo "${host##*@}"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ switch ($platform) {
|
|||||||
Write-Host "Issue #$Issue updated successfully"
|
Write-Host "Issue #$Issue updated successfully"
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$needsEdit = $false
|
$needsEdit = $false
|
||||||
$cmd = @("tea", "issue", "edit", $Issue)
|
$cmd = @("tea", "issue", "edit", $Issue)
|
||||||
|
|
||||||
@@ -87,7 +92,7 @@ switch ($platform) {
|
|||||||
$needsEdit = $true
|
$needsEdit = $true
|
||||||
}
|
}
|
||||||
if ($Milestone) {
|
if ($Milestone) {
|
||||||
$milestoneList = tea milestones list 2>$null
|
$milestoneList = tea milestones list @repoArgs 2>$null
|
||||||
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
||||||
if ($milestoneId) {
|
if ($milestoneId) {
|
||||||
$cmd += @("--milestone", $milestoneId)
|
$cmd += @("--milestone", $milestoneId)
|
||||||
@@ -98,6 +103,7 @@ switch ($platform) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($needsEdit) {
|
if ($needsEdit) {
|
||||||
|
$cmd += $repoArgs
|
||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
Write-Host "Issue #$Issue updated successfully"
|
Write-Host "Issue #$Issue updated successfully"
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -98,7 +98,11 @@ case "$PLATFORM" in
|
|||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
# tea issue edit syntax
|
# tea issue edit syntax
|
||||||
CMD="tea issue edit $ISSUE"
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
|
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
CMD="tea issue edit $ISSUE $REPO_ARGS"
|
||||||
NEEDS_EDIT=false
|
NEEDS_EDIT=false
|
||||||
|
|
||||||
if [[ -n "$ASSIGNEE" ]]; then
|
if [[ -n "$ASSIGNEE" ]]; then
|
||||||
@@ -112,7 +116,7 @@ case "$PLATFORM" in
|
|||||||
NEEDS_EDIT=true
|
NEEDS_EDIT=true
|
||||||
fi
|
fi
|
||||||
if [[ -n "$MILESTONE" ]]; then
|
if [[ -n "$MILESTONE" ]]; then
|
||||||
MILESTONE_ID=$(tea milestones list 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
MILESTONE_ID=$(tea milestones list $REPO_ARGS 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
||||||
if [[ -n "$MILESTONE_ID" ]]; then
|
if [[ -n "$MILESTONE_ID" ]]; then
|
||||||
CMD="$CMD --milestone $MILESTONE_ID"
|
CMD="$CMD --milestone $MILESTONE_ID"
|
||||||
NEEDS_EDIT=true
|
NEEDS_EDIT=true
|
||||||
|
|||||||
@@ -44,10 +44,43 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Detect platform and close issue
|
# Detect platform and close issue
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
|
gitea_issue_comment_api() {
|
||||||
|
local host token url payload
|
||||||
|
host=$(get_remote_host) || return 1
|
||||||
|
token=$(get_gitea_token "$host") || return 1
|
||||||
|
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}/comments"
|
||||||
|
payload=$(COMMENT="$COMMENT" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({"body": os.environ["COMMENT"]}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
|
-H "Authorization: token ${token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"$url" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
gitea_issue_close_api() {
|
||||||
|
local host token url
|
||||||
|
host=$(get_remote_host) || return 1
|
||||||
|
token=$(get_gitea_token "$host") || return 1
|
||||||
|
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}"
|
||||||
|
curl -fsS -X PATCH \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
|
-H "Authorization: token ${token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"state":"closed"}' \
|
||||||
|
"$url" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
@@ -55,10 +88,19 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue close "$ISSUE_NUMBER"
|
gh issue close "$ISSUE_NUMBER"
|
||||||
echo "Closed GitHub issue #$ISSUE_NUMBER"
|
echo "Closed GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
|
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||||
|
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
|
fi
|
||||||
|
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "$GITEA_LOGIN_NAME"
|
||||||
|
else
|
||||||
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
|
if [[ -n "$COMMENT" ]]; then
|
||||||
|
gitea_issue_comment_api
|
||||||
|
fi
|
||||||
|
gitea_issue_close_api
|
||||||
fi
|
fi
|
||||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
|
|
||||||
echo "Closed Gitea issue #$ISSUE_NUMBER"
|
echo "Closed Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ if [[ -z "$COMMENT" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||||
|
|||||||
@@ -58,12 +58,17 @@ switch ($platform) {
|
|||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$cmd = @("tea", "issue", "create", "--title", $Title)
|
$cmd = @("tea", "issue", "create", "--title", $Title)
|
||||||
if ($Body) { $cmd += @("--description", $Body) }
|
if ($Body) { $cmd += @("--description", $Body) }
|
||||||
if ($Labels) { $cmd += @("--labels", $Labels) }
|
if ($Labels) { $cmd += @("--labels", $Labels) }
|
||||||
if ($Milestone) {
|
if ($Milestone) {
|
||||||
# Try to get milestone ID by name
|
# Try to get milestone ID by name
|
||||||
$milestoneList = tea milestones list 2>$null
|
$milestoneList = tea milestones list @repoArgs 2>$null
|
||||||
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
||||||
if ($milestoneId) {
|
if ($milestoneId) {
|
||||||
$cmd += @("--milestone", $milestoneId)
|
$cmd += @("--milestone", $milestoneId)
|
||||||
@@ -71,6 +76,7 @@ switch ($platform) {
|
|||||||
Write-Warning "Could not find milestone '$Milestone', creating without milestone"
|
Write-Warning "Could not find milestone '$Milestone', creating without milestone"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$cmd += $repoArgs
|
||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ PY
|
|||||||
|
|
||||||
url="https://${host}/api/v1/repos/${repo}/issues"
|
url="https://${host}/api/v1/repos/${repo}/issues"
|
||||||
curl -fsS -X POST \
|
curl -fsS -X POST \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
-H "Authorization: token ${token}" \
|
-H "Authorization: token ${token}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$payload" \
|
-d "$payload" \
|
||||||
@@ -121,7 +122,12 @@ case "$PLATFORM" in
|
|||||||
gitea)
|
gitea)
|
||||||
if command -v tea >/dev/null 2>&1; then
|
if command -v tea >/dev/null 2>&1; then
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
|
gitea_issue_create_api
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
[[ -n "$LABELS" ]] && CMD+=(--labels "$LABELS")
|
[[ -n "$LABELS" ]] && CMD+=(--labels "$LABELS")
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
CMD="gh issue edit $ISSUE_NUMBER"
|
CMD="gh issue edit $ISSUE_NUMBER"
|
||||||
@@ -71,7 +71,11 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
eval $CMD
|
eval $CMD
|
||||||
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
CMD="tea issue edit $ISSUE_NUMBER"
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
|
echo "Error: Could not resolve Gitea repo/login args for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
CMD="tea issue edit $ISSUE_NUMBER $REPO_ARGS"
|
||||||
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
||||||
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
||||||
[[ -n "$LABELS" ]] && CMD="$CMD --add-labels \"$LABELS\""
|
[[ -n "$LABELS" ]] && CMD="$CMD --add-labels \"$LABELS\""
|
||||||
|
|||||||
@@ -63,9 +63,15 @@ switch ($platform) {
|
|||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$cmd = @("tea", "issues", "list", "--state", $State, "--limit", $Limit)
|
$cmd = @("tea", "issues", "list", "--state", $State, "--limit", $Limit)
|
||||||
if ($Label) { $cmd += @("--labels", $Label) }
|
if ($Label) { $cmd += @("--labels", $Label) }
|
||||||
if ($Milestone) { $cmd += @("--milestones", $Milestone) }
|
if ($Milestone) { $cmd += @("--milestones", $Milestone) }
|
||||||
|
$cmd += $repoArgs
|
||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
if ($Assignee) {
|
if ($Assignee) {
|
||||||
Write-Warning "Assignee filtering may require manual review for Gitea"
|
Write-Warning "Assignee filtering may require manual review for Gitea"
|
||||||
|
|||||||
@@ -98,7 +98,18 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
CMD=(tea issues list --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}" --state "$STATE" --limit "$LIMIT")
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
||||||
|
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
|
echo "Error: Could not resolve Gitea login for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
CMD=(tea issues list --repo "$REPO_INFO" --login "$GITEA_LOGIN_NAME" --state "$STATE" --limit "$LIMIT")
|
||||||
[[ -n "$LABEL" ]] && CMD+=(--labels "$LABEL")
|
[[ -n "$LABEL" ]] && CMD+=(--labels "$LABEL")
|
||||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
|
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
|
||||||
# Note: tea may not support assignee filter directly in all versions.
|
# Note: tea may not support assignee filter directly in all versions.
|
||||||
|
|||||||
@@ -42,7 +42,42 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
OWNER=$(get_repo_owner)
|
||||||
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
|
gitea_issue_comment_api() {
|
||||||
|
local host token url payload
|
||||||
|
host=$(get_remote_host) || return 1
|
||||||
|
token=$(get_gitea_token "$host") || return 1
|
||||||
|
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}/comments"
|
||||||
|
payload=$(COMMENT="$COMMENT" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
print(json.dumps({"body": os.environ["COMMENT"]}))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
curl -fsS -X POST \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
|
-H "Authorization: token ${token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$payload" \
|
||||||
|
"$url" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
gitea_issue_reopen_api() {
|
||||||
|
local host token url
|
||||||
|
host=$(get_remote_host) || return 1
|
||||||
|
token=$(get_gitea_token "$host") || return 1
|
||||||
|
url="https://${host}/api/v1/repos/${OWNER}/${REPO}/issues/${ISSUE_NUMBER}"
|
||||||
|
curl -fsS -X PATCH \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
|
-H "Authorization: token ${token}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"state":"open"}' \
|
||||||
|
"$url" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
@@ -51,10 +86,19 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
gh issue reopen "$ISSUE_NUMBER"
|
gh issue reopen "$ISSUE_NUMBER"
|
||||||
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
|
REPO_ARGS=$(get_gitea_repo_args || true)
|
||||||
|
if [[ -n "$REPO_ARGS" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $REPO_ARGS
|
||||||
|
fi
|
||||||
|
tea issue reopen "$ISSUE_NUMBER" $REPO_ARGS
|
||||||
|
else
|
||||||
|
echo "No tea login configured for $(get_remote_host); using authenticated Gitea API fallback." >&2
|
||||||
|
if [[ -n "$COMMENT" ]]; then
|
||||||
|
gitea_issue_comment_api
|
||||||
|
fi
|
||||||
|
gitea_issue_reopen_api
|
||||||
fi
|
fi
|
||||||
tea issue reopen "$ISSUE_NUMBER" $(get_gitea_repo_args)
|
|
||||||
echo "Reopened Gitea issue #$ISSUE_NUMBER"
|
echo "Reopened Gitea issue #$ISSUE_NUMBER"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ gitea_issue_view_api() {
|
|||||||
|
|
||||||
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
|
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
curl -fsS -H "Authorization: token ${token}" "$url" | python3 -m json.tool
|
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -m json.tool
|
||||||
else
|
else
|
||||||
curl -fsS -H "Authorization: token ${token}" "$url"
|
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh issue view "$ISSUE_NUMBER"
|
gh issue view "$ISSUE_NUMBER"
|
||||||
|
|||||||
@@ -36,13 +36,17 @@ if [[ -z "$TITLE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed
|
gh api -X PATCH "/repos/{owner}/{repo}/milestones/$(gh api "/repos/{owner}/{repo}/milestones" --jq ".[] | select(.title==\"$TITLE\") | .number")" -f state=closed
|
||||||
echo "Closed GitHub milestone: $TITLE"
|
echo "Closed GitHub milestone: $TITLE"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
tea milestone close "$TITLE"
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
|
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
tea milestone close "$TITLE" $REPO_ARGS
|
||||||
echo "Closed Gitea milestone: $TITLE"
|
echo "Closed Gitea milestone: $TITLE"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
|
|||||||
@@ -59,7 +59,12 @@ if ($List) {
|
|||||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)`t\(.title)`t\(.state)`t\(.open_issues)/\(.closed_issues) issues"'
|
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)`t\(.title)`t\(.state)`t\(.open_issues)/\(.closed_issues) issues"'
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
tea milestones list
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
tea milestones list @repoArgs
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
Write-Error "Could not detect git platform"
|
Write-Error "Could not detect git platform"
|
||||||
@@ -85,9 +90,15 @@ switch ($platform) {
|
|||||||
Write-Host "Milestone '$Title' created successfully"
|
Write-Host "Milestone '$Title' created successfully"
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$cmd = @("tea", "milestones", "create", "--title", $Title)
|
$cmd = @("tea", "milestones", "create", "--title", $Title)
|
||||||
if ($Description) { $cmd += @("--description", $Description) }
|
if ($Description) { $cmd += @("--description", $Description) }
|
||||||
if ($Due) { $cmd += @("--deadline", $Due) }
|
if ($Due) { $cmd += @("--deadline", $Due) }
|
||||||
|
$cmd += $repoArgs
|
||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
Write-Host "Milestone '$Title' created successfully"
|
Write-Host "Milestone '$Title' created successfully"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,11 @@ if [[ "$LIST_ONLY" == true ]]; then
|
|||||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
|
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
tea milestones list
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
|
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
tea milestones list $REPO_ARGS
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Error: Could not detect git platform" >&2
|
echo "Error: Could not detect git platform" >&2
|
||||||
@@ -104,10 +108,14 @@ case "$PLATFORM" in
|
|||||||
echo "Milestone '$TITLE' created successfully"
|
echo "Milestone '$TITLE' created successfully"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
CMD="tea milestones create --title \"$TITLE\""
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
[[ -n "$DESCRIPTION" ]] && CMD="$CMD --description \"$DESCRIPTION\""
|
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||||
[[ -n "$DUE_DATE" ]] && CMD="$CMD --deadline \"$DUE_DATE\""
|
exit 1
|
||||||
eval "$CMD"
|
}
|
||||||
|
CMD=(tea milestones create --title "$TITLE")
|
||||||
|
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
|
||||||
|
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
|
||||||
|
"${CMD[@]}" $REPO_ARGS
|
||||||
echo "Milestone '$TITLE' created successfully"
|
echo "Milestone '$TITLE' created successfully"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -31,12 +31,16 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
|
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
tea milestone list
|
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||||
|
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
tea milestone list $REPO_ARGS
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ PR_NUMBER=""
|
|||||||
TIMEOUT_SEC=1800
|
TIMEOUT_SEC=1800
|
||||||
INTERVAL_SEC=15
|
INTERVAL_SEC=15
|
||||||
REPO_OVERRIDE=""
|
REPO_OVERRIDE=""
|
||||||
|
HOST_OVERRIDE=""
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -19,6 +20,7 @@ Usage: $(basename "$0") -n <pr_number> [-t timeout_sec] [-i interval_sec]
|
|||||||
Options:
|
Options:
|
||||||
-n, --number NUMBER PR number (required)
|
-n, --number NUMBER PR number (required)
|
||||||
-r, --repo OWNER/REPO Repository slug (default: infer from git origin)
|
-r, --repo OWNER/REPO Repository slug (default: infer from git origin)
|
||||||
|
--host HOST Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)
|
||||||
-t, --timeout SECONDS Max wait time in seconds (default: 1800)
|
-t, --timeout SECONDS Max wait time in seconds (default: 1800)
|
||||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||||
-h, --help Show this help
|
-h, --help Show this help
|
||||||
@@ -124,7 +126,7 @@ gitea_get_pr_head_sha() {
|
|||||||
local repo="$2"
|
local repo="$2"
|
||||||
local token="$3"
|
local token="$3"
|
||||||
local url="https://${host}/api/v1/repos/${repo}/pulls/${PR_NUMBER}"
|
local url="https://${host}/api/v1/repos/${repo}/pulls/${PR_NUMBER}"
|
||||||
curl -fsSL -H "Authorization: token ${token}" "$url" | python3 -c '
|
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||||
import json, sys
|
import json, sys
|
||||||
data = json.load(sys.stdin)
|
data = json.load(sys.stdin)
|
||||||
print((data.get("head") or {}).get("sha", ""))
|
print((data.get("head") or {}).get("sha", ""))
|
||||||
@@ -137,7 +139,7 @@ gitea_get_commit_status_json() {
|
|||||||
local token="$3"
|
local token="$3"
|
||||||
local sha="$4"
|
local sha="$4"
|
||||||
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
||||||
curl -fsSL -H "Authorization: token ${token}" "$url"
|
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -150,6 +152,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
REPO_OVERRIDE="$2"
|
REPO_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--host)
|
||||||
|
HOST_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-t|--timeout)
|
-t|--timeout)
|
||||||
TIMEOUT_SEC="$2"
|
TIMEOUT_SEC="$2"
|
||||||
shift 2
|
shift 2
|
||||||
@@ -211,7 +217,19 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
fi
|
fi
|
||||||
echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
HOST=$(get_remote_host 2>/dev/null || echo "git.mosaicstack.dev")
|
if [[ -n "$HOST_OVERRIDE" ]]; then
|
||||||
|
HOST="$HOST_OVERRIDE"
|
||||||
|
elif [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
HOST=$(get_gitea_api_host_for_repo_override) || {
|
||||||
|
echo "Error: Gitea host is required with --repo. Pass --host or set GITEA_HOST/GITEA_URL." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
HOST=$(get_remote_host) || {
|
||||||
|
echo "Error: Could not determine Gitea host from git origin." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
TOKEN=$(get_gitea_token "$HOST") || {
|
TOKEN=$(get_gitea_token "$HOST") || {
|
||||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ if [[ -z "$PR_NUMBER" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
if [[ -n "$COMMENT" ]]; then
|
if [[ -n "$COMMENT" ]]; then
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ param(
|
|||||||
[Alias("b")]
|
[Alias("b")]
|
||||||
[string]$Body,
|
[string]$Body,
|
||||||
|
|
||||||
[Alias("B")]
|
|
||||||
[string]$Base,
|
[string]$Base,
|
||||||
|
|
||||||
[Alias("H")]
|
[Alias("H")]
|
||||||
@@ -101,6 +100,11 @@ switch ($platform) {
|
|||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$cmd = @("tea", "pr", "create", "--title", $Title)
|
$cmd = @("tea", "pr", "create", "--title", $Title)
|
||||||
if ($Body) { $cmd += @("--description", $Body) }
|
if ($Body) { $cmd += @("--description", $Body) }
|
||||||
if ($Base) { $cmd += @("--base", $Base) }
|
if ($Base) { $cmd += @("--base", $Base) }
|
||||||
@@ -108,7 +112,7 @@ switch ($platform) {
|
|||||||
if ($Labels) { $cmd += @("--labels", $Labels) }
|
if ($Labels) { $cmd += @("--labels", $Labels) }
|
||||||
|
|
||||||
if ($Milestone) {
|
if ($Milestone) {
|
||||||
$milestoneList = tea milestones list 2>$null
|
$milestoneList = tea milestones list @repoArgs 2>$null
|
||||||
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
||||||
if ($milestoneId) {
|
if ($milestoneId) {
|
||||||
$cmd += @("--milestone", $milestoneId)
|
$cmd += @("--milestone", $milestoneId)
|
||||||
@@ -121,6 +125,7 @@ switch ($platform) {
|
|||||||
Write-Warning "Draft PR may not be supported by your tea version"
|
Write-Warning "Draft PR may not be supported by your tea version"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cmd += $repoArgs
|
||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ PY
|
|||||||
|
|
||||||
url="https://${host}/api/v1/repos/${repo}/pulls"
|
url="https://${host}/api/v1/repos/${repo}/pulls"
|
||||||
curl -fsS -X POST \
|
curl -fsS -X POST \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
-H "Authorization: token ${token}" \
|
-H "Authorization: token ${token}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$payload" \
|
-d "$payload" \
|
||||||
@@ -177,7 +178,12 @@ case "$PLATFORM" in
|
|||||||
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
||||||
# of eval so markdown backticks/body content are not shell-executed.
|
# of eval so markdown backticks/body content are not shell-executed.
|
||||||
REPO_SLUG=$(get_repo_slug)
|
REPO_SLUG=$(get_repo_slug)
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
|
echo "Warning: could not resolve Gitea login for tea; trying Gitea API fallback..." >&2
|
||||||
|
gitea_pr_create_api
|
||||||
|
exit $?
|
||||||
|
}
|
||||||
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
|||||||
PR_NUMBER=""
|
PR_NUMBER=""
|
||||||
OUTPUT_FILE=""
|
OUTPUT_FILE=""
|
||||||
REPO_OVERRIDE=""
|
REPO_OVERRIDE=""
|
||||||
|
HOST_OVERRIDE=""
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case $1 in
|
case $1 in
|
||||||
@@ -26,12 +27,17 @@ while [[ $# -gt 0 ]]; do
|
|||||||
REPO_OVERRIDE="$2"
|
REPO_OVERRIDE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--host)
|
||||||
|
HOST_OVERRIDE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [-o <output_file>]"
|
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [--host host] [-o <output_file>]"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " -n, --number PR number (required)"
|
echo " -n, --number PR number (required)"
|
||||||
echo " -r, --repo Repository slug (default: infer from git origin)"
|
echo " -r, --repo Repository slug (default: infer from git origin)"
|
||||||
|
echo " --host Gitea host for --repo API calls (or set GITEA_HOST/GITEA_URL)"
|
||||||
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
echo " -o, --output Output file (optional, prints to stdout if omitted)"
|
||||||
echo " -h, --help Show this help"
|
echo " -h, --help Show this help"
|
||||||
exit 0
|
exit 0
|
||||||
@@ -69,16 +75,28 @@ if [[ "$PLATFORM" == "github" ]]; then
|
|||||||
fi
|
fi
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
# tea doesn't have a direct diff command — use the API
|
# tea doesn't have a direct diff command — use the API
|
||||||
HOST=$(get_remote_host 2>/dev/null || echo "git.mosaicstack.dev")
|
if [[ -n "$HOST_OVERRIDE" ]]; then
|
||||||
|
HOST="$HOST_OVERRIDE"
|
||||||
|
elif [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
HOST=$(get_gitea_api_host_for_repo_override) || {
|
||||||
|
echo "Error: Gitea host is required with --repo. Pass --host or set GITEA_HOST/GITEA_URL." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
HOST=$(get_remote_host) || {
|
||||||
|
echo "Error: Could not determine Gitea host from git origin." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
DIFF_URL="https://${HOST}/api/v1/repos/${REPO_INFO}/pulls/${PR_NUMBER}.diff"
|
DIFF_URL="https://${HOST}/api/v1/repos/${REPO_INFO}/pulls/${PR_NUMBER}.diff"
|
||||||
|
|
||||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
||||||
|
|
||||||
if [[ -n "$GITEA_API_TOKEN" ]]; then
|
if [[ -n "$GITEA_API_TOKEN" ]]; then
|
||||||
DIFF_CONTENT=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" "$DIFF_URL")
|
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token $GITEA_API_TOKEN" "$DIFF_URL")
|
||||||
else
|
else
|
||||||
DIFF_CONTENT=$(curl -sS "$DIFF_URL")
|
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" "$DIFF_URL")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ switch ($platform) {
|
|||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
$cmd = @("tea", "pr", "list", "--state", $State, "--limit", $Limit)
|
$cmd = @("tea", "pr", "list", "--state", $State, "--limit", $Limit)
|
||||||
|
|
||||||
if ($Label) {
|
if ($Label) {
|
||||||
@@ -67,6 +72,7 @@ switch ($platform) {
|
|||||||
Write-Warning "Author filtering may require manual review for Gitea"
|
Write-Warning "Author filtering may require manual review for Gitea"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cmd += $repoArgs
|
||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
|
|||||||
@@ -93,7 +93,18 @@ case "$PLATFORM" in
|
|||||||
"${CMD[@]}"
|
"${CMD[@]}"
|
||||||
;;
|
;;
|
||||||
gitea)
|
gitea)
|
||||||
CMD=(tea pr list --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}" --state "$STATE" --limit "$LIMIT")
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
||||||
|
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
|
echo "Error: Could not resolve Gitea login for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
CMD=(tea pr list --repo "$REPO_INFO" --login "$GITEA_LOGIN_NAME" --state "$STATE" --limit "$LIMIT")
|
||||||
|
|
||||||
# tea filtering may be limited
|
# tea filtering may be limited
|
||||||
if [[ -n "$LABEL" ]]; then
|
if [[ -n "$LABEL" ]]; then
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ switch ($platform) {
|
|||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
"gitea" {
|
"gitea" {
|
||||||
|
$repoArgs = @(Get-GiteaRepoArgs)
|
||||||
|
if ($repoArgs.Length -eq 0) {
|
||||||
|
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
if (-not $SkipQueueGuard) {
|
if (-not $SkipQueueGuard) {
|
||||||
$timeout = if ($env:MOSAIC_CI_QUEUE_TIMEOUT_SEC) { [int]$env:MOSAIC_CI_QUEUE_TIMEOUT_SEC } else { 900 }
|
$timeout = if ($env:MOSAIC_CI_QUEUE_TIMEOUT_SEC) { [int]$env:MOSAIC_CI_QUEUE_TIMEOUT_SEC } else { 900 }
|
||||||
$interval = if ($env:MOSAIC_CI_QUEUE_POLL_SEC) { [int]$env:MOSAIC_CI_QUEUE_POLL_SEC } else { 15 }
|
$interval = if ($env:MOSAIC_CI_QUEUE_POLL_SEC) { [int]$env:MOSAIC_CI_QUEUE_POLL_SEC } else { 15 }
|
||||||
@@ -87,6 +92,7 @@ switch ($platform) {
|
|||||||
Write-Warning "Branch deletion after merge may need to be done separately with tea"
|
Write-Warning "Branch deletion after merge may need to be done separately with tea"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cmd += $repoArgs
|
||||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
|
|||||||
@@ -106,34 +106,6 @@ PLATFORM=$(detect_platform)
|
|||||||
OWNER=$(get_repo_owner)
|
OWNER=$(get_repo_owner)
|
||||||
REPO=$(get_repo_name)
|
REPO=$(get_repo_name)
|
||||||
|
|
||||||
find_tea_login_for_host() {
|
|
||||||
local host="$1"
|
|
||||||
local logins_json
|
|
||||||
|
|
||||||
command -v tea >/dev/null 2>&1 || return 1
|
|
||||||
logins_json=$(tea login list --output json 2>/dev/null) || return 1
|
|
||||||
TEA_LOGINS_JSON="$logins_json" python3 - "$host" <<'PY'
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
host = sys.argv[1]
|
|
||||||
try:
|
|
||||||
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
|
||||||
except Exception:
|
|
||||||
raise SystemExit(1)
|
|
||||||
|
|
||||||
for login in logins if isinstance(logins, list) else []:
|
|
||||||
url = str(login.get("url") or login.get("URL") or "")
|
|
||||||
name = str(login.get("name") or login.get("Name") or "")
|
|
||||||
if url.rstrip("/").endswith(host) and name:
|
|
||||||
print(name)
|
|
||||||
raise SystemExit(0)
|
|
||||||
|
|
||||||
raise SystemExit(1)
|
|
||||||
PY
|
|
||||||
}
|
|
||||||
|
|
||||||
is_known_tea_empty_identity_failure() {
|
is_known_tea_empty_identity_failure() {
|
||||||
local error_file="$1"
|
local error_file="$1"
|
||||||
|
|
||||||
@@ -164,6 +136,7 @@ merge_gitea_with_api() {
|
|||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||||
-X POST \
|
-X POST \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
-H "Authorization: token $token" \
|
-H "Authorization: token $token" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$payload" \
|
-d "$payload" \
|
||||||
@@ -179,6 +152,7 @@ merge_gitea_with_api() {
|
|||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||||
-X POST \
|
-X POST \
|
||||||
-u "$basic_auth" \
|
-u "$basic_auth" \
|
||||||
|
-H "User-Agent: curl/8" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "$payload" \
|
-d "$payload" \
|
||||||
"$api_url" || true)
|
"$api_url" || true)
|
||||||
@@ -214,7 +188,7 @@ if [[ "$DRY_RUN" == true ]]; then
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}"
|
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
if [[ -n "$TEA_LOGIN" ]]; then
|
||||||
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
echo "Dry run: would merge PR #$PR_NUMBER on $HOST with tea login '$TEA_LOGIN' (base=$BASE_BRANCH, method=squash)."
|
||||||
else
|
else
|
||||||
@@ -237,7 +211,7 @@ case "$PLATFORM" in
|
|||||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}"
|
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||||
|
|
||||||
if [[ -n "$TEA_LOGIN" ]]; then
|
if [[ -n "$TEA_LOGIN" ]]; then
|
||||||
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
|
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ curl_gitea_pull() {
|
|||||||
|
|
||||||
token=$(get_gitea_token "$HOST" || true)
|
token=$(get_gitea_token "$HOST" || true)
|
||||||
if [[ -n "$token" ]]; then
|
if [[ -n "$token" ]]; then
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "Authorization: token $token" "$api_url" || true)
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
cat "$body_file"
|
cat "$body_file"
|
||||||
rm -f "$body_file"
|
rm -f "$body_file"
|
||||||
@@ -70,7 +70,7 @@ curl_gitea_pull() {
|
|||||||
|
|
||||||
basic_auth=$(get_gitea_basic_auth "$HOST" || true)
|
basic_auth=$(get_gitea_basic_auth "$HOST" || true)
|
||||||
if [[ -n "$basic_auth" ]]; then
|
if [[ -n "$basic_auth" ]]; then
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" "$api_url" || true)
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
|
||||||
if [[ "$raw_code" =~ ^2 ]]; then
|
if [[ "$raw_code" =~ ^2 ]]; then
|
||||||
cat "$body_file"
|
cat "$body_file"
|
||||||
rm -f "$body_file"
|
rm -f "$body_file"
|
||||||
@@ -80,7 +80,7 @@ curl_gitea_pull() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${http_code:-}" ]]; then
|
if [[ -z "${http_code:-}" ]]; then
|
||||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" "$api_url" || true)
|
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url" || true)
|
||||||
http_code="$raw_code"
|
http_code="$raw_code"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if [[ -z "$ACTION" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
detect_platform
|
detect_platform >/dev/null
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
case $ACTION in
|
case $ACTION in
|
||||||
|
|||||||
@@ -58,7 +58,18 @@ fi
|
|||||||
if [[ "$PLATFORM" == "github" ]]; then
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
|
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
|
||||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||||
tea pr "$PR_NUMBER" --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}"
|
if [[ -n "$REPO_OVERRIDE" ]]; then
|
||||||
|
GITEA_LOGIN_NAME=$(get_gitea_login_for_repo_override) || {
|
||||||
|
echo "Error: Could not resolve Gitea login for --repo override. Set GITEA_LOGIN or configure a default tea login." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
else
|
||||||
|
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||||
|
echo "Error: Could not resolve Gitea login for remote host" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
tea pr "$PR_NUMBER" --repo "$REPO_INFO" --login "$GITEA_LOGIN_NAME"
|
||||||
else
|
else
|
||||||
echo "Error: Unknown platform"
|
echo "Error: Unknown platform"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
233
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
Executable file
233
packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh
Executable file
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for host-specific Gitea tea login resolution.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/gitea-login-resolution}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{
|
||||||
|
"gitea": {
|
||||||
|
"mosaicstack": {
|
||||||
|
"url": "https://git.mosaicstack.dev",
|
||||||
|
"token": "mosaic-token"
|
||||||
|
},
|
||||||
|
"usc": {
|
||||||
|
"url": "https://git.uscllc.com",
|
||||||
|
"token": "usc-token"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
cat <<'JSON'
|
||||||
|
[
|
||||||
|
{"name":"evil-usc","url":"https://evilgit.uscllc.com","user":"bad.actor"},
|
||||||
|
{"name":"usc","url":"https://git.uscllc.com","user":"jason.woltje"}
|
||||||
|
]
|
||||||
|
JSON
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
|
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
||||||
|
echo 'GetUserByName: simulated stale login failure' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
|
url="${*: -1}"
|
||||||
|
case "$url" in
|
||||||
|
*/pulls/*.diff)
|
||||||
|
printf 'diff --git a/file b/file\n'
|
||||||
|
;;
|
||||||
|
*/pulls/*)
|
||||||
|
printf '{"head":{"sha":"abc123"}}'
|
||||||
|
;;
|
||||||
|
*/commits/*/status)
|
||||||
|
printf '{"state":"success","statuses":[{"context":"ci/mock","status":"success"}]}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf '{}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
SH
|
||||||
|
|
||||||
|
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
run_in_repo() {
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
|
"$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
usc_login=$(run_in_repo bash -c '
|
||||||
|
export GITEA_LOGIN=mosaicstack
|
||||||
|
export GITEA_URL=https://git.mosaicstack.dev
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login
|
||||||
|
')
|
||||||
|
if [[ "$usc_login" != "usc" ]]; then
|
||||||
|
echo "Expected USC host to resolve tea login 'usc' despite stale mosaicstack env; got '$usc_login'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
usc_login_with_usc_url=$(run_in_repo bash -c '
|
||||||
|
export GITEA_LOGIN=mosaicstack
|
||||||
|
export GITEA_URL=https://git.uscllc.com
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login
|
||||||
|
')
|
||||||
|
if [[ "$usc_login_with_usc_url" != "usc" ]]; then
|
||||||
|
echo "Expected USC host to reject stale GITEA_LOGIN even when GITEA_URL matches USC; got '$usc_login_with_usc_url'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
usc_login_without_url=$(run_in_repo bash -c '
|
||||||
|
export GITEA_LOGIN=mosaicstack
|
||||||
|
unset GITEA_URL
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login
|
||||||
|
')
|
||||||
|
if [[ "$usc_login_without_url" != "usc" ]]; then
|
||||||
|
echo "Expected USC host to ignore unmatched GITEA_LOGIN without URL; got '$usc_login_without_url'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://hermes:token@git.uscllc.com/USC/uconnect.git
|
||||||
|
embedded_host=$(run_in_repo bash -c '
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_remote_host
|
||||||
|
')
|
||||||
|
if [[ "$embedded_host" != "git.uscllc.com" ]]; then
|
||||||
|
echo "Expected credential-bearing remote host to strip userinfo; got '$embedded_host'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
|
||||||
|
override_login=$(run_in_repo bash -c '
|
||||||
|
export GITEA_LOGIN=usc
|
||||||
|
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||||
|
get_gitea_login_for_repo_override
|
||||||
|
')
|
||||||
|
if [[ "$override_login" != "usc" ]]; then
|
||||||
|
echo "Expected --repo override path to honor explicit GITEA_LOGIN; got '$override_login'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo env GITEA_LOGIN=usc "$SCRIPT_DIR/issue-list.sh" --repo USC/uconnect -n 1
|
||||||
|
grep -q -- 'tea issues list --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo "$SCRIPT_DIR/issue-close.sh" -i 42
|
||||||
|
grep -q -- 'tea issue close 42 --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
if grep -q -- '--login mosaicstack' "$LOG_FILE"; then
|
||||||
|
echo "issue-close.sh used hardcoded mosaicstack login on USC host" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo "$SCRIPT_DIR/milestone-list.sh"
|
||||||
|
grep -q -- 'tea milestone list --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo "$SCRIPT_DIR/milestone-create.sh" -t "0.2.0" -d "USC milestone"
|
||||||
|
grep -q -- 'tea milestones create --title 0.2.0 --description USC milestone --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo "$SCRIPT_DIR/milestone-close.sh" -t "0.2.0"
|
||||||
|
grep -q -- 'tea milestone close 0.2.0 --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
if command -v pwsh >/dev/null 2>&1; then
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo pwsh -NoProfile -File "$SCRIPT_DIR/issue-list.ps1" -Limit 1
|
||||||
|
grep -q -- 'tea issues list --state open --limit 1 --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo pwsh -NoProfile -File "$SCRIPT_DIR/issue-create.ps1" -Title "PowerShell issue"
|
||||||
|
grep -q -- 'tea issue create --title PowerShell issue --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo pwsh -NoProfile -File "$SCRIPT_DIR/pr-list.ps1" -Limit 1
|
||||||
|
grep -q -- 'tea pr list --state open --limit 1 --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo pwsh -NoProfile -File "$SCRIPT_DIR/pr-create.ps1" -Title "PowerShell PR"
|
||||||
|
grep -q -- 'tea pr create --title PowerShell PR --head master --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo pwsh -NoProfile -File "$SCRIPT_DIR/pr-merge.ps1" -Number 42 -SkipQueueGuard
|
||||||
|
grep -q -- 'tea pr merge 42 --style squash --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo pwsh -NoProfile -File "$SCRIPT_DIR/milestone-create.ps1" -List
|
||||||
|
grep -q -- 'tea milestones list --repo USC/uconnect --login usc' "$LOG_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
if run_in_repo "$SCRIPT_DIR/pr-diff.sh" --repo USC/uconnect -n 7 >/dev/null 2>&1; then
|
||||||
|
echo "Expected pr-diff.sh --repo without host to fail loud" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -q -- 'git.mosaicstack.dev/api/v1/repos/USC/uconnect' "$LOG_FILE"; then
|
||||||
|
echo "pr-diff.sh --repo defaulted API host to git.mosaicstack.dev" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo env GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-diff.sh" --repo USC/uconnect -n 7 >/dev/null
|
||||||
|
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/7.diff' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo "$SCRIPT_DIR/pr-ci-wait.sh" --repo USC/uconnect --host git.uscllc.com -n 9 -t 2 -i 1
|
||||||
|
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls/9' "$LOG_FILE"
|
||||||
|
grep -q -- 'curl .*https://git.uscllc.com/api/v1/repos/USC/uconnect/commits/abc123/status' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo env MOSAIC_TEA_FAIL_PR_CREATE=1 GITEA_TOKEN=usc-token GITEA_URL=https://git.uscllc.com "$SCRIPT_DIR/pr-create.sh" -t "USC API fallback" -H feature/pr-create
|
||||||
|
grep -q -- 'tea pr create --repo USC/uconnect --login usc --title USC API fallback --head feature/pr-create' "$LOG_FILE"
|
||||||
|
grep -q -- 'curl .*Authorization: token usc-token .*https://git.uscllc.com/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"
|
||||||
|
if grep -q -- 'git.mosaicstack.dev/api/v1/repos/USC/uconnect/pulls' "$LOG_FILE"; then
|
||||||
|
echo "pr-create.sh API fallback defaulted USC repo to git.mosaicstack.dev" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_in_repo env GITEA_TOKEN=mosaic-token GITEA_URL=https://git.mosaicstack.dev "$SCRIPT_DIR/issue-close.sh" -i 536
|
||||||
|
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/536' "$LOG_FILE"
|
||||||
|
if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then
|
||||||
|
echo "issue-close.sh invented a mosaicstack tea login instead of using API fallback" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Gitea login resolution regression harness passed"
|
||||||
@@ -23,6 +23,10 @@ cat > "$MOCK_BIN/tea" <<'EOF'
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||||
|
if [[ "$*" == *"login list"* ]]; then
|
||||||
|
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [[ "$*" == *"pr merge"* ]]; then
|
if [[ "$*" == *"pr merge"* ]]; then
|
||||||
echo 'user does not exist [uid: 0, name: ]' >&2
|
echo 'user does not exist [uid: 0, name: ]' >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -99,6 +103,7 @@ git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
|||||||
export PATH="$MOCK_BIN:$PATH"
|
export PATH="$MOCK_BIN:$PATH"
|
||||||
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||||
|
export GITEA_URL="https://git.mosaicstack.dev"
|
||||||
export GITEA_TOKEN="redacted-test-token"
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
OUTPUT="$SANDBOX/output.log"
|
OUTPUT="$SANDBOX/output.log"
|
||||||
@@ -127,6 +132,10 @@ cat > "$MOCK_BIN/tea" <<'EOF'
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
printf 'tea %q ' "$@" >> "$PR_MERGE_TEST_LOG"
|
||||||
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
printf '\n' >> "$PR_MERGE_TEST_LOG"
|
||||||
|
if [[ "$*" == *"login list"* ]]; then
|
||||||
|
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
if [[ "$*" == *"pr merge"* ]]; then
|
if [[ "$*" == *"pr merge"* ]]; then
|
||||||
echo 'tea network timeout' >&2
|
echo 'tea network timeout' >&2
|
||||||
exit 2
|
exit 2
|
||||||
|
|||||||
78
packages/mosaic/framework/tools/tmux/README.md
Normal file
78
packages/mosaic/framework/tools/tmux/README.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Inter-Agent tmux Comms — Standard & Tooling
|
||||||
|
|
||||||
|
Reliable, self-identifying messaging between Mosaic agents running in tmux panes
|
||||||
|
(Claude Code / Codex / OpenCode REPLs), across hosts.
|
||||||
|
|
||||||
|
## The addressing standard (required)
|
||||||
|
|
||||||
|
Every cross-agent tmux message MUST begin with an addressing preamble:
|
||||||
|
|
||||||
|
```
|
||||||
|
[<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `host` = `hostname -s` of the machine the agent runs on (e.g. `web1`, `sb-it-mgr-0-lt`).
|
||||||
|
- `session` = the tmux session name (e.g. `mos-claude`, `rev0-4`, `installer-1`).
|
||||||
|
- **Replies FLIP the preamble**: the recipient answers with `[<dst> -> <src>] ...`.
|
||||||
|
|
||||||
|
Why: a fresh or context-wiped agent always knows who sent a message and to whom.
|
||||||
|
No ambiguity about origin or lane after a tmux wipe / session restart.
|
||||||
|
|
||||||
|
Example exchange:
|
||||||
|
|
||||||
|
```
|
||||||
|
[web1:mos-claude -> sb-it-mgr-0-lt:installer-1] status on #29?
|
||||||
|
[sb-it-mgr-0-lt:installer-1 -> web1:mos-claude] Q2 done, opening PR #34.
|
||||||
|
```
|
||||||
|
|
||||||
|
## The helper: `agent-send.sh`
|
||||||
|
|
||||||
|
Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||||
|
delivers reliably to local OR remote panes.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Local target (same host)
|
||||||
|
agent-send.sh -s <dst_session> -m "message"
|
||||||
|
|
||||||
|
# Remote target (over ssh)
|
||||||
|
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||||
|
|
||||||
|
# From a file / stdin
|
||||||
|
agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||||
|
echo "msg" | agent-send.sh -s <dst_session>
|
||||||
|
```
|
||||||
|
|
||||||
|
Key flags: `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||||
|
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||||
|
override source label · `-v` verbose · `-r N` Enter-flush attempts.
|
||||||
|
|
||||||
|
## Why a helper exists (the submission gotcha)
|
||||||
|
|
||||||
|
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||||
|
trailing `Enter` is frequently swallowed and the message sits as an **unsubmitted
|
||||||
|
draft** ("Press up to edit queued messages"). Over an `ssh -> nested tmux` hop the
|
||||||
|
plain `Enter` keyname often does not register at all — `C-m` is needed.
|
||||||
|
|
||||||
|
`send-message.sh` solves this for a **local** pane: bracketed-paste the body
|
||||||
|
(so multi-line content doesn't submit early), pause, then send `Enter` as its own
|
||||||
|
keystroke and flush with a second, verifying against a draft heuristic.
|
||||||
|
|
||||||
|
`agent-send.sh` solves the **remote** case by _shipping `send-message.sh` over ssh_
|
||||||
|
(`ssh host bash -s -- ... < send-message.sh`) and running it local to the target
|
||||||
|
pane — so the reliable send-keys always happens on the pane's own host. The remote
|
||||||
|
needs only `bash` + `tmux` + `base64`; **no mosaic install required there**. The
|
||||||
|
message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||||
|
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
These live in the installed framework copy at
|
||||||
|
`~/.config/mosaic/tools/tmux/`. `install.sh` rsyncs the framework **source tree**
|
||||||
|
to each host, so to propagate permanently, land both files in the framework
|
||||||
|
source repo and re-run the installer on each host. Until then, `agent-send.sh`
|
||||||
|
already works against any reachable host because it ships `send-message.sh` over
|
||||||
|
ssh per-send — no pre-install on the target host is needed to _send to_ it.
|
||||||
100
packages/mosaic/framework/tools/tmux/agent-send.sh
Executable file
100
packages/mosaic/framework/tools/tmux/agent-send.sh
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# agent-send.sh — standard inter-agent tmux messaging for the Mosaic stack.
|
||||||
|
#
|
||||||
|
# WHAT IT DOES
|
||||||
|
# Sends a message to another agent's tmux pane (local or on a remote host)
|
||||||
|
# with the canonical addressing preamble prepended:
|
||||||
|
#
|
||||||
|
# [<src_host>:<src_session> -> <dst_host>:<dst_session>] <message>
|
||||||
|
#
|
||||||
|
# The preamble makes every inter-agent message self-identifying, so a fresh
|
||||||
|
# or context-wiped agent always knows who sent a message and to whom — no
|
||||||
|
# ambiguity about lanes or origin. Recipients replying should FLIP the
|
||||||
|
# preamble: [<dst> -> <src>] ... (this tool sends; it does not auto-reply).
|
||||||
|
#
|
||||||
|
# WHY A WRAPPER
|
||||||
|
# Reliable submission into an interactive REPL (Claude Code / Codex) is fiddly:
|
||||||
|
# a trailing Enter is often swallowed and the message sits as an unsubmitted
|
||||||
|
# DRAFT. tools/tmux/send-message.sh already solves that for a LOCAL pane via
|
||||||
|
# bracketed-paste + Enter-flush + draft-detection. For REMOTE targets this
|
||||||
|
# wrapper SHIPS send-message.sh over ssh (stdin) and runs it there, so the
|
||||||
|
# reliable send-keys happens local to the target pane — sidestepping the
|
||||||
|
# ssh->nested-tmux Enter/C-m swallow entirely. No mosaic install needed on
|
||||||
|
# the remote host; only bash + tmux + base64 (standard).
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# agent-send.sh -s <dst_session> -m "message" # local target
|
||||||
|
# agent-send.sh -H user@host -s <dst_session> -m "message" # remote target
|
||||||
|
# agent-send.sh -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||||
|
# echo "msg" | agent-send.sh -H user@host -s <dst_session>
|
||||||
|
#
|
||||||
|
# OPTIONS
|
||||||
|
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||||
|
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||||
|
# -n DST_HOST hostname to show in the preamble for the target.
|
||||||
|
# Default: local hostname, or (remote) resolved via one ssh.
|
||||||
|
# -m MESSAGE message text (single- or multi-line)
|
||||||
|
# -f FILE read message from FILE instead of -m
|
||||||
|
# -S SRC_LABEL override source label "<host>:<session>" (default: auto)
|
||||||
|
# -r N Enter-flush attempts passed through (default 2)
|
||||||
|
# -v verbose: print pane tail after delivery
|
||||||
|
# -h help
|
||||||
|
#
|
||||||
|
# EXIT CODES (passed through from send-message.sh)
|
||||||
|
# 0 delivered/queued · 1 target not found · 2 still draft · 3 usage error
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
SENDER="$SELF_DIR/send-message.sh"
|
||||||
|
|
||||||
|
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""
|
||||||
|
SRC_LABEL=""; RETRIES=2; VERBOSE=0
|
||||||
|
usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; }
|
||||||
|
|
||||||
|
while getopts "s:H:n:m:f:S:r:vh" o; do
|
||||||
|
case "$o" in
|
||||||
|
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||||
|
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||||
|
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$DST_SESSION" ] || { echo "ERROR: -s DST_SESSION is required" >&2; usage 3; }
|
||||||
|
[ -x "$SENDER" ] || { echo "ERROR: send-message.sh not found beside this script" >&2; exit 3; }
|
||||||
|
|
||||||
|
# Message body from -f / -m / stdin.
|
||||||
|
if [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||||
|
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||||
|
fi
|
||||||
|
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||||
|
|
||||||
|
# Source label: this agent's host:session (auto-detected, overridable).
|
||||||
|
if [ -z "$SRC_LABEL" ]; then
|
||||||
|
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||||
|
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||||
|
SRC_LABEL="${src_host}:${src_sess}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Destination host label for the preamble.
|
||||||
|
if [ -z "$DST_HOST" ]; then
|
||||||
|
if [ -n "$SSH_TARGET" ]; then
|
||||||
|
DST_HOST=$(ssh -o ConnectTimeout=8 -o BatchMode=yes "$SSH_TARGET" 'hostname -s' 2>/dev/null || echo "${SSH_TARGET#*@}")
|
||||||
|
else
|
||||||
|
DST_HOST=$(hostname -s 2>/dev/null || echo "local")
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREAMBLE="[${SRC_LABEL} -> ${DST_HOST}:${DST_SESSION}]"
|
||||||
|
FULL="${PREAMBLE} ${MSG}"
|
||||||
|
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||||
|
|
||||||
|
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||||
|
|
||||||
|
if [ -z "$SSH_TARGET" ]; then
|
||||||
|
# Local pane: call the canonical sender directly.
|
||||||
|
exec "$SENDER" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
|
||||||
|
else
|
||||||
|
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||||
|
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||||
|
"bash -s -- -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||||
|
fi
|
||||||
97
packages/mosaic/framework/tools/tmux/send-message.sh
Executable file
97
packages/mosaic/framework/tools/tmux/send-message.sh
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# send-message.sh — reliably deliver a message to a tmux pane running an
|
||||||
|
# interactive REPL (e.g. a Claude Code / Codex agent).
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# Pasting multi-line text into an interactive agent REPL via `tmux send-keys`
|
||||||
|
# is unreliable: the text lands in the input box but a single trailing Enter
|
||||||
|
# in the same keystroke stream is frequently swallowed, so the message sits as
|
||||||
|
# an UNSUBMITTED DRAFT ("Press up to edit queued messages") and the agent never
|
||||||
|
# sees it. The mechanical fix is: paste as a bracketed paste (so embedded
|
||||||
|
# newlines don't submit early), pause, then send Enter as its OWN keystroke,
|
||||||
|
# pause, and send Enter again to flush. An extra Enter on an empty prompt is a
|
||||||
|
# no-op in Claude Code, so the double-Enter is safe.
|
||||||
|
#
|
||||||
|
# USAGE
|
||||||
|
# send-message.sh -t <target> -m "message"
|
||||||
|
# send-message.sh -t <target> -f <file>
|
||||||
|
# echo "message" | send-message.sh -t <target>
|
||||||
|
# ssh host bash -s -- -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||||
|
#
|
||||||
|
# OPTIONS
|
||||||
|
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||||
|
# -m MESSAGE message text (single- or multi-line)
|
||||||
|
# -f FILE read message from FILE instead of -m
|
||||||
|
# -b BASE64 message as base64 (ssh-safe transport; decoded internally)
|
||||||
|
# -r N Enter-flush attempts (default 2)
|
||||||
|
# -v verbose: print a short tail of the pane after delivery
|
||||||
|
# -h help
|
||||||
|
#
|
||||||
|
# EXIT CODES
|
||||||
|
# 0 delivered (submitted) or queued (agent busy; will process when free)
|
||||||
|
# 1 tmux target not found
|
||||||
|
# 2 message still appears to be an unsubmitted draft after retries
|
||||||
|
# 3 usage error
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||||
|
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
|
||||||
|
|
||||||
|
while getopts "t:m:f:b:r:vh" o; do
|
||||||
|
case "$o" in
|
||||||
|
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||||
|
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -n "$TARGET" ] || { echo "ERROR: -t TARGET is required" >&2; usage 3; }
|
||||||
|
if [ -n "$B64" ]; then MSG=$(printf '%s' "$B64" | base64 -d) || { echo "ERROR: bad -b base64" >&2; exit 3; }
|
||||||
|
elif [ -n "$FILE" ]; then [ -r "$FILE" ] || { echo "ERROR: cannot read $FILE" >&2; exit 3; }; MSG=$(cat -- "$FILE")
|
||||||
|
elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||||
|
fi
|
||||||
|
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||||
|
|
||||||
|
# Target must resolve to a live pane.
|
||||||
|
if ! tmux list-panes -t "$TARGET" >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
QUEUED_RE='Press up to edit queued messages'
|
||||||
|
# A distinctive tail of the message to spot an unsubmitted draft on the input line.
|
||||||
|
snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g' | tail -c 32)
|
||||||
|
|
||||||
|
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||||
|
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||||
|
printf '%s' "$MSG" | tmux load-buffer -b __mosaic_send -
|
||||||
|
# -p = bracketed paste when the client supports it; fall back if not.
|
||||||
|
tmux paste-buffer -d -p -b __mosaic_send -t "$TARGET" 2>/dev/null \
|
||||||
|
|| tmux paste-buffer -d -b __mosaic_send -t "$TARGET"
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
||||||
|
status="sent"
|
||||||
|
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||||
|
tmux send-keys -t "$TARGET" Enter
|
||||||
|
sleep 1.2
|
||||||
|
pane=$(tmux capture-pane -t "$TARGET" -p 2>/dev/null)
|
||||||
|
|
||||||
|
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
|
||||||
|
status="queued"; break
|
||||||
|
fi
|
||||||
|
# Draft heuristic: the prompt glyph line still carries our message tail.
|
||||||
|
# (Submitted messages scroll up into history; a draft stays on the ❯ line.)
|
||||||
|
promptline=$(printf '%s' "$pane" | grep -E '❯|^>|│ >' | tail -1)
|
||||||
|
if [ -n "$snippet" ] && printf '%s' "$promptline" | grep -qF "$snippet"; then
|
||||||
|
status="draft"; continue
|
||||||
|
fi
|
||||||
|
status="delivered"; break
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$VERBOSE" = 1 ] && { echo "--- pane tail ($TARGET) ---"; printf '%s\n' "$pane" | tail -4; echo "---"; }
|
||||||
|
|
||||||
|
case "$status" in
|
||||||
|
delivered) echo "✓ delivered to $TARGET"; exit 0 ;;
|
||||||
|
queued) echo "✓ queued to $TARGET (agent busy — will process when it returns to prompt)"; exit 0 ;;
|
||||||
|
draft) echo "✗ still an unsubmitted draft on $TARGET after $RETRIES flush attempts" >&2; exit 2 ;;
|
||||||
|
*) echo "✓ sent to $TARGET (submission state indeterminate; verify with -v)"; exit 0 ;;
|
||||||
|
esac
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.30",
|
"version": "0.0.31",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ describe('Gitea git wrapper API calls', () => {
|
|||||||
(scriptName) => {
|
(scriptName) => {
|
||||||
const script = readGitTool(scriptName);
|
const script = readGitTool(scriptName);
|
||||||
|
|
||||||
expect(script).not.toContain('curl -fsS -H "Authorization: token');
|
expect(script).not.toMatch(/curl -fsS\s+(?:-H "[^"]+"\s+)*-H "Authorization: token/);
|
||||||
expect(script).toContain('curl -fsSL -H "Authorization: token');
|
expect(script).toMatch(/curl -fsSL\s+(?:-H "[^"]+"\s+)*-H "Authorization: token/);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -39,6 +39,25 @@ importers:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
version: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
|
apps/appservice:
|
||||||
|
dependencies:
|
||||||
|
'@mosaicstack/appservice':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/appservice
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.19.15
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.19.0
|
||||||
|
version: 4.21.0
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.8.0
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.1.9(@types/node@22.19.15)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)
|
||||||
|
|
||||||
apps/gateway:
|
apps/gateway:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@anthropic-ai/sdk':
|
'@anthropic-ai/sdk':
|
||||||
|
|||||||
35
scratchpads/contract-thin-core.md
Normal file
35
scratchpads/contract-thin-core.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Scratchpad — Thin-core prompt diet (#528)
|
||||||
|
|
||||||
|
**Branch:** `feat/contract-thin-core` · **Issue:** #528 · **Mode:** Delivery
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Cut the always-injected contract (`defaults/AGENTS.md` + `defaults/TOOLS.md` + `runtime/claude/RUNTIME.md`, inlined every turn by the launcher) without losing any hard gate. Restore the original "thin core + on-demand guides" intent.
|
||||||
|
|
||||||
|
## Change
|
||||||
|
|
||||||
|
- `defaults/AGENTS.md` → thin core: 12 hard gates verbatim, 37 operating rules condensed to ~15 bullets (detail already in `guides/E2E-DELIVERY.md`), Superpowers condensed, load order made on-demand (no halt-on-missing for STANDARDS), conditional guide-loading index retained.
|
||||||
|
- `defaults/TOOLS.md` → index; full catalog moved to new `guides/TOOLS-REFERENCE.md` (read on demand).
|
||||||
|
- `runtime/claude/RUNTIME.md` → slimmed (dedup tier table, terser pointers).
|
||||||
|
|
||||||
|
## Method (autoresearch-style validation)
|
||||||
|
|
||||||
|
1. Built a 9-probe role battery (backend/deploy/review/orchestrate/secrets/docs/simple-trap/no-stop-at-PR/agent-work) + a deterministic 18-signature gate-checklist.
|
||||||
|
2. Headless interactive runs (Claude Max **subscription**, tmux — no API), scored by per-probe rubric.
|
||||||
|
3. Keep-or-discard hill-climb (token cost gated by per-probe fidelity) proved the method; final design re-derived against THIS repo's content (diet-only, no drifted-deployment content imported).
|
||||||
|
|
||||||
|
## Validation evidence
|
||||||
|
|
||||||
|
- Gate-checklist: ALL gates + critical rules + mode lines + sequential-thinking + OpenBrain + Superpowers present.
|
||||||
|
- A/B on real repo content: **thin 7/9 vs monolith 5/9** probes; strictly better on deploy/review/simple-task; composed **8,827 → 4,122 tok (−53%)**.
|
||||||
|
- p11 (don't-stop-at-PR): 3→2/3 on one rubric line — verified a scorer/phrasing artifact (answer correctly cites gates §5/§9 + close-issue; gate verbatim-present). Variance: thin 2/2/3, v0 3/3/3.
|
||||||
|
|
||||||
|
## Decisions / risks
|
||||||
|
|
||||||
|
- **Diet-only** vs repo content (user decision). Did NOT import web1's Gate 13-15 / federated memory / OpenViking — canonical repo is behind those deployments; flagged for separate reconciliation.
|
||||||
|
- AGENTS/TOOLS are shared across runtimes → diet benefits codex/pi/opencode too; RUNTIME change is claude-only.
|
||||||
|
- p11 accepted as-is (user decision) — not gaming the rubric.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
PR open, paused for maintainer merge ratification (fleet-governing change). `mosaic upgrade` will propagate on merge.
|
||||||
Reference in New Issue
Block a user