Compare commits
10 Commits
f6b901dbca
...
fix/wrappe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e8a9cfa8d | ||
|
|
b90aec2024 | ||
| b8807e60df | |||
| c461380a4a | |||
| 98a771c8f8 | |||
| bd9527c033 | |||
| aa221bf92e | |||
| 799df40f4e | |||
| b79e9f32c6 | |||
| 89d69eb23b |
@@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { AppserviceDaemon } from '../server.js';
|
import { AppserviceDaemon } from '../server.js';
|
||||||
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
||||||
|
|
||||||
|
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
|
||||||
|
|
||||||
const cfg: DaemonConfig = {
|
const cfg: DaemonConfig = {
|
||||||
homeserverUrl: 'https://hs.example',
|
homeserverUrl: 'https://hs.example',
|
||||||
domain: 'hs.example',
|
domain: 'hs.example',
|
||||||
@@ -137,6 +139,240 @@ describe('AppserviceDaemon routing', () => {
|
|||||||
expect(res.status).toBe(405);
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A daemon whose fetch mock backs account_data with a mutable in-test object,
|
||||||
|
// so register/verify/revoke round-trip through the (faked) homeserver.
|
||||||
|
const makeAgentDaemon = () => {
|
||||||
|
const accountData: { value: Record<string, unknown> | null } = { value: null };
|
||||||
|
const fetchMock = vi.fn(async (input: URL | string, init?: RequestInit) => {
|
||||||
|
const url = new URL(String(input));
|
||||||
|
const path = url.pathname;
|
||||||
|
if (path.includes(`/account_data/${AGENTS_TYPE}`)) {
|
||||||
|
if (init?.method === 'PUT') {
|
||||||
|
accountData.value = JSON.parse(String(init.body)) as Record<string, unknown>;
|
||||||
|
return jsonResponse(200, {});
|
||||||
|
}
|
||||||
|
if (accountData.value === null) {
|
||||||
|
return jsonResponse(404, { errcode: 'M_NOT_FOUND', error: 'not found' });
|
||||||
|
}
|
||||||
|
return jsonResponse(200, accountData.value);
|
||||||
|
}
|
||||||
|
if (path.endsWith('/register')) return jsonResponse(200, { user_id: 'whatever' });
|
||||||
|
if (path.includes('/send/m.room.message/')) return jsonResponse(200, { event_id: '$sent' });
|
||||||
|
return jsonResponse(200, {});
|
||||||
|
});
|
||||||
|
const daemon = new AppserviceDaemon(cfg, fetchMock as unknown as typeof fetch, () => {});
|
||||||
|
return { daemon, fetchMock };
|
||||||
|
};
|
||||||
|
|
||||||
|
const registerAgent = async (
|
||||||
|
daemon: AppserviceDaemon,
|
||||||
|
body: Record<string, unknown> = { alias: 'pi0', host: 'web1' },
|
||||||
|
) =>
|
||||||
|
daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/agents',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('host token registers an agent and returns agent_user_id + bridge_token', async () => {
|
||||||
|
const { daemon, fetchMock } = makeAgentDaemon();
|
||||||
|
const res = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
||||||
|
expect(String(res.body.bridge_token).startsWith('magt_')).toBe(true);
|
||||||
|
const registerCall = fetchMock.mock.calls
|
||||||
|
.map((c) => new URL(String(c[0])))
|
||||||
|
.find((u) => u.pathname.endsWith('/register'));
|
||||||
|
expect(registerCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register requires a HOST token (agent token and no token are 403)', async () => {
|
||||||
|
const { daemon } = makeAgentDaemon();
|
||||||
|
const minted = await registerAgent(daemon);
|
||||||
|
const agentToken = String(minted.body.bridge_token);
|
||||||
|
|
||||||
|
const asAgent = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/agents',
|
||||||
|
authorizationHeader: `Bearer ${agentToken}`,
|
||||||
|
body: { alias: 'pi1', host: 'web2' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(asAgent.status).toBe(403);
|
||||||
|
|
||||||
|
const noAuth = await daemon.handle(
|
||||||
|
request({ method: 'POST', path: '/bridge/v1/agents', body: { alias: 'pi1', host: 'web2' } }),
|
||||||
|
);
|
||||||
|
expect(noAuth.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent-scoped token may send as itself but not as another agent', async () => {
|
||||||
|
const { daemon } = makeAgentDaemon();
|
||||||
|
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||||
|
const agentToken = String(minted.body.bridge_token);
|
||||||
|
|
||||||
|
const self = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/messages',
|
||||||
|
authorizationHeader: `Bearer ${agentToken}`,
|
||||||
|
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(self.status).toBe(200);
|
||||||
|
|
||||||
|
const other = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/messages',
|
||||||
|
authorizationHeader: `Bearer ${agentToken}`,
|
||||||
|
body: { room_id: '!r:hs.example', agent: 'pi9-web9', body: 'hi' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(other.status).toBe(403);
|
||||||
|
expect(other.body.error).toBe('token not scoped to this agent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revoked agent token is rejected on messages', async () => {
|
||||||
|
const { daemon } = makeAgentDaemon();
|
||||||
|
const minted = await registerAgent(daemon, { alias: 'pi0', host: 'web1' });
|
||||||
|
const agentToken = String(minted.body.bridge_token);
|
||||||
|
|
||||||
|
const revoke = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/agents/revoke',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
body: { agent_user_id: '@agent-pi0-web1:hs.example' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(revoke.status).toBe(200);
|
||||||
|
expect(revoke.body.revoked).toBe(1);
|
||||||
|
|
||||||
|
const afterRevoke = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'POST',
|
||||||
|
path: '/bridge/v1/messages',
|
||||||
|
authorizationHeader: `Bearer ${agentToken}`,
|
||||||
|
body: { room_id: '!r:hs.example', agent: 'pi0-web1', body: 'hi' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(afterRevoke.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /bridge/v1/agents lists registered agents (host only)', async () => {
|
||||||
|
const { daemon } = makeAgentDaemon();
|
||||||
|
await registerAgent(daemon, { alias: 'pi0', host: 'web1', display_name: 'Pi Zero' });
|
||||||
|
|
||||||
|
const res = await daemon.handle(
|
||||||
|
request({
|
||||||
|
method: 'GET',
|
||||||
|
path: '/bridge/v1/agents',
|
||||||
|
authorizationHeader: 'Bearer bridge-secret',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const agents = res.body.agents as Array<Record<string, unknown>>;
|
||||||
|
expect(agents).toHaveLength(1);
|
||||||
|
expect(agents[0]?.agent_user_id).toBe('@agent-pi0-web1:hs.example');
|
||||||
|
expect(agents[0]?.display_name).toBe('Pi Zero');
|
||||||
|
});
|
||||||
|
|
||||||
it('empty bridge token list denies everything', async () => {
|
it('empty bridge token list denies everything', async () => {
|
||||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
||||||
const res = await daemon.handle(
|
const res = await daemon.handle(
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AgentTokenStore,
|
||||||
AppserviceIntent,
|
AppserviceIntent,
|
||||||
TransactionHandler,
|
TransactionHandler,
|
||||||
validateBridgeMessage,
|
validateBridgeMessage,
|
||||||
validateBridgeTyping,
|
validateBridgeTyping,
|
||||||
|
validateProvisionRoom,
|
||||||
|
validateRegisterAgent,
|
||||||
|
validateRevokeAgent,
|
||||||
} from '@mosaicstack/appservice';
|
} from '@mosaicstack/appservice';
|
||||||
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
||||||
|
|
||||||
@@ -36,6 +40,13 @@ const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a),
|
|||||||
|
|
||||||
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolved identity for an authenticated /bridge/v1/* caller. Host principals
|
||||||
|
* (the agent-comms host daemons) are unrestricted; agent principals are scoped
|
||||||
|
* to a single virtual user and may only act as themselves.
|
||||||
|
*/
|
||||||
|
export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: string } | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
* HTTP-framework-agnostic request router for the mosaic-as daemon: the
|
||||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
* Application Service transactions endpoint (Synapse-facing) plus the
|
||||||
@@ -45,6 +56,7 @@ const TXN_PATH = /^\/_matrix\/app\/v1\/transactions\/([^/]+)$/;
|
|||||||
export class AppserviceDaemon {
|
export class AppserviceDaemon {
|
||||||
readonly intent: AppserviceIntent;
|
readonly intent: AppserviceIntent;
|
||||||
private readonly transactions: TransactionHandler;
|
private readonly transactions: TransactionHandler;
|
||||||
|
private readonly agents: AgentTokenStore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly cfg: DaemonConfig,
|
private readonly cfg: DaemonConfig,
|
||||||
@@ -52,6 +64,7 @@ export class AppserviceDaemon {
|
|||||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
private readonly log: (line: string) => void = (line) => console.log(line),
|
||||||
) {
|
) {
|
||||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
||||||
|
this.agents = new AgentTokenStore(this.intent);
|
||||||
this.transactions = new TransactionHandler({
|
this.transactions = new TransactionHandler({
|
||||||
hsToken: cfg.hsToken,
|
hsToken: cfg.hsToken,
|
||||||
onEvent: (event) => this.onEvent(event),
|
onEvent: (event) => this.onEvent(event),
|
||||||
@@ -68,10 +81,20 @@ export class AppserviceDaemon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
|
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
||||||
if (!authorizationHeader?.startsWith('Bearer ')) return false;
|
* host tokens win (timing-safe compare); otherwise a magt_* bearer is looked
|
||||||
|
* up in the agent token store; anything else is rejected. */
|
||||||
|
private async bridgeAuthorized(
|
||||||
|
authorizationHeader: string | undefined,
|
||||||
|
): Promise<BridgePrincipal> {
|
||||||
|
if (!authorizationHeader?.startsWith('Bearer ')) return null;
|
||||||
const presented = authorizationHeader.slice('Bearer '.length);
|
const presented = authorizationHeader.slice('Bearer '.length);
|
||||||
return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token));
|
if (this.cfg.bridgeTokens.some((token) => safeEqual(presented, token))) {
|
||||||
|
return { kind: 'host' };
|
||||||
|
}
|
||||||
|
const agentUserId = await this.agents.verifyToken(presented);
|
||||||
|
if (agentUserId) return { kind: 'agent', agentUserId };
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||||
@@ -88,12 +111,60 @@ export class AppserviceDaemon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.path.startsWith('/bridge/v1/')) {
|
if (req.path.startsWith('/bridge/v1/')) {
|
||||||
if (!this.bridgeAuthorized(req.authorizationHeader)) {
|
const principal = await this.bridgeAuthorized(req.authorizationHeader);
|
||||||
|
if (!principal) {
|
||||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
if (req.method === 'POST' && req.path === '/bridge/v1/agents') {
|
||||||
|
if (principal.kind !== 'host') {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot register agents' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
validateRegisterAgent(req.body);
|
||||||
|
const { agentUserId, token } = await this.agents.register({
|
||||||
|
alias: req.body.alias,
|
||||||
|
host: req.body.host,
|
||||||
|
displayName: req.body.display_name,
|
||||||
|
});
|
||||||
|
this.log(`registered agent ${agentUserId}`);
|
||||||
|
return { status: 200, body: { agent_user_id: agentUserId, bridge_token: token } };
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && req.path === '/bridge/v1/agents/revoke') {
|
||||||
|
if (principal.kind !== 'host') {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot revoke agents' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
validateRevokeAgent(req.body);
|
||||||
|
const revoked = await this.agents.revoke(req.body.agent_user_id);
|
||||||
|
this.log(`revoked ${revoked} token(s) for ${req.body.agent_user_id}`);
|
||||||
|
return { status: 200, body: { revoked } };
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && req.path === '/bridge/v1/agents') {
|
||||||
|
if (principal.kind !== 'host') {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
body: { errcode: 'M_FORBIDDEN', error: 'agents cannot list agents' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const agents = await this.agents.list();
|
||||||
|
return { status: 200, body: { agents } };
|
||||||
|
}
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
if (req.method === 'POST' && req.path === '/bridge/v1/messages') {
|
||||||
validateBridgeMessage(req.body);
|
validateBridgeMessage(req.body);
|
||||||
|
if (
|
||||||
|
principal.kind === 'agent' &&
|
||||||
|
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||||
|
};
|
||||||
|
}
|
||||||
const eventId = await this.intent.sendAsAgent({
|
const eventId = await this.intent.sendAsAgent({
|
||||||
roomId: req.body.room_id,
|
roomId: req.body.room_id,
|
||||||
agent: req.body.agent,
|
agent: req.body.agent,
|
||||||
@@ -106,9 +177,39 @@ export class AppserviceDaemon {
|
|||||||
}
|
}
|
||||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
||||||
validateBridgeTyping(req.body);
|
validateBridgeTyping(req.body);
|
||||||
|
if (
|
||||||
|
principal.kind === 'agent' &&
|
||||||
|
this.intent.agentUserId(req.body.agent) !== principal.agentUserId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
status: 403,
|
||||||
|
body: { errcode: 'M_FORBIDDEN', error: 'token not scoped to this agent' },
|
||||||
|
};
|
||||||
|
}
|
||||||
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
await this.intent.setTyping(req.body.room_id, req.body.agent, req.body.typing);
|
||||||
return { status: 200, body: {} };
|
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) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
|
this.log(`bridge error ${req.method} ${req.path}: ${message}`);
|
||||||
|
|||||||
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.
|
||||||
173
docs/plans/agent-reflection-loop-PRD.md
Normal file
173
docs/plans/agent-reflection-loop-PRD.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# PRD — Agent Reflection Loop (durable kernel)
|
||||||
|
|
||||||
|
**Issue:** [#544](http://git.mosaicstack.dev/mosaicstack/stack/issues/544)
|
||||||
|
**Source design:** jarvis-brain `docs/planning/AGENT-REFLECTION-LOOP.md` (commit df6576fc, debate-hardened v2)
|
||||||
|
**Status:** in-progress
|
||||||
|
**Scope rule:** Build the **durable kernel** only. The closed calibration/skill-synthesis loop
|
||||||
|
(design §7–§8) is **gated** behind Phase-0 experiments P1/P2/P3 and is explicitly out of scope here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
At end-of-run an agent holds context that never reaches the diff or the "done" message —
|
||||||
|
assumptions, shortcuts, untested paths, the single most-likely way the work is wrong. That context
|
||||||
|
is what a lead/human needs to judge trust, and it evaporates when the session ends. Capture it
|
||||||
|
mechanically as **structured data** (`reflection.v1`), and derive a **review risk-floor** from the
|
||||||
|
change surface so risky diffs are flagged for independent review.
|
||||||
|
|
||||||
|
## 2. Non-goals (gated on Phase-0)
|
||||||
|
|
||||||
|
- No closed calibration loop (predicted-vs-actual scoring as a routing input).
|
||||||
|
- No skill synthesis.
|
||||||
|
- No automated reviewer routing/dispatch. The kernel **writes** the sidecar; pickup is future work.
|
||||||
|
|
||||||
|
## 3. Components & exact placement (main-branch truth)
|
||||||
|
|
||||||
|
| # | Component | Path | Mirror |
|
||||||
|
| --- | -------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------- |
|
||||||
|
| a | Stop hook (capture) | `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh` | `tools/qa/prevent-memory-write.sh` |
|
||||||
|
| a | Hook registration | `packages/mosaic/framework/runtime/claude/settings.json` (`hooks.Stop`) | existing `PreToolUse`/`PostToolUse` |
|
||||||
|
| b | JSON Schema | `packages/macp/src/schemas/reflection.v1.schema.json` | `schemas/task.schema.json` |
|
||||||
|
| b | TS types (zod) + DTO | `packages/types/src/reflection/{index.ts,reflection.dto.ts}` + re-export from `src/index.ts` | `packages/types/src/federation/*` |
|
||||||
|
| c | Diff risk-floor | `packages/macp/src/risk-floor.ts` (+ `__tests__/risk-floor.test.ts`, export from `src/index.ts`) | `packages/macp/src/gate-runner.ts` |
|
||||||
|
| d | Phase-0 scripts | `scripts/analysis/reflect-{git-history,board-history,calibration}.sh` | `scripts/publish-npmjs.sh` |
|
||||||
|
|
||||||
|
**Activation note (deliberate deviation):** the `settings-overlays/` directory has **no merge
|
||||||
|
mechanism** (referenced only in docs), so a hooks overlay there would be inert. The Stop hook is
|
||||||
|
registered in the canonical `runtime/claude/settings.json` — the same file the `mosaic` launcher
|
||||||
|
reflects into `~/.claude/settings.json` (verified byte-identical hooks live there). Still fully
|
||||||
|
vendored in-repo.
|
||||||
|
|
||||||
|
## 4. `reflection.v1` schema (authoritative field list)
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"schema": "reflection.v1", // literal
|
||||||
|
"task_ref": "string", // canonical task ref; kernel derives from REFLECTION_TASK_REF or repo+branch
|
||||||
|
"agent": "string", // persona/runtime id (REFLECTION_AGENT or "unknown")
|
||||||
|
"session_id": "string", // from Stop payload session_id, else "unknown"
|
||||||
|
"timestamp": "string", // ISO-8601 UTC
|
||||||
|
"repo": "string", // repo root basename
|
||||||
|
"confidence": 0.0, // FLOAT [0,1] — SELF-REPORTED (optional; null if not supplied)
|
||||||
|
"most_likely_wrong": {
|
||||||
|
// SELF-REPORTED (optional)
|
||||||
|
"surface": "auth|data|infra|ui|build|test|docs|none",
|
||||||
|
"description": "string",
|
||||||
|
},
|
||||||
|
"known_not_in_diff": "string|null", // SELF-REPORTED: "what I know that isn't visible in the diff"
|
||||||
|
"risk": {
|
||||||
|
// MECHANICAL — from risk-floor
|
||||||
|
"needs_review": true,
|
||||||
|
"score": 0.0, // [0,1]
|
||||||
|
"surface": "auth|data|infra|ui|build|test|docs|none",
|
||||||
|
"reason": "string",
|
||||||
|
},
|
||||||
|
"files_changed": ["string"], // MECHANICAL — git diff name-only
|
||||||
|
"provenance": {
|
||||||
|
"source": "stop-hook",
|
||||||
|
"reflection_attempt": 1,
|
||||||
|
"degraded": false, // true if self-report inputs missing/unreadable
|
||||||
|
"reflection_mode": "off|solo|orchestrated",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mechanical vs self-reported.** A bash Stop hook cannot author the agent's self-assessment. The
|
||||||
|
hook populates the **mechanical** fields deterministically (risk, files_changed, provenance, ids).
|
||||||
|
The **self-reported** fields are read from an optional agent-supplied input file
|
||||||
|
(`$REFLECTION_INPUT`, default `<repo>/.mosaic/reflection-input.json`) and merged if present;
|
||||||
|
absent/unreadable → those fields null and `provenance.degraded=true`. This realizes the design's
|
||||||
|
"hook is a pre-seed, not the asker" (§4).
|
||||||
|
|
||||||
|
## 5. Stop hook behavior (fail-closed, non-blocking)
|
||||||
|
|
||||||
|
1. Read Stop payload JSON from stdin.
|
||||||
|
2. **Fail-closed:** if `REFLECTION_MODE` is unset or `off` → `exit 0` immediately (strict no-op). This
|
||||||
|
is the global-registration safety guarantee.
|
||||||
|
3. **Sentinel guard:** if `<sidecar>.lock` exists → `exit 0` (prevents re-fire loops). Create it,
|
||||||
|
`trap` cleanup.
|
||||||
|
4. Determine output dir: `$REFLECTION_DIR` else `<repo>/.mosaic/reflections/`. `mkdir -p`.
|
||||||
|
5. Compute mechanical fields: `git diff --name-only` (HEAD + staged + worktree, best-effort),
|
||||||
|
call risk-floor logic (inline bash port OR `node -e` into `@mosaicstack/macp` — see §6), session
|
||||||
|
ids from payload + env.
|
||||||
|
6. Merge optional `$REFLECTION_INPUT` self-report if readable JSON.
|
||||||
|
7. Write `reflection.v1` to a temp file, `mv` (atomic) to `<dir>/<session>-<ts>.reflection.json`.
|
||||||
|
8. Always `exit 0`. **Never** emit a `decision` field (Stop hooks are observational).
|
||||||
|
|
||||||
|
Hook must never fail the session: wrap risky steps, default to `degraded:true` on any error, exit 0.
|
||||||
|
|
||||||
|
## 6. Risk-floor (`packages/macp/src/risk-floor.ts`)
|
||||||
|
|
||||||
|
Pure, deterministic, no IO. Single source of truth for the verdict; the hook calls it via
|
||||||
|
`node --input-type=module -e` (importing the built package) **or**, to avoid a node dependency in the
|
||||||
|
hook path, the hook ports the same surface table. **Decision:** implement the canonical logic in TS
|
||||||
|
(tested), and have the hook shell out to node when available, else fall back to a minimal inline
|
||||||
|
classifier flagged `degraded:true`. (Keep the TS the authority; the inline path is a safety net.)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type ReviewSurface = 'auth' | 'data' | 'infra' | 'ui' | 'build' | 'test' | 'docs' | 'none';
|
||||||
|
export interface RiskFloorInput {
|
||||||
|
filesChanged: string[];
|
||||||
|
insertions?: number;
|
||||||
|
deletions?: number;
|
||||||
|
}
|
||||||
|
export interface RiskFloorVerdict {
|
||||||
|
needs_review: boolean;
|
||||||
|
score: number;
|
||||||
|
surface: ReviewSurface;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
export function evaluateRiskFloor(input: RiskFloorInput): RiskFloorVerdict;
|
||||||
|
```
|
||||||
|
|
||||||
|
Surface classification by path regex (first match wins, highest-risk surface dominates):
|
||||||
|
|
||||||
|
- `auth` (weight 1.0): `auth`, `login`, `session`, `token`, `permission`, `rbac`, `credential`, `secret`
|
||||||
|
- `data` (0.9): `migration`, `prisma`, `schema`, `\.sql`, `entity`, `repository`, `seed`
|
||||||
|
- `infra` (0.85): `docker`, `\.woodpecker`, `compose`, `traefik`, `deploy`, `helm`, `k8s`, `terraform`
|
||||||
|
- `build` (0.6): `package.json`, `tsconfig`, `turbo.json`, `pnpm-`, `\.config\.`, `eslint`, `vite`
|
||||||
|
- `ui` (0.4): `\.tsx`, `\.css`, `components/`, `apps/web/`
|
||||||
|
- `test` (0.2): `\.spec\.`, `\.test\.`, `__tests__/`
|
||||||
|
- `docs` (0.1): `\.md`, `docs/`
|
||||||
|
- `none` (0.0): anything else
|
||||||
|
|
||||||
|
`needs_review = score >= THRESHOLD` (default `0.5`, overridable). `reason` names the files+surface
|
||||||
|
that tripped it. **Subordinate to CI:** this is a _floor_ (minimum review requirement) only;
|
||||||
|
consumers MUST treat CI/tests as authoritative above the floor (precedence: CI/tests > human merge >
|
||||||
|
reviewer verdict > self-reflection). Documented in the module header.
|
||||||
|
|
||||||
|
## 7. Phase-0 experiment scripts (`scripts/analysis/`)
|
||||||
|
|
||||||
|
Offline, no-infra bash. Each script: `#!/usr/bin/env bash`, `set -euo pipefail`, header `Usage:` +
|
||||||
|
`Requirements:`, flag parsing, **prints its pre-registered kill condition**, emits structured
|
||||||
|
(JSON/markdown) output. They are harnesses + rubrics — real corpora are wired later.
|
||||||
|
|
||||||
|
- `reflect-git-history.sh` (**P2** — only-self-reflection bucket): scan `git log` for failure signals
|
||||||
|
(reverts, `fix:`/`hotfix` shortly after a feature merge) over a window; classify each by which gate
|
||||||
|
would catch it (CI / human-review / only-self-reflection) via a pre-registered heuristic; tally.
|
||||||
|
Kill: bucket-3 near-empty → no §7/§8.
|
||||||
|
- `reflect-board-history.sh` (**P3** — outcome detectability): given a task/board export (or the
|
||||||
|
git history of `data/` task files), measure the fraction of completed tasks with a
|
||||||
|
machine-detectable correct/wrong signal within 30 days. Kill: base-rate < 20% → caveat-notes only.
|
||||||
|
- `reflect-calibration.sh` (**P1** — confidence signal): consume a labeled corpus (JSONL of
|
||||||
|
`{confidence, correct}`), compute discrimination (AUC/lift) on the self-rated-high subset, print
|
||||||
|
the metric vs the pre-registered chance threshold. Kill: AUC ≈ chance on the high subset → no §7/§8.
|
||||||
|
|
||||||
|
## 8. CI / quality gates
|
||||||
|
|
||||||
|
- TS packages: `pnpm typecheck` (tsc --noEmit), `pnpm lint` (eslint), `pnpm format:check`
|
||||||
|
(prettier), `pnpm test` (vitest). ESM, NodeNext, `.js` import specifiers, `*.dto.ts` at boundaries.
|
||||||
|
- New files in existing packages need no CI config change; add ≥1 vitest spec per new TS module.
|
||||||
|
- Bash scripts/hook are dev/runtime tooling, not CI-built; keep them `shellcheck`-clean.
|
||||||
|
|
||||||
|
## 9. Acceptance criteria
|
||||||
|
|
||||||
|
1. `REFLECTION_MODE` unset → hook is a strict no-op (`exit 0`, no file written). **(test)**
|
||||||
|
2. With `REFLECTION_MODE=solo`, hook writes a schema-valid `reflection.v1` with correct mechanical
|
||||||
|
fields; self-report merged when `$REFLECTION_INPUT` present, `degraded:true` when absent.
|
||||||
|
3. `evaluateRiskFloor` deterministic across all surfaces; unit-tested incl. auth/data/infra → review,
|
||||||
|
docs/test → no review, empty → `none`/no review.
|
||||||
|
4. `reflection.v1` zod type + JSON Schema agree; sidecar validates against the schema.
|
||||||
|
5. Phase-0 scripts run offline, print kill conditions, emit structured output, shellcheck-clean.
|
||||||
|
6. `pnpm typecheck && pnpm lint && pnpm format:check && pnpm test` green; independent review passed.
|
||||||
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.
|
||||||
55
docs/scratchpads/544-agent-reflection-loop.md
Normal file
55
docs/scratchpads/544-agent-reflection-loop.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Scratchpad — #544 Agent Reflection Loop (durable kernel)
|
||||||
|
|
||||||
|
**Started:** 2026-06-16 · **Branch:** `feat/agent-reflection-loop` · **Base:** `main` @ c461380
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Bake the durable kernel of the agent reflection loop into the Mosaic Stack
|
||||||
|
monorepo through full delivery gates. Kernel only; closed loop (§7–§8) gated on
|
||||||
|
Phase-0. Authoritative spec: `docs/plans/agent-reflection-loop-PRD.md`. Task
|
||||||
|
breakdown: `docs/tasks/544-agent-reflection-loop.md`.
|
||||||
|
|
||||||
|
## Timeline / decisions
|
||||||
|
|
||||||
|
- Mapped house style against `main` truth (the earlier recon had mapped a dirty
|
||||||
|
feature branch and returned non-existent paths; re-cloned `main` clean).
|
||||||
|
- macp uses co-located `*.spec.ts`; types uses `src/<mod>/{*.ts, *.dto.ts, __tests__/*.spec.ts}`.
|
||||||
|
- zod v4 + class-validator/class-transformer present in `@mosaicstack/types`;
|
||||||
|
`packages/types/tsconfig.json` enables `experimentalDecorators`/`emitDecoratorMetadata`.
|
||||||
|
- **Gotcha (fixed):** `class-transformer`'s `@Type` calls `Reflect.getMetadata`
|
||||||
|
at module-load time; the types vitest env has no `reflect-metadata`, so any test
|
||||||
|
importing the reflection barrel crashed on import. `chat.dto.ts` avoids this by
|
||||||
|
using class-validator only. Fix: dropped `@Type`/`@ValidateNested` from the DTO;
|
||||||
|
zod owns deep nested validation.
|
||||||
|
- **Gotcha (fixed):** Stop hook `EXIT` trap referenced a `main`-local `lock` →
|
||||||
|
`unbound variable` under `set -u` at exit. Promoted to a global `LOCKFILE`.
|
||||||
|
- **Gotcha (fixed):** the hook's own lock + `.mosaic/` scratch leaked into
|
||||||
|
`files_changed`. Excluded `^\.mosaic/` from the change-surface scan.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- macp: typecheck OK, lint OK, **88 tests pass** (15 new risk-floor).
|
||||||
|
- types: typecheck OK, lint OK, **64 tests pass** (10 new reflection).
|
||||||
|
- Root: `pnpm typecheck` (41 tasks), `pnpm lint` (23), `pnpm format:check`, `pnpm build` (23) — all green.
|
||||||
|
- Stop hook smoke (throwaway git repo): TEST1 no-op (mode unset, 0 files);
|
||||||
|
TEST2 solo degraded, `.mosaic/` excluded, auth→needs_review; TEST3 self-report
|
||||||
|
merged, degraded=false; TEST4 lock suppresses re-fire. All pass, always exit 0.
|
||||||
|
- shellcheck clean: hook + `reflect-{git-history,board-history,calibration}.sh`.
|
||||||
|
- Phase-0 smoke: P2 on this repo (142 failures classified), P1 AUC=0.875 on a
|
||||||
|
synthetic fixture, P3 base-rate on a synthetic board — all emit structured output
|
||||||
|
- kill conditions.
|
||||||
|
|
||||||
|
## Open risks / follow-ups
|
||||||
|
|
||||||
|
- Full `pnpm test` (DB-bound packages) validated via CI's postgres service, not
|
||||||
|
locally; affected packages (macp, types) are DB-independent and green here.
|
||||||
|
- sequential-thinking MCP was registered mid-session (effective next session);
|
||||||
|
this session compensated with the written PRD as the planning artifact.
|
||||||
|
- Phase-0 corpora are not yet wired — scripts are harnesses + pre-registered
|
||||||
|
rubrics (P1/P2/P3 tasks tracked in jarvis-brain `agent-reflection-loop` project).
|
||||||
|
|
||||||
|
## Gate status
|
||||||
|
|
||||||
|
- [x] PRD authored · [x] issue #544 created + linked · [x] code + tests
|
||||||
|
- [x] local gates green · [ ] independent code review · [ ] PR opened
|
||||||
|
- [ ] CI terminal green · [ ] merged to main · [ ] issue closed
|
||||||
67
docs/tasks/544-agent-reflection-loop.md
Normal file
67
docs/tasks/544-agent-reflection-loop.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 544: Agent Reflection Loop — durable kernel
|
||||||
|
|
||||||
|
**Issue:** [#544](http://git.mosaicstack.dev/mosaicstack/stack/issues/544)
|
||||||
|
**PRD:** [`docs/plans/agent-reflection-loop-PRD.md`](../plans/agent-reflection-loop-PRD.md)
|
||||||
|
**Branch:** `feat/agent-reflection-loop`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Build the **durable kernel** of the agent reflection loop: passive end-of-run
|
||||||
|
capture of the doer's end-state as structured `reflection.v1` data, plus a
|
||||||
|
deterministic diff **review risk-floor**. The closed calibration / skill-synthesis
|
||||||
|
loop (design §7–§8) stays **gated** behind Phase-0 experiments P1/P2/P3 and is
|
||||||
|
explicitly out of scope here. Source design: jarvis-brain
|
||||||
|
`docs/planning/AGENT-REFLECTION-LOOP.md` (debate-hardened v2).
|
||||||
|
|
||||||
|
Scope rule, non-goals, the full `reflection.v1` field list, and acceptance
|
||||||
|
criteria live in the PRD. This file is the task breakdown + status.
|
||||||
|
|
||||||
|
## Work items
|
||||||
|
|
||||||
|
| # | Item | Path | Status |
|
||||||
|
| --- | ----------------------------------------------------- | --------------------------------------------------------- | ------ |
|
||||||
|
| 1 | Diff risk-floor (pure, deterministic) + unit tests | `packages/macp/src/risk-floor.ts`, `risk-floor.spec.ts` | done |
|
||||||
|
| 2 | `reflection.v1` JSON Schema (documented contract) | `packages/macp/src/schemas/reflection.v1.schema.json` | done |
|
||||||
|
| 3 | `reflection.v1` zod schemas + self-report DTO + tests | `packages/types/src/reflection/*` | done |
|
||||||
|
| 4 | Stop hook (fail-closed capture) | `packages/mosaic/framework/tools/qa/reflect-stop-hook.sh` | done |
|
||||||
|
| 5 | Hook registration (`hooks.Stop`) | `packages/mosaic/framework/runtime/claude/settings.json` | done |
|
||||||
|
| 6 | Phase-0 experiment harnesses (P1/P2/P3) | `scripts/analysis/reflect-*.sh` | done |
|
||||||
|
|
||||||
|
## Design decisions (this implementation)
|
||||||
|
|
||||||
|
- **Mechanical vs self-reported split.** A bash Stop hook cannot author the
|
||||||
|
agent's self-assessment, so it writes the mechanical fields (risk-floor verdict,
|
||||||
|
`files_changed`, ids, provenance) and merges an optional agent-supplied
|
||||||
|
`$REFLECTION_INPUT` self-report; absent/unreadable ⇒ those fields `null` and
|
||||||
|
`provenance.degraded = true`.
|
||||||
|
- **Risk-floor authority.** `evaluateRiskFloor` (TS, tested) is the source of
|
||||||
|
truth. The hook ports the same surface table inline to avoid a node/build
|
||||||
|
dependency on the hook path; the two are documented as kept in sync.
|
||||||
|
- **Hook registration deviation.** `settings-overlays/` has no merge mechanism
|
||||||
|
(docs-only), so a hooks overlay there would be inert. The Stop hook is
|
||||||
|
registered in the canonical `runtime/claude/settings.json` — the same file the
|
||||||
|
`mosaic` launcher reflects into `~/.claude/settings.json`. Still vendored in-repo.
|
||||||
|
- **DTO without class-transformer.** `reflection.dto.ts` uses class-validator only
|
||||||
|
(no `@Type`), matching `chat.dto.ts`, so the module imports without a
|
||||||
|
`reflect-metadata` shim in the types-package test env. Deep nested validation is
|
||||||
|
owned by the zod `ReflectionSelfReportSchema` (the runtime authority the hook uses).
|
||||||
|
- **`.mosaic/` excluded** from the change surface — it is agent scratch
|
||||||
|
(reflections, locks, self-report input), not part of the diff under review.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/macp test` → 88 passed (15 new risk-floor).
|
||||||
|
- `pnpm --filter @mosaicstack/types test` → 64 passed (10 new reflection).
|
||||||
|
- Root `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` → green.
|
||||||
|
- Stop hook smoke: fail-closed no-op (mode unset), solo capture (degraded),
|
||||||
|
self-report merge (degraded=false), re-fire lock guard — all pass.
|
||||||
|
- All bash (hook + 3 Phase-0 scripts) shellcheck-clean; Phase-0 scripts emit
|
||||||
|
structured JSON/markdown and print their pre-registered kill conditions.
|
||||||
|
|
||||||
|
## Activation (post-merge, deployment concern — not a blocker)
|
||||||
|
|
||||||
|
The Stop hook only activates when a launcher/profile sets
|
||||||
|
`REFLECTION_MODE=solo|orchestrated`; unset/`off` is a strict no-op, so global
|
||||||
|
registration is safe. `framework/install.sh` rsyncs the hook into
|
||||||
|
`~/.config/mosaic/tools/qa/`, and the `mosaic` launcher reflects the updated
|
||||||
|
`settings.json` (`hooks.Stop`) into `~/.claude/settings.json`.
|
||||||
@@ -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.
|
||||||
|
|||||||
116
packages/appservice/src/__tests__/agent-store.test.ts
Normal file
116
packages/appservice/src/__tests__/agent-store.test.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { AGENTS_ACCOUNT_DATA_TYPE, AgentTokenStore } from '../agent-store.js';
|
||||||
|
import type { AppserviceIntent } from '../intent.js';
|
||||||
|
|
||||||
|
/** Fake intent: in-memory account_data, no-op user provisioning. Only the
|
||||||
|
* surface AgentTokenStore touches is implemented. */
|
||||||
|
const makeFakeIntent = () => {
|
||||||
|
const store: Record<string, Record<string, unknown>> = {};
|
||||||
|
const fake = {
|
||||||
|
domain: 'hs.example',
|
||||||
|
getSenderAccountData: async (type: string): Promise<Record<string, unknown> | null> =>
|
||||||
|
store[type] ?? null,
|
||||||
|
setSenderAccountData: async (type: string, content: Record<string, unknown>): Promise<void> => {
|
||||||
|
store[type] = structuredClone(content);
|
||||||
|
},
|
||||||
|
ensureRegistered: async (agent: string): Promise<string> => `@agent-${agent}:hs.example`,
|
||||||
|
setDisplayName: async (): Promise<void> => {},
|
||||||
|
};
|
||||||
|
return { intent: fake as unknown as AppserviceIntent, store };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AgentTokenStore', () => {
|
||||||
|
it('mints a magt_ token and stores only its sha256 (never plaintext)', async () => {
|
||||||
|
const { intent, store } = makeFakeIntent();
|
||||||
|
const s = new AgentTokenStore(intent);
|
||||||
|
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
|
||||||
|
|
||||||
|
expect(agentUserId).toBe('@agent-pi0-web1:hs.example');
|
||||||
|
expect(token.startsWith('magt_')).toBe(true);
|
||||||
|
|
||||||
|
const raw = JSON.stringify(store[AGENTS_ACCOUNT_DATA_TYPE]);
|
||||||
|
expect(raw).not.toContain(token);
|
||||||
|
// The stored hash is sha256hex(token), 64 hex chars.
|
||||||
|
const { createHash } = await import('node:crypto');
|
||||||
|
const hash = createHash('sha256').update(token).digest('hex');
|
||||||
|
expect(raw).toContain(hash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifyToken returns the agentUserId for a fresh token, null otherwise', async () => {
|
||||||
|
const { intent } = makeFakeIntent();
|
||||||
|
const s = new AgentTokenStore(intent);
|
||||||
|
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
|
||||||
|
|
||||||
|
expect(await s.verifyToken(token)).toBe(agentUserId);
|
||||||
|
expect(await s.verifyToken('magt_garbage')).toBeNull();
|
||||||
|
expect(await s.verifyToken('not-a-token')).toBeNull();
|
||||||
|
expect(await s.verifyToken('')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revoke invalidates tokens, returns count, and hides agent from list', async () => {
|
||||||
|
const { intent } = makeFakeIntent();
|
||||||
|
const s = new AgentTokenStore(intent);
|
||||||
|
const { agentUserId, token } = await s.register({ alias: 'pi0', host: 'web1' });
|
||||||
|
|
||||||
|
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
|
||||||
|
|
||||||
|
const count = await s.revoke(agentUserId);
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(await s.verifyToken(token)).toBeNull();
|
||||||
|
expect((await s.list()).map((a) => a.agent_user_id)).not.toContain(agentUserId);
|
||||||
|
|
||||||
|
// Idempotent on unknown / already-revoked.
|
||||||
|
expect(await s.revoke(agentUserId)).toBe(0);
|
||||||
|
expect(await s.revoke('@agent-nope:hs.example')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-register after revoke yields a working token and the agent reappears', async () => {
|
||||||
|
const { intent } = makeFakeIntent();
|
||||||
|
const s = new AgentTokenStore(intent);
|
||||||
|
const { agentUserId, token: t1 } = await s.register({ alias: 'pi0', host: 'web1' });
|
||||||
|
await s.revoke(agentUserId);
|
||||||
|
|
||||||
|
const { token: t2 } = await s.register({ alias: 'pi0', host: 'web1' });
|
||||||
|
expect(await s.verifyToken(t1)).toBeNull();
|
||||||
|
expect(await s.verifyToken(t2)).toBe(agentUserId);
|
||||||
|
expect((await s.list()).map((a) => a.agent_user_id)).toContain(agentUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('agent A token never verifies as agent B', async () => {
|
||||||
|
const { intent } = makeFakeIntent();
|
||||||
|
const s = new AgentTokenStore(intent);
|
||||||
|
const a = await s.register({ alias: 'pi0', host: 'web1' });
|
||||||
|
const b = await s.register({ alias: 'pi1', host: 'web2' });
|
||||||
|
|
||||||
|
expect(await s.verifyToken(a.token)).toBe(a.agentUserId);
|
||||||
|
expect(await s.verifyToken(b.token)).toBe(b.agentUserId);
|
||||||
|
expect(a.agentUserId).not.toBe(b.agentUserId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an ambiguous re-registration that collides on one Matrix id', async () => {
|
||||||
|
const { intent } = makeFakeIntent();
|
||||||
|
const s = new AgentTokenStore(intent);
|
||||||
|
// alias="a-b",host="c" and alias="a",host="b-c" both -> @agent-a-b-c.
|
||||||
|
const first = await s.register({ alias: 'a-b', host: 'c' });
|
||||||
|
expect(first.agentUserId).toBe('@agent-a-b-c:hs.example');
|
||||||
|
|
||||||
|
await expect(s.register({ alias: 'a', host: 'b-c' })).rejects.toThrow(/collision/);
|
||||||
|
|
||||||
|
// The original registration is untouched: still one active token, correct pair.
|
||||||
|
expect(await s.verifyToken(first.token)).toBe(first.agentUserId);
|
||||||
|
const summary = (await s.list()).find((x) => x.agent_user_id === first.agentUserId);
|
||||||
|
expect(summary?.alias).toBe('a-b');
|
||||||
|
expect(summary?.host).toBe('c');
|
||||||
|
expect(summary?.active_token_count).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('display_name is stored and surfaced in list', async () => {
|
||||||
|
const { intent } = makeFakeIntent();
|
||||||
|
const s = new AgentTokenStore(intent);
|
||||||
|
await s.register({ alias: 'pi0', host: 'web1', displayName: 'Pi Zero' });
|
||||||
|
const summary = (await s.list())[0];
|
||||||
|
expect(summary?.display_name).toBe('Pi Zero');
|
||||||
|
expect(summary?.active_token_count).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
63
packages/appservice/src/agent-registry.dto.ts
Normal file
63
packages/appservice/src/agent-registry.dto.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/** DTOs for agent registration + scoped/revocable bridge tokens (US-007). */
|
||||||
|
|
||||||
|
export interface RegisterAgentDto {
|
||||||
|
/** Agent alias slug, e.g. "pi0". Combined with host into the agent slug. */
|
||||||
|
alias: string;
|
||||||
|
/** Host slug, e.g. "web1". Combined with alias into the agent slug. */
|
||||||
|
host: string;
|
||||||
|
display_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevokeAgentDto {
|
||||||
|
agent_user_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterAgentResponse {
|
||||||
|
agent_user_id: string;
|
||||||
|
bridge_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSummary {
|
||||||
|
agent_user_id: string;
|
||||||
|
alias: string;
|
||||||
|
host: string;
|
||||||
|
display_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
active_token_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SLUG_RE = /^[a-z0-9][a-z0-9_.-]*$/;
|
||||||
|
|
||||||
|
/** Combined agent slug, e.g. alias="pi0", host="web1" -> "pi0-web1". */
|
||||||
|
export function agentSlug(alias: string, host: string): string {
|
||||||
|
return `${alias}-${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertSlug = (value: unknown, field: string): void => {
|
||||||
|
if (typeof value !== 'string' || value.length === 0 || !SLUG_RE.test(value)) {
|
||||||
|
throw new Error(`${field} must match [a-z0-9][a-z0-9_.-]* (lowercase, non-empty)`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateRegisterAgent(input: unknown): asserts input is RegisterAgentDto {
|
||||||
|
const o = input as Partial<RegisterAgentDto> | null | undefined;
|
||||||
|
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
|
||||||
|
assertSlug(o.alias, 'alias');
|
||||||
|
assertSlug(o.host, 'host');
|
||||||
|
if (o.display_name !== undefined) {
|
||||||
|
if (typeof o.display_name !== 'string' || o.display_name.length === 0) {
|
||||||
|
throw new Error('display_name must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (o.display_name.length > 100) {
|
||||||
|
throw new Error('display_name must be at most 100 chars');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRevokeAgent(input: unknown): asserts input is RevokeAgentDto {
|
||||||
|
const o = input as Partial<RevokeAgentDto> | null | undefined;
|
||||||
|
if (!o || typeof o !== 'object') throw new Error('payload must be an object');
|
||||||
|
if (typeof o.agent_user_id !== 'string' || !o.agent_user_id.startsWith('@')) {
|
||||||
|
throw new Error('agent_user_id must be a Matrix user id');
|
||||||
|
}
|
||||||
|
}
|
||||||
160
packages/appservice/src/agent-store.ts
Normal file
160
packages/appservice/src/agent-store.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
import { agentSlug } from './agent-registry.dto.js';
|
||||||
|
import type { AgentSummary } from './agent-registry.dto.js';
|
||||||
|
import type { AppserviceIntent } from './intent.js';
|
||||||
|
|
||||||
|
/** account_data type holding the agent registry on the AS sender user. */
|
||||||
|
export const AGENTS_ACCOUNT_DATA_TYPE = 'org.uscllc.mosaic_as.agents';
|
||||||
|
|
||||||
|
const TOKEN_PREFIX = 'magt_';
|
||||||
|
|
||||||
|
interface StoredAgent {
|
||||||
|
alias: string;
|
||||||
|
host: string;
|
||||||
|
display_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
/** sha256hex of each active token. Plaintext tokens are NEVER stored. */
|
||||||
|
token_hashes: string[];
|
||||||
|
revoked_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AgentRegistry {
|
||||||
|
agents: Record<string, StoredAgent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sha256hex = (value: string): string => createHash('sha256').update(value).digest('hex');
|
||||||
|
|
||||||
|
const mintToken = (): string => `${TOKEN_PREFIX}${randomBytes(32).toString('base64url')}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists scoped/revocable bridge tokens for agent virtual users in Matrix
|
||||||
|
* account_data on the AS sender user (no new infra; survives restart).
|
||||||
|
*
|
||||||
|
* Tokens are stored only as sha256 hashes (the high-entropy `magt_` token makes
|
||||||
|
* plain sha256 safe — no salt/KDF needed since brute force is infeasible).
|
||||||
|
*
|
||||||
|
* KNOWN v1 LIMIT: Synapse caps a single account_data object (default
|
||||||
|
* max_account_data_size, ~100KB). Each agent + hash entry is small, so this
|
||||||
|
* supports thousands of agents, but a very large fleet would eventually need a
|
||||||
|
* dedicated store. Revoked agents with no active tokens are pruned of hashes
|
||||||
|
* (kept as tombstones) to bound growth.
|
||||||
|
*/
|
||||||
|
export class AgentTokenStore {
|
||||||
|
constructor(private readonly intent: AppserviceIntent) {}
|
||||||
|
|
||||||
|
/** Read the registry fresh from account_data (low-frequency ops favor
|
||||||
|
* correctness over caching; verifyToken/list also read fresh). */
|
||||||
|
private async read(): Promise<AgentRegistry> {
|
||||||
|
const data = await this.intent.getSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE);
|
||||||
|
const agents = data?.agents;
|
||||||
|
if (agents && typeof agents === 'object') {
|
||||||
|
return { agents: agents as Record<string, StoredAgent> };
|
||||||
|
}
|
||||||
|
return { agents: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async write(registry: AgentRegistry): Promise<void> {
|
||||||
|
await this.intent.setSenderAccountData(AGENTS_ACCOUNT_DATA_TYPE, {
|
||||||
|
agents: registry.agents,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the virtual user exists, mint a fresh token, store its hash, and
|
||||||
|
* return the plaintext token ONCE. Clears any prior revocation. */
|
||||||
|
async register(opts: {
|
||||||
|
alias: string;
|
||||||
|
host: string;
|
||||||
|
displayName?: string;
|
||||||
|
}): Promise<{ agentUserId: string; token: string }> {
|
||||||
|
const slug = agentSlug(opts.alias, opts.host);
|
||||||
|
const agentUserId = await this.intent.ensureRegistered(slug);
|
||||||
|
if (opts.displayName !== undefined) {
|
||||||
|
await this.intent.setDisplayName(slug, opts.displayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = mintToken();
|
||||||
|
const hash = sha256hex(token);
|
||||||
|
|
||||||
|
const registry = await this.read();
|
||||||
|
const existing = registry.agents[agentUserId];
|
||||||
|
if (existing) {
|
||||||
|
// The agent slug `<alias>-<host>` joins with a `-`, which is also a legal
|
||||||
|
// slug char, so distinct pairs can collide on one Matrix id (e.g.
|
||||||
|
// a/b-c and a-b/c both -> @agent-a-b-c). They ARE the same Matrix user,
|
||||||
|
// but silently overwriting the stored alias/host of a different pair
|
||||||
|
// would conflate two logical agents into one token bucket. Reject the
|
||||||
|
// ambiguous re-registration instead of overwriting.
|
||||||
|
if (existing.alias !== opts.alias || existing.host !== opts.host) {
|
||||||
|
throw new Error(
|
||||||
|
`agent id collision: ${agentUserId} already registered as ` +
|
||||||
|
`${existing.alias}/${existing.host}, refusing ${opts.alias}/${opts.host}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (opts.displayName !== undefined) existing.display_name = opts.displayName;
|
||||||
|
existing.token_hashes = [...existing.token_hashes, hash];
|
||||||
|
delete existing.revoked_at;
|
||||||
|
} else {
|
||||||
|
registry.agents[agentUserId] = {
|
||||||
|
alias: opts.alias,
|
||||||
|
host: opts.host,
|
||||||
|
...(opts.displayName !== undefined ? { display_name: opts.displayName } : {}),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
token_hashes: [hash],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.write(registry);
|
||||||
|
return { agentUserId, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return the agentUserId bound to an active (non-revoked) token, else null.
|
||||||
|
* Constant-time hash comparison; no early-out on match. */
|
||||||
|
async verifyToken(token: string): Promise<string | null> {
|
||||||
|
if (!token.startsWith(TOKEN_PREFIX)) return null;
|
||||||
|
const presented = Buffer.from(sha256hex(token), 'hex');
|
||||||
|
|
||||||
|
const registry = await this.read();
|
||||||
|
let matched: string | null = null;
|
||||||
|
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
|
||||||
|
if (agent.revoked_at) continue;
|
||||||
|
for (const stored of agent.token_hashes) {
|
||||||
|
const candidate = Buffer.from(stored, 'hex');
|
||||||
|
if (candidate.length === presented.length && timingSafeEqual(candidate, presented)) {
|
||||||
|
// No early break: keep scanning so timing does not reveal match position.
|
||||||
|
matched = agentUserId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke all active tokens for an agent. Idempotent; returns count revoked. */
|
||||||
|
async revoke(agentUserId: string): Promise<number> {
|
||||||
|
const registry = await this.read();
|
||||||
|
const agent = registry.agents[agentUserId];
|
||||||
|
if (!agent) return 0;
|
||||||
|
const count = agent.token_hashes.length;
|
||||||
|
agent.token_hashes = [];
|
||||||
|
agent.revoked_at = new Date().toISOString();
|
||||||
|
await this.write(registry);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List agents with at least one active token (never advertise revoked/phantom). */
|
||||||
|
async list(): Promise<AgentSummary[]> {
|
||||||
|
const registry = await this.read();
|
||||||
|
const out: AgentSummary[] = [];
|
||||||
|
for (const [agentUserId, agent] of Object.entries(registry.agents)) {
|
||||||
|
if (agent.revoked_at || agent.token_hashes.length === 0) continue;
|
||||||
|
out.push({
|
||||||
|
agent_user_id: agentUserId,
|
||||||
|
alias: agent.alias,
|
||||||
|
host: agent.host,
|
||||||
|
...(agent.display_name !== undefined ? { display_name: agent.display_name } : {}),
|
||||||
|
created_at: agent.created_at,
|
||||||
|
active_token_count: agent.token_hashes.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,20 @@ 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 { agentSlug, validateRegisterAgent, validateRevokeAgent } from './agent-registry.dto.js';
|
||||||
|
export type {
|
||||||
|
RegisterAgentDto,
|
||||||
|
RevokeAgentDto,
|
||||||
|
RegisterAgentResponse,
|
||||||
|
AgentSummary,
|
||||||
|
} from './agent-registry.dto.js';
|
||||||
|
export { AgentTokenStore, AGENTS_ACCOUNT_DATA_TYPE } from './agent-store.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);
|
||||||
@@ -181,4 +233,30 @@ export class AppserviceIntent {
|
|||||||
body: { displayname: displayName },
|
body: { displayname: displayName },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read an account_data object on the AS sender user. Returns null when the
|
||||||
|
* key has never been written (M_NOT_FOUND), so callers can treat that as an
|
||||||
|
* empty store; any other error propagates. */
|
||||||
|
async getSenderAccountData(type: string): Promise<Record<string, unknown> | null> {
|
||||||
|
const user = encodeURIComponent(this.senderUserId);
|
||||||
|
const key = encodeURIComponent(type);
|
||||||
|
try {
|
||||||
|
return await this.request('GET', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
|
||||||
|
userId: this.senderUserId,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof MatrixApiError && err.errcode === 'M_NOT_FOUND') return null;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write an account_data object on the AS sender user. */
|
||||||
|
async setSenderAccountData(type: string, content: Record<string, unknown>): Promise<void> {
|
||||||
|
const user = encodeURIComponent(this.senderUserId);
|
||||||
|
const key = encodeURIComponent(type);
|
||||||
|
await this.request('PUT', `/_matrix/client/v3/user/${user}/account_data/${key}`, {
|
||||||
|
userId: this.senderUserId,
|
||||||
|
body: content,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './g
|
|||||||
|
|
||||||
export type { NormalizedGate } from './gate-runner.js';
|
export type { NormalizedGate } from './gate-runner.js';
|
||||||
|
|
||||||
|
// Risk-floor (agent reflection loop — diff review classifier)
|
||||||
|
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
|
||||||
|
|
||||||
|
export type { ReviewSurface, RiskFloorInput, RiskFloorVerdict } from './risk-floor.js';
|
||||||
|
|
||||||
// Event emitter
|
// Event emitter
|
||||||
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
||||||
|
|
||||||
|
|||||||
87
packages/macp/src/risk-floor.spec.ts
Normal file
87
packages/macp/src/risk-floor.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { DEFAULT_RISK_THRESHOLD, evaluateRiskFloor, type ReviewSurface } from './risk-floor.js';
|
||||||
|
|
||||||
|
describe('evaluateRiskFloor', () => {
|
||||||
|
it('returns a no-review "none" verdict for an empty diff', () => {
|
||||||
|
const v = evaluateRiskFloor({ filesChanged: [] });
|
||||||
|
expect(v).toEqual({
|
||||||
|
needs_review: false,
|
||||||
|
score: 0,
|
||||||
|
surface: 'none',
|
||||||
|
reason: 'no files changed',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty/non-string entries', () => {
|
||||||
|
const v = evaluateRiskFloor({ filesChanged: ['', ' ' as unknown as string].filter(Boolean) });
|
||||||
|
// only the whitespace string survives the Boolean filter; it classifies to none
|
||||||
|
expect(v.surface).toBe('none');
|
||||||
|
expect(v.needs_review).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each<[string, string, ReviewSurface, boolean]>([
|
||||||
|
['auth', 'apps/api/src/auth/session.guard.ts', 'auth', true],
|
||||||
|
['data', 'packages/db/migrations/0007_add_users.sql', 'data', true],
|
||||||
|
['infra', '.woodpecker/deploy.yml', 'infra', true],
|
||||||
|
['build', 'packages/types/tsconfig.json', 'build', true],
|
||||||
|
['ui', 'apps/web/src/components/Button.tsx', 'ui', false],
|
||||||
|
['test', 'packages/macp/src/risk-floor.spec.ts', 'test', false],
|
||||||
|
['docs', 'docs/plans/agent-reflection-loop-PRD.md', 'docs', false],
|
||||||
|
['none', 'README', 'none', false],
|
||||||
|
])(
|
||||||
|
'classifies a single %s file → surface=%s needs_review=%s',
|
||||||
|
(_label, file, surface, needsReview) => {
|
||||||
|
const v = evaluateRiskFloor({ filesChanged: [file] });
|
||||||
|
expect(v.surface).toBe(surface);
|
||||||
|
expect(v.needs_review).toBe(needsReview);
|
||||||
|
expect(v.reason).toContain(
|
||||||
|
file === 'README' ? 'no sensitive surface' : surface === 'none' ? '' : surface,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('lets the highest-risk surface dominate a mixed diff', () => {
|
||||||
|
const v = evaluateRiskFloor({
|
||||||
|
filesChanged: [
|
||||||
|
'docs/readme.md',
|
||||||
|
'apps/web/src/components/Nav.tsx',
|
||||||
|
'apps/api/src/auth/token.service.ts',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(v.surface).toBe('auth');
|
||||||
|
expect(v.score).toBe(1.0);
|
||||||
|
expect(v.needs_review).toBe(true);
|
||||||
|
expect(v.reason).toContain('token.service.ts');
|
||||||
|
expect(v.reason).not.toContain('readme.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every file that ties at the dominant surface', () => {
|
||||||
|
const v = evaluateRiskFloor({
|
||||||
|
filesChanged: ['src/login.ts', 'src/permission-check.ts'],
|
||||||
|
});
|
||||||
|
expect(v.surface).toBe('auth');
|
||||||
|
expect(v.reason).toContain('src/login.ts');
|
||||||
|
expect(v.reason).toContain('src/permission-check.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats docs+test-only diffs as below the floor', () => {
|
||||||
|
const v = evaluateRiskFloor({
|
||||||
|
filesChanged: ['docs/guide.md', 'packages/x/src/x.test.ts'],
|
||||||
|
});
|
||||||
|
expect(v.needs_review).toBe(false);
|
||||||
|
expect(v.surface).toBe('test'); // higher weight than docs
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors a custom threshold', () => {
|
||||||
|
const docsOnly = { filesChanged: ['docs/guide.md'] };
|
||||||
|
expect(evaluateRiskFloor(docsOnly, 0.05).needs_review).toBe(true);
|
||||||
|
expect(evaluateRiskFloor(docsOnly, DEFAULT_RISK_THRESHOLD).needs_review).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic across call order', () => {
|
||||||
|
const a = evaluateRiskFloor({ filesChanged: ['a.md', 'auth/x.ts', 'b.tsx'] });
|
||||||
|
const b = evaluateRiskFloor({ filesChanged: ['b.tsx', 'a.md', 'auth/x.ts'] });
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
packages/macp/src/risk-floor.ts
Normal file
138
packages/macp/src/risk-floor.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* Diff risk-floor — deterministic review-need classifier.
|
||||||
|
*
|
||||||
|
* Given the set of changed files in a diff, derive a *minimum* review
|
||||||
|
* requirement ("floor") from the change surface. This is the mechanical half
|
||||||
|
* of the agent reflection loop (design §6): risky surfaces (auth, data, infra)
|
||||||
|
* trip a review requirement regardless of what the agent self-reports.
|
||||||
|
*
|
||||||
|
* Precedence (authoritative ordering, see design §5):
|
||||||
|
* CI/tests > human merge > reviewer verdict > self-reflection
|
||||||
|
* This module sits at the *floor*. It NEVER overrides CI or a human; a
|
||||||
|
* `needs_review: false` verdict means "no surface tripped the floor", not
|
||||||
|
* "safe to merge". Consumers MUST keep CI/tests authoritative above it.
|
||||||
|
*
|
||||||
|
* Pure and deterministic: no IO, no clock, no randomness. Same input → same
|
||||||
|
* verdict. Safe to call from a Stop hook via `node -e` or to port inline.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Review surfaces, ordered most- to least-sensitive. */
|
||||||
|
export type ReviewSurface = 'auth' | 'data' | 'infra' | 'build' | 'ui' | 'test' | 'docs' | 'none';
|
||||||
|
|
||||||
|
export interface RiskFloorInput {
|
||||||
|
/** Paths of changed files, repo-relative. Order-insensitive. */
|
||||||
|
filesChanged: string[];
|
||||||
|
/** Optional diff size signals; reserved for future weighting. */
|
||||||
|
insertions?: number;
|
||||||
|
deletions?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskFloorVerdict {
|
||||||
|
/** True when the change surface meets/exceeds the review threshold. */
|
||||||
|
needs_review: boolean;
|
||||||
|
/** Aggregate risk score in [0, 1] — the max surface weight across files. */
|
||||||
|
score: number;
|
||||||
|
/** The dominant (highest-weight) surface across all changed files. */
|
||||||
|
surface: ReviewSurface;
|
||||||
|
/** Human-readable explanation naming the surface and tripping files. */
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default review threshold; `score >= THRESHOLD` ⇒ `needs_review`. */
|
||||||
|
export const DEFAULT_RISK_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
interface SurfaceRule {
|
||||||
|
surface: ReviewSurface;
|
||||||
|
weight: number;
|
||||||
|
/** Case-insensitive regex matched against the file path. */
|
||||||
|
pattern: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface classification rules, evaluated highest-weight first. The first
|
||||||
|
* rule whose pattern matches a path classifies that file; the file's surface
|
||||||
|
* is the highest-risk surface it matches (rules are pre-sorted by weight).
|
||||||
|
*/
|
||||||
|
const SURFACE_RULES: readonly SurfaceRule[] = [
|
||||||
|
{
|
||||||
|
surface: 'auth',
|
||||||
|
weight: 1.0,
|
||||||
|
pattern: /auth|login|session|token|permission|rbac|credential|secret/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: 'data',
|
||||||
|
weight: 0.9,
|
||||||
|
pattern: /migration|prisma|schema|\.sql|entity|repository|seed/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: 'infra',
|
||||||
|
weight: 0.85,
|
||||||
|
pattern: /docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
surface: 'build',
|
||||||
|
weight: 0.6,
|
||||||
|
pattern: /package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite/i,
|
||||||
|
},
|
||||||
|
{ surface: 'ui', weight: 0.4, pattern: /\.tsx|\.css|components\/|apps\/web\// },
|
||||||
|
{ surface: 'test', weight: 0.2, pattern: /\.spec\.|\.test\.|__tests__\// },
|
||||||
|
{ surface: 'docs', weight: 0.1, pattern: /\.md$|docs\// },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NONE_WEIGHT = 0.0;
|
||||||
|
|
||||||
|
/** Classify a single path to its highest-risk surface and weight. */
|
||||||
|
function classify(path: string): { surface: ReviewSurface; weight: number } {
|
||||||
|
for (const rule of SURFACE_RULES) {
|
||||||
|
if (rule.pattern.test(path)) {
|
||||||
|
return { surface: rule.surface, weight: rule.weight };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { surface: 'none', weight: NONE_WEIGHT };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate the review risk-floor for a diff.
|
||||||
|
*
|
||||||
|
* @param input changed files (+ optional size signals)
|
||||||
|
* @param threshold review cutoff; defaults to {@link DEFAULT_RISK_THRESHOLD}
|
||||||
|
*/
|
||||||
|
export function evaluateRiskFloor(
|
||||||
|
input: RiskFloorInput,
|
||||||
|
threshold: number = DEFAULT_RISK_THRESHOLD,
|
||||||
|
): RiskFloorVerdict {
|
||||||
|
const files = (input.filesChanged ?? []).filter((f) => typeof f === 'string' && f.length > 0);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return {
|
||||||
|
needs_review: false,
|
||||||
|
score: 0,
|
||||||
|
surface: 'none',
|
||||||
|
reason: 'no files changed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let topSurface: ReviewSurface = 'none';
|
||||||
|
let topWeight = NONE_WEIGHT;
|
||||||
|
const tripping: string[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const { surface, weight } = classify(file);
|
||||||
|
if (weight > topWeight) {
|
||||||
|
topWeight = weight;
|
||||||
|
topSurface = surface;
|
||||||
|
tripping.length = 0;
|
||||||
|
tripping.push(file);
|
||||||
|
} else if (weight === topWeight && surface === topSurface && surface !== 'none') {
|
||||||
|
tripping.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const needs_review = topWeight >= threshold;
|
||||||
|
const reason =
|
||||||
|
topSurface === 'none'
|
||||||
|
? `no sensitive surface in ${files.length} changed file(s)`
|
||||||
|
: `${topSurface} surface (weight ${topWeight}) in: ${tripping.join(', ')}`;
|
||||||
|
|
||||||
|
return { needs_review, score: topWeight, surface: topSurface, reason };
|
||||||
|
}
|
||||||
105
packages/macp/src/schemas/reflection.v1.schema.json
Normal file
105
packages/macp/src/schemas/reflection.v1.schema.json
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://mosaicstack.dev/schemas/reflection/reflection.v1.schema.json",
|
||||||
|
"title": "Agent Reflection (v1)",
|
||||||
|
"description": "End-of-run reflection sidecar. Mechanical fields are written by the Stop hook; self-reported fields are merged from an optional agent-supplied input and are null when absent (provenance.degraded=true).",
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"schema",
|
||||||
|
"task_ref",
|
||||||
|
"agent",
|
||||||
|
"session_id",
|
||||||
|
"timestamp",
|
||||||
|
"repo",
|
||||||
|
"risk",
|
||||||
|
"files_changed",
|
||||||
|
"provenance"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema": {
|
||||||
|
"const": "reflection.v1"
|
||||||
|
},
|
||||||
|
"task_ref": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Canonical task ref; derived from REFLECTION_TASK_REF or repo+branch."
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Persona/runtime id (REFLECTION_AGENT or 'unknown')."
|
||||||
|
},
|
||||||
|
"session_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "From the Stop payload session_id, else 'unknown'."
|
||||||
|
},
|
||||||
|
"timestamp": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "ISO-8601 UTC capture time."
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Repo root basename."
|
||||||
|
},
|
||||||
|
"confidence": {
|
||||||
|
"type": ["number", "null"],
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 1,
|
||||||
|
"description": "SELF-REPORTED. Agent's overall confidence; null when not supplied."
|
||||||
|
},
|
||||||
|
"most_likely_wrong": {
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"description": "SELF-REPORTED. The single most-likely way the work is wrong.",
|
||||||
|
"required": ["surface", "description"],
|
||||||
|
"properties": {
|
||||||
|
"surface": { "$ref": "#/$defs/surface" },
|
||||||
|
"description": { "type": "string" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"known_not_in_diff": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "SELF-REPORTED. What the agent knows that isn't visible in the diff."
|
||||||
|
},
|
||||||
|
"risk": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "MECHANICAL. Output of the diff risk-floor.",
|
||||||
|
"required": ["needs_review", "score", "surface", "reason"],
|
||||||
|
"properties": {
|
||||||
|
"needs_review": { "type": "boolean" },
|
||||||
|
"score": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||||
|
"surface": { "$ref": "#/$defs/surface" },
|
||||||
|
"reason": { "type": "string" }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"files_changed": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"description": "MECHANICAL. git diff name-only."
|
||||||
|
},
|
||||||
|
"provenance": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["source", "reflection_attempt", "degraded", "reflection_mode"],
|
||||||
|
"properties": {
|
||||||
|
"source": { "const": "stop-hook" },
|
||||||
|
"reflection_attempt": { "type": "integer", "minimum": 1 },
|
||||||
|
"degraded": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True when self-report inputs were missing/unreadable."
|
||||||
|
},
|
||||||
|
"reflection_mode": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["off", "solo", "orchestrated"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false,
|
||||||
|
"$defs": {
|
||||||
|
"surface": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["auth", "data", "infra", "build", "ui", "test", "docs", "none"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ At session start, additionally:
|
|||||||
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 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.
|
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 (condensed — full detail in `guides/E2E-DELIVERY.md`)
|
## Non-Negotiable Operating Rules (condensed — full detail in `guides/E2E-DELIVERY.md`)
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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>`
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -34,6 +34,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"Stop": [
|
||||||
|
{
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||||
|
"timeout": 15
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
# After loading, service-specific env vars are exported.
|
# After loading, service-specific env vars are exported.
|
||||||
# Run `load_credentials --help` for details.
|
# Run `load_credentials --help` for details.
|
||||||
|
|
||||||
MOSAIC_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
|
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
|
||||||
|
for _cand in "$HOME/.config/mosaic/credentials.json" "$HOME/src/jarvis-brain/credentials.json"; do
|
||||||
|
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
||||||
|
done
|
||||||
|
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/src/jarvis-brain/credentials.json}"
|
||||||
|
fi
|
||||||
|
|
||||||
_mosaic_require_jq() {
|
_mosaic_require_jq() {
|
||||||
if ! command -v jq &>/dev/null; then
|
if ! command -v jq &>/dev/null; then
|
||||||
@@ -34,6 +39,19 @@ _mosaic_read_cred() {
|
|||||||
jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE"
|
jq -r "$jq_path // empty" "$MOSAIC_CREDENTIALS_FILE"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Decide curl TLS flag for a target URL: validate public hosts (MITM matters on
|
||||||
|
# WAN); allow self-signed only for private-network IP literals (trusted LAN) or an
|
||||||
|
# explicit $MOSAIC_INSECURE_TLS opt-in. Echoes "-k" or "" (empty).
|
||||||
|
_mosaic_tls_opt() {
|
||||||
|
local url="$1" host
|
||||||
|
[[ -n "${MOSAIC_INSECURE_TLS:-}" ]] && { echo "-k"; return; }
|
||||||
|
host=$(printf '%s' "$url" | sed -E 's#^[a-zA-Z]+://([^/:]+).*#\1#')
|
||||||
|
if [[ "$host" =~ ^(10\.|127\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.) ]]; then
|
||||||
|
echo "-k"; return
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
# Sync Woodpecker credentials to ~/.woodpecker/<instance>.env
|
# Sync Woodpecker credentials to ~/.woodpecker/<instance>.env
|
||||||
# Only writes when values differ to avoid unnecessary disk writes.
|
# Only writes when values differ to avoid unnecessary disk writes.
|
||||||
_mosaic_sync_woodpecker_env() {
|
_mosaic_sync_woodpecker_env() {
|
||||||
@@ -261,7 +279,8 @@ mosaic_http() {
|
|||||||
local base_url="${4:-}"
|
local base_url="${4:-}"
|
||||||
|
|
||||||
local response
|
local response
|
||||||
response=$(curl -sk -w "\n%{http_code}" -X "$method" \
|
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||||
|
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
|
||||||
-H "$auth_header" \
|
-H "$auth_header" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
"${base_url}${endpoint}")
|
"${base_url}${endpoint}")
|
||||||
@@ -279,7 +298,8 @@ mosaic_http_post() {
|
|||||||
local base_url="${4:-}"
|
local base_url="${4:-}"
|
||||||
|
|
||||||
local response
|
local response
|
||||||
response=$(curl -sk -w "\n%{http_code}" -X POST \
|
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||||
|
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
|
||||||
-H "$auth_header" \
|
-H "$auth_header" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$data" \
|
-d "$data" \
|
||||||
@@ -297,7 +317,8 @@ mosaic_http_patch() {
|
|||||||
local base_url="${4:-}"
|
local base_url="${4:-}"
|
||||||
|
|
||||||
local response
|
local response
|
||||||
response=$(curl -sk -w "\n%{http_code}" -X PATCH \
|
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||||
|
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
|
||||||
-H "$auth_header" \
|
-H "$auth_header" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$data" \
|
-d "$data" \
|
||||||
|
|||||||
@@ -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
|
||||||
if [[ -n "$COMMENT" ]]; then
|
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
|
if [[ -n "$GITEA_LOGIN_NAME" ]]; then
|
||||||
|
if [[ -n "$COMMENT" ]]; then
|
||||||
|
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
|
||||||
if [[ -n "$COMMENT" ]]; then
|
REPO_ARGS=$(get_gitea_repo_args || true)
|
||||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
if [[ -n "$REPO_ARGS" ]]; then
|
||||||
|
if [[ -n "$COMMENT" ]]; then
|
||||||
|
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
|
||||||
@@ -70,6 +72,11 @@ elif values and all(v == "success" for v in values):
|
|||||||
print("success")
|
print("success")
|
||||||
elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
|
elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
|
||||||
print("pending")
|
print("pending")
|
||||||
|
elif not values and not state:
|
||||||
|
# No pipeline/status of any kind reported for this commit. Distinct from
|
||||||
|
# "unknown" (an ambiguous/unrecognized status that should keep polling):
|
||||||
|
# this signals a repo/commit that simply has no CI configured.
|
||||||
|
print("no-status")
|
||||||
else:
|
else:
|
||||||
print("unknown")
|
print("unknown")
|
||||||
PY
|
PY
|
||||||
@@ -124,7 +131,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 +144,22 @@ 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
gitea_get_default_branch() {
|
||||||
|
local host="$1"
|
||||||
|
local repo="$2"
|
||||||
|
local token="$3"
|
||||||
|
local url="https://${host}/api/v1/repos/${repo}"
|
||||||
|
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||||
|
import json, sys
|
||||||
|
print((json.load(sys.stdin) or {}).get("default_branch", ""))
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
github_get_default_branch() {
|
||||||
|
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
|
||||||
}
|
}
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -150,6 +172,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 +237,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
|
||||||
@@ -227,6 +265,51 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# No-CI determination is TWO-TIER (primary: CI history; secondary: empty-poll streak).
|
||||||
|
#
|
||||||
|
# PRIMARY — "does this repo run CI at all?" Probed once, up front, from the DEFAULT
|
||||||
|
# BRANCH's commit status. A repo whose default branch carries CI statuses
|
||||||
|
# demonstrably runs CI, so an EMPTY status on the PR head means the pipeline simply
|
||||||
|
# has not registered YET (webhook/queue lag) — NOT that the repo is CI-less. In that
|
||||||
|
# case we must NEVER fast-green; we keep polling until the pipeline registers or the
|
||||||
|
# timeout fires (both safe). This closes the webhook-lag false-green: a slow-to-
|
||||||
|
# register pipeline feeding a merge gate can no longer be mistaken for "no CI".
|
||||||
|
#
|
||||||
|
# SECONDARY — the empty-poll streak below applies ONLY to genuinely CI-less repos
|
||||||
|
# (default branch also has no CI history, e.g. device-imaging class), where burning
|
||||||
|
# the full timeout would be pure waste. There, NO_CI_MAX empty polls => fast-exit 0.
|
||||||
|
#
|
||||||
|
# Probe failure is treated conservatively as REPO_HAS_CI=1 (assume CI present): we
|
||||||
|
# would rather wait-then-timeout than risk a false-green, per the merge-gate priority.
|
||||||
|
REPO_HAS_CI=1
|
||||||
|
detect_repo_ci() {
|
||||||
|
local def_branch def_status
|
||||||
|
# Every early exit returns 0: a probe miss must leave the conservative
|
||||||
|
# REPO_HAS_CI=1 default in place, never abort the caller under `set -e`.
|
||||||
|
if [[ "$PLATFORM" == "github" ]]; then
|
||||||
|
def_branch=$(github_get_default_branch 2>/dev/null) || {
|
||||||
|
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
|
||||||
|
[[ -n "$def_branch" ]] || return 0
|
||||||
|
def_status=$(github_get_commit_status_json "$OWNER" "$REPO" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
|
||||||
|
else
|
||||||
|
def_branch=$(gitea_get_default_branch "$HOST" "$OWNER/$REPO" "$TOKEN" 2>/dev/null) || {
|
||||||
|
echo "[pr-ci-wait] WARN: default-branch probe failed; assuming CI-enabled (will not fast-green on empty status)."; return 0; }
|
||||||
|
[[ -n "$def_branch" ]] || return 0
|
||||||
|
def_status=$(gitea_get_commit_status_json "$HOST" "$OWNER/$REPO" "$TOKEN" "$def_branch" 2>/dev/null | extract_state_from_status_json) || return 0
|
||||||
|
fi
|
||||||
|
if [[ "$def_status" == "no-status" || -z "$def_status" ]]; then
|
||||||
|
REPO_HAS_CI=0
|
||||||
|
echo "[pr-ci-wait] default branch '${def_branch}' has no CI status history — treating repo as CI-less (empty-poll fast-exit enabled)."
|
||||||
|
else
|
||||||
|
REPO_HAS_CI=1
|
||||||
|
echo "[pr-ci-wait] default branch '${def_branch}' has CI history (state=${def_status}) — repo runs CI; empty status on PR head => awaiting registration, will not fast-green."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
detect_repo_ci || true
|
||||||
|
|
||||||
|
NO_CI_STREAK=0
|
||||||
|
NO_CI_MAX=3
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
NOW_TS=$(date +%s)
|
NOW_TS=$(date +%s)
|
||||||
if (( NOW_TS > DEADLINE_TS )); then
|
if (( NOW_TS > DEADLINE_TS )); then
|
||||||
@@ -254,11 +337,35 @@ while true; do
|
|||||||
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
|
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
|
no-status)
|
||||||
|
if [[ "$REPO_HAS_CI" == "1" ]]; then
|
||||||
|
# PRIMARY tier: repo demonstrably runs CI but this commit's pipeline
|
||||||
|
# has not registered yet (webhook/queue lag). Do NOT fast-green — keep
|
||||||
|
# polling until it registers or the timeout fires. Reset the streak so
|
||||||
|
# a later genuine CI-less misread can't accumulate across this state.
|
||||||
|
NO_CI_STREAK=0
|
||||||
|
echo "[pr-ci-wait] empty status on PR head but repo runs CI — awaiting pipeline registration (webhook lag), not fast-greening."
|
||||||
|
else
|
||||||
|
# SECONDARY tier: genuinely CI-less repo (default branch has no CI
|
||||||
|
# history either). Empty polls => fast-exit green after NO_CI_MAX.
|
||||||
|
NO_CI_STREAK=$((NO_CI_STREAK + 1))
|
||||||
|
if (( NO_CI_STREAK >= NO_CI_MAX )); then
|
||||||
|
echo "[INFO] no CI configured for this repo/commit (PR #$PR_NUMBER, ${NO_CI_STREAK} consecutive empty polls, default branch also CI-less); treating as green."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep "$INTERVAL_SEC"
|
||||||
|
;;
|
||||||
pending|unknown)
|
pending|unknown)
|
||||||
|
# A pipeline exists but hasn't reached a terminal state (or is
|
||||||
|
# transiently ambiguous) — keep waiting, and reset the no-CI streak
|
||||||
|
# since this commit is not in the "no CI at all" condition.
|
||||||
|
NO_CI_STREAK=0
|
||||||
sleep "$INTERVAL_SEC"
|
sleep "$INTERVAL_SEC"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
|
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
|
||||||
|
NO_CI_STREAK=0
|
||||||
sleep "$INTERVAL_SEC"
|
sleep "$INTERVAL_SEC"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
197
packages/mosaic/framework/tools/qa/reflect-stop-hook.sh
Executable file
197
packages/mosaic/framework/tools/qa/reflect-stop-hook.sh
Executable file
@@ -0,0 +1,197 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# reflect-stop-hook.sh — Stop hook (agent reflection loop, durable kernel)
|
||||||
|
#
|
||||||
|
# At end-of-run, capture the doer's end-state as a structured `reflection.v1`
|
||||||
|
# sidecar: the mechanical diff risk-floor plus any self-report the agent left
|
||||||
|
# behind. This is the passive capture half of the design (§10 step 1). It does
|
||||||
|
# NOT route, score, or gate — it only writes the sidecar; pickup is future work.
|
||||||
|
#
|
||||||
|
# FAIL-CLOSED: if REFLECTION_MODE is unset or "off", this is a strict no-op.
|
||||||
|
# Global registration is therefore safe; the feature only activates when a
|
||||||
|
# launcher/profile explicitly sets REFLECTION_MODE=solo|orchestrated.
|
||||||
|
#
|
||||||
|
# NON-BLOCKING: Stop hooks are observational. This script NEVER emits a
|
||||||
|
# `decision` field and ALWAYS exits 0 — it can never fail or stall a session.
|
||||||
|
#
|
||||||
|
# Environment contract:
|
||||||
|
# REFLECTION_MODE off|solo|orchestrated (default: off → no-op)
|
||||||
|
# REFLECTION_DIR output dir (default: <repo>/.mosaic/reflections)
|
||||||
|
# REFLECTION_INPUT self-report JSON (default: <repo>/.mosaic/reflection-input.json)
|
||||||
|
# REFLECTION_TASK_REF canonical task ref (default: <repo>#<branch>)
|
||||||
|
# REFLECTION_AGENT persona/runtime id (default: unknown)
|
||||||
|
# REFLECTION_RISK_THRESHOLD review cutoff [0,1] (default: 0.5)
|
||||||
|
#
|
||||||
|
# Risk-floor surface table is kept in sync with the authoritative TS
|
||||||
|
# implementation at packages/macp/src/risk-floor.ts (evaluateRiskFloor).
|
||||||
|
#
|
||||||
|
# Exit codes: always 0 (observational hook).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---- fail-closed gate -------------------------------------------------------
|
||||||
|
MODE="${REFLECTION_MODE:-off}"
|
||||||
|
if [[ "$MODE" != "solo" && "$MODE" != "orchestrated" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read the Stop payload (best-effort; never required).
|
||||||
|
INPUT="$(cat || true)"
|
||||||
|
|
||||||
|
# Sentinel lock path (global so the EXIT trap can clean it after main returns).
|
||||||
|
LOCKFILE=""
|
||||||
|
trap 'rm -f "${LOCKFILE:-}" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
main() {
|
||||||
|
command -v jq >/dev/null 2>&1 || return 0 # no jq → silently no-op
|
||||||
|
|
||||||
|
local session_id payload_cwd repo_dir repo_name branch task_ref agent
|
||||||
|
session_id="$(printf '%s' "$INPUT" | jq -r '.session_id // "unknown"' 2>/dev/null || echo unknown)"
|
||||||
|
# Sanitize: session_id is interpolated into file/lock paths — allow safe
|
||||||
|
# filename chars only (defends against ../ or / in the payload).
|
||||||
|
session_id="${session_id//[^a-zA-Z0-9_-]/}"
|
||||||
|
session_id="${session_id:-unknown}"
|
||||||
|
payload_cwd="$(printf '%s' "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
# Resolve repo root: prefer git toplevel from the payload cwd, else PWD.
|
||||||
|
local start_dir="${payload_cwd:-$PWD}"
|
||||||
|
repo_dir="$(git -C "$start_dir" rev-parse --show-toplevel 2>/dev/null || echo "$start_dir")"
|
||||||
|
repo_name="$(basename "$repo_dir")"
|
||||||
|
branch="$(git -C "$repo_dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)"
|
||||||
|
|
||||||
|
task_ref="${REFLECTION_TASK_REF:-${repo_name}#${branch}}"
|
||||||
|
agent="${REFLECTION_AGENT:-unknown}"
|
||||||
|
|
||||||
|
# ---- sentinel guard: avoid re-fire loops --------------------------------
|
||||||
|
local out_dir lock
|
||||||
|
out_dir="${REFLECTION_DIR:-${repo_dir}/.mosaic/reflections}"
|
||||||
|
mkdir -p "$out_dir" 2>/dev/null || return 0
|
||||||
|
lock="${out_dir}/.${session_id}.lock"
|
||||||
|
if [[ -e "$lock" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
: > "$lock" 2>/dev/null || true
|
||||||
|
LOCKFILE="$lock"
|
||||||
|
|
||||||
|
# ---- mechanical: changed files ------------------------------------------
|
||||||
|
# Union of committed-vs-HEAD~ is out of scope; capture the working surface:
|
||||||
|
# staged + unstaged + untracked, best-effort.
|
||||||
|
# Exclude .mosaic/ (agent scratch: reflections, locks, self-report input) —
|
||||||
|
# it is tooling state, not part of the diff under review.
|
||||||
|
local files
|
||||||
|
files="$(
|
||||||
|
{
|
||||||
|
git -C "$repo_dir" diff --name-only HEAD 2>/dev/null || true
|
||||||
|
git -C "$repo_dir" diff --name-only --staged 2>/dev/null || true
|
||||||
|
git -C "$repo_dir" ls-files --others --exclude-standard 2>/dev/null || true
|
||||||
|
} | sed '/^$/d' | grep -v '^\.mosaic/' | sort -u || true
|
||||||
|
)"
|
||||||
|
|
||||||
|
# ---- mechanical: risk-floor (inline port of evaluateRiskFloor) ----------
|
||||||
|
local threshold="${REFLECTION_RISK_THRESHOLD:-0.5}"
|
||||||
|
local top_surface="none" top_weight="0.0" tripping=""
|
||||||
|
local f surface weight
|
||||||
|
while IFS= read -r f; do
|
||||||
|
[[ -z "$f" ]] && continue
|
||||||
|
surface="$(classify_surface "$f")"
|
||||||
|
weight="$(surface_weight "$surface")"
|
||||||
|
if awk "BEGIN{exit !($weight > $top_weight)}"; then
|
||||||
|
top_weight="$weight"; top_surface="$surface"; tripping="$f"
|
||||||
|
elif [[ "$surface" == "$top_surface" && "$surface" != "none" ]] && awk "BEGIN{exit !($weight == $top_weight)}"; then
|
||||||
|
tripping="${tripping:+$tripping, }$f"
|
||||||
|
fi
|
||||||
|
done <<< "$files"
|
||||||
|
|
||||||
|
local needs_review reason file_count
|
||||||
|
file_count="$(printf '%s\n' "$files" | sed '/^$/d' | wc -l | tr -d ' ')"
|
||||||
|
if awk "BEGIN{exit !($top_weight >= $threshold)}"; then needs_review=true; else needs_review=false; fi
|
||||||
|
if [[ "$top_surface" == "none" ]]; then
|
||||||
|
if [[ "$file_count" -eq 0 ]]; then reason="no files changed"; else reason="no sensitive surface in ${file_count} changed file(s)"; fi
|
||||||
|
else
|
||||||
|
reason="${top_surface} surface (weight ${top_weight}) in: ${tripping}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- self-report merge (optional) ---------------------------------------
|
||||||
|
local input_file degraded self_json
|
||||||
|
input_file="${REFLECTION_INPUT:-${repo_dir}/.mosaic/reflection-input.json}"
|
||||||
|
degraded=true
|
||||||
|
self_json='{"confidence":null,"most_likely_wrong":null,"known_not_in_diff":null}'
|
||||||
|
if [[ -r "$input_file" ]] && jq -e . "$input_file" >/dev/null 2>&1; then
|
||||||
|
self_json="$(jq '{
|
||||||
|
confidence: (.confidence // null),
|
||||||
|
most_likely_wrong: (.most_likely_wrong // null),
|
||||||
|
known_not_in_diff: (.known_not_in_diff // null)
|
||||||
|
}' "$input_file" 2>/dev/null || echo "$self_json")"
|
||||||
|
degraded=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- assemble + atomic write --------------------------------------------
|
||||||
|
local ts files_json record tmp final
|
||||||
|
ts="$(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
|
||||||
|
files_json="$(printf '%s\n' "$files" | jq -R . | jq -s 'map(select(length>0))')"
|
||||||
|
|
||||||
|
record="$(jq -n \
|
||||||
|
--arg task_ref "$task_ref" \
|
||||||
|
--arg agent "$agent" \
|
||||||
|
--arg session_id "$session_id" \
|
||||||
|
--arg ts "$ts" \
|
||||||
|
--arg repo "$repo_name" \
|
||||||
|
--argjson needs_review "$needs_review" \
|
||||||
|
--argjson score "$top_weight" \
|
||||||
|
--arg surface "$top_surface" \
|
||||||
|
--arg reason "$reason" \
|
||||||
|
--argjson files "$files_json" \
|
||||||
|
--argjson self "$self_json" \
|
||||||
|
--argjson degraded "$degraded" \
|
||||||
|
--arg mode "$MODE" \
|
||||||
|
'{
|
||||||
|
schema: "reflection.v1",
|
||||||
|
task_ref: $task_ref,
|
||||||
|
agent: $agent,
|
||||||
|
session_id: $session_id,
|
||||||
|
timestamp: $ts,
|
||||||
|
repo: $repo,
|
||||||
|
confidence: $self.confidence,
|
||||||
|
most_likely_wrong: $self.most_likely_wrong,
|
||||||
|
known_not_in_diff: $self.known_not_in_diff,
|
||||||
|
risk: { needs_review: $needs_review, score: $score, surface: $surface, reason: $reason },
|
||||||
|
files_changed: $files,
|
||||||
|
provenance: { source: "stop-hook", reflection_attempt: 1, degraded: $degraded, reflection_mode: $mode }
|
||||||
|
}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
[[ -z "$record" ]] && return 0
|
||||||
|
|
||||||
|
final="${out_dir}/${session_id}-${ts//[:]/}.reflection.json"
|
||||||
|
tmp="${final}.tmp"
|
||||||
|
printf '%s\n' "$record" > "$tmp" 2>/dev/null || return 0
|
||||||
|
mv -f "$tmp" "$final" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# classify_surface PATH → surface name (highest-risk match wins, mirrors TS)
|
||||||
|
classify_surface() {
|
||||||
|
local p="$1"
|
||||||
|
if printf '%s' "$p" | grep -qiE 'auth|login|session|token|permission|rbac|credential|secret'; then echo auth; return; fi
|
||||||
|
if printf '%s' "$p" | grep -qiE 'migration|prisma|schema|\.sql|entity|repository|seed'; then echo data; return; fi
|
||||||
|
if printf '%s' "$p" | grep -qiE 'docker|\.woodpecker|compose|traefik|deploy|helm|k8s|terraform'; then echo infra; return; fi
|
||||||
|
if printf '%s' "$p" | grep -qiE 'package\.json|tsconfig|turbo\.json|pnpm-|\.config\.|eslint|vite'; then echo build; return; fi
|
||||||
|
if printf '%s' "$p" | grep -qE '\.tsx|\.css|components/|apps/web/'; then echo ui; return; fi
|
||||||
|
if printf '%s' "$p" | grep -qE '\.spec\.|\.test\.|__tests__/'; then echo test; return; fi
|
||||||
|
if printf '%s' "$p" | grep -qE '\.md$|docs/'; then echo docs; return; fi
|
||||||
|
echo none
|
||||||
|
}
|
||||||
|
|
||||||
|
# surface_weight SURFACE → numeric weight (mirrors TS SURFACE_RULES)
|
||||||
|
surface_weight() {
|
||||||
|
case "$1" in
|
||||||
|
auth) echo 1.0 ;;
|
||||||
|
data) echo 0.9 ;;
|
||||||
|
infra) echo 0.85 ;;
|
||||||
|
build) echo 0.6 ;;
|
||||||
|
ui) echo 0.4 ;;
|
||||||
|
test) echo 0.2 ;;
|
||||||
|
docs) echo 0.1 ;;
|
||||||
|
*) echo 0.0 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main || true
|
||||||
|
exit 0
|
||||||
@@ -12,7 +12,7 @@ wp_resolve_repo_id() {
|
|||||||
local full_name="$1"
|
local full_name="$1"
|
||||||
local response http_code body repo_id
|
local response http_code body repo_id
|
||||||
|
|
||||||
response=$(curl -sk -w "\n%{http_code}" \
|
response=$(curl -sS -w "\n%{http_code}" \
|
||||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||||
"${WOODPECKER_URL}/api/repos/lookup/${full_name}")
|
"${WOODPECKER_URL}/api/repos/lookup/${full_name}")
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ fi
|
|||||||
# Resolve owner/repo to numeric ID (Woodpecker v3 API)
|
# Resolve owner/repo to numeric ID (Woodpecker v3 API)
|
||||||
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
||||||
|
|
||||||
response=$(curl -sk -w "\n%{http_code}" \
|
response=$(curl -sS -w "\n%{http_code}" \
|
||||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||||
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")
|
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
|||||||
_wp_fetch() {
|
_wp_fetch() {
|
||||||
local ep="$1"
|
local ep="$1"
|
||||||
local resp http_code body
|
local resp http_code body
|
||||||
resp=$(curl -sk -w "\n%{http_code}" \
|
resp=$(curl -sS -w "\n%{http_code}" \
|
||||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||||
"$ep")
|
"$ep")
|
||||||
http_code=$(echo "$resp" | tail -n1)
|
http_code=$(echo "$resp" | tail -n1)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
|||||||
|
|
||||||
echo "Triggering pipeline for $REPO on branch $BRANCH..."
|
echo "Triggering pipeline for $REPO on branch $BRANCH..."
|
||||||
|
|
||||||
response=$(curl -sk -w "\n%{http_code}" -X POST \
|
response=$(curl -sS -w "\n%{http_code}" -X POST \
|
||||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \
|
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \
|
||||||
|
|||||||
@@ -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/);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from './provider/index.js';
|
|||||||
export * from './routing/index.js';
|
export * from './routing/index.js';
|
||||||
export * from './commands/index.js';
|
export * from './commands/index.js';
|
||||||
export * from './federation/index.js';
|
export * from './federation/index.js';
|
||||||
|
export * from './reflection/index.js';
|
||||||
|
|||||||
146
packages/types/src/reflection/__tests__/reflection.spec.ts
Normal file
146
packages/types/src/reflection/__tests__/reflection.spec.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the reflection.v1 schema + self-report boundary.
|
||||||
|
*
|
||||||
|
* The runtime source of truth is the zod schema set in `reflection.ts`. The
|
||||||
|
* class-validator `ReflectionSelfReportDto` is the NestJS-side boundary type
|
||||||
|
* (exercised under the gateway app's reflect-metadata runtime, mirroring how
|
||||||
|
* `chat.dto.ts` is tested in apps/gateway); here we validate the self-report
|
||||||
|
* input with its zod counterpart, which is what the Stop hook actually uses.
|
||||||
|
*
|
||||||
|
* Coverage:
|
||||||
|
* - REVIEW_SURFACES canonical ordering (the enum both zod + JSON Schema mirror)
|
||||||
|
* - ReflectionV1Schema accepts a fully-populated record
|
||||||
|
* - ReflectionV1Schema accepts a degraded record (self-report fields null)
|
||||||
|
* - ReflectionV1Schema rejects bad schema literal / out-of-range confidence / bad surface
|
||||||
|
* - ReflectionSelfReportSchema accepts valid + empty, rejects bad input
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
REVIEW_SURFACES,
|
||||||
|
ReflectionV1Schema,
|
||||||
|
ReflectionSelfReportSchema,
|
||||||
|
type ReflectionV1,
|
||||||
|
} from '../index.js';
|
||||||
|
|
||||||
|
const baseMechanical = {
|
||||||
|
schema: 'reflection.v1' as const,
|
||||||
|
task_ref: 'stack#544',
|
||||||
|
agent: 'claude',
|
||||||
|
session_id: 'sess-abc',
|
||||||
|
timestamp: '2026-06-16T00:00:00.000Z',
|
||||||
|
repo: 'stack',
|
||||||
|
risk: {
|
||||||
|
needs_review: true,
|
||||||
|
score: 1.0,
|
||||||
|
surface: 'auth' as const,
|
||||||
|
reason: 'auth surface (weight 1) in: src/auth.ts',
|
||||||
|
},
|
||||||
|
files_changed: ['src/auth.ts'],
|
||||||
|
provenance: {
|
||||||
|
source: 'stop-hook' as const,
|
||||||
|
reflection_attempt: 1,
|
||||||
|
degraded: false,
|
||||||
|
reflection_mode: 'solo' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('REVIEW_SURFACES', () => {
|
||||||
|
it('keeps the canonical most→least-sensitive ordering', () => {
|
||||||
|
expect(REVIEW_SURFACES).toEqual([
|
||||||
|
'auth',
|
||||||
|
'data',
|
||||||
|
'infra',
|
||||||
|
'build',
|
||||||
|
'ui',
|
||||||
|
'test',
|
||||||
|
'docs',
|
||||||
|
'none',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReflectionV1Schema', () => {
|
||||||
|
it('accepts a fully-populated record', () => {
|
||||||
|
const rec: ReflectionV1 = {
|
||||||
|
...baseMechanical,
|
||||||
|
confidence: 0.7,
|
||||||
|
most_likely_wrong: { surface: 'auth', description: 'token refresh untested' },
|
||||||
|
known_not_in_diff: 'manual QA only on the happy path',
|
||||||
|
};
|
||||||
|
expect(() => ReflectionV1Schema.parse(rec)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a degraded record with null self-report fields', () => {
|
||||||
|
const rec: ReflectionV1 = {
|
||||||
|
...baseMechanical,
|
||||||
|
confidence: null,
|
||||||
|
most_likely_wrong: null,
|
||||||
|
known_not_in_diff: null,
|
||||||
|
provenance: { ...baseMechanical.provenance, degraded: true },
|
||||||
|
};
|
||||||
|
expect(() => ReflectionV1Schema.parse(rec)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a wrong schema literal', () => {
|
||||||
|
const bad = {
|
||||||
|
...baseMechanical,
|
||||||
|
schema: 'reflection.v2',
|
||||||
|
confidence: null,
|
||||||
|
most_likely_wrong: null,
|
||||||
|
known_not_in_diff: null,
|
||||||
|
};
|
||||||
|
expect(() => ReflectionV1Schema.parse(bad)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects out-of-range confidence', () => {
|
||||||
|
const bad = {
|
||||||
|
...baseMechanical,
|
||||||
|
confidence: 1.5,
|
||||||
|
most_likely_wrong: null,
|
||||||
|
known_not_in_diff: null,
|
||||||
|
};
|
||||||
|
expect(() => ReflectionV1Schema.parse(bad)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown surface', () => {
|
||||||
|
const bad = {
|
||||||
|
...baseMechanical,
|
||||||
|
risk: { ...baseMechanical.risk, surface: 'network' },
|
||||||
|
confidence: null,
|
||||||
|
most_likely_wrong: null,
|
||||||
|
known_not_in_diff: null,
|
||||||
|
};
|
||||||
|
expect(() => ReflectionV1Schema.parse(bad)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReflectionSelfReportSchema', () => {
|
||||||
|
it('accepts a valid self-report', () => {
|
||||||
|
const ok = ReflectionSelfReportSchema.safeParse({
|
||||||
|
confidence: 0.8,
|
||||||
|
most_likely_wrong: {
|
||||||
|
surface: 'data',
|
||||||
|
description: 'migration not run against prod-sized data',
|
||||||
|
},
|
||||||
|
known_not_in_diff: 'rollback path untested',
|
||||||
|
});
|
||||||
|
expect(ok.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an empty self-report (all optional)', () => {
|
||||||
|
expect(ReflectionSelfReportSchema.safeParse({}).success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects confidence above 1', () => {
|
||||||
|
expect(ReflectionSelfReportSchema.safeParse({ confidence: 2 }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown most_likely_wrong.surface', () => {
|
||||||
|
const res = ReflectionSelfReportSchema.safeParse({
|
||||||
|
most_likely_wrong: { surface: 'network', description: 'x' },
|
||||||
|
});
|
||||||
|
expect(res.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
packages/types/src/reflection/index.ts
Normal file
30
packages/types/src/reflection/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Agent reflection (v1) — public barrel.
|
||||||
|
*
|
||||||
|
* reflection.ts — zod schemas (runtime source of truth) + inferred types
|
||||||
|
* reflection.dto.ts — class-validator DTO for the agent self-report input
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
REVIEW_SURFACES,
|
||||||
|
ReviewSurfaceSchema,
|
||||||
|
MostLikelyWrongSchema,
|
||||||
|
ReflectionRiskSchema,
|
||||||
|
ReflectionModeSchema,
|
||||||
|
ReflectionProvenanceSchema,
|
||||||
|
ReflectionSelfReportSchema,
|
||||||
|
ReflectionV1Schema,
|
||||||
|
REFLECTION_SCHEMA_ID,
|
||||||
|
} from './reflection.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ReviewSurface,
|
||||||
|
MostLikelyWrong,
|
||||||
|
ReflectionRisk,
|
||||||
|
ReflectionMode,
|
||||||
|
ReflectionProvenance,
|
||||||
|
ReflectionSelfReport,
|
||||||
|
ReflectionV1,
|
||||||
|
} from './reflection.js';
|
||||||
|
|
||||||
|
export { MostLikelyWrongDto, ReflectionSelfReportDto } from './reflection.dto.js';
|
||||||
55
packages/types/src/reflection/reflection.dto.ts
Normal file
55
packages/types/src/reflection/reflection.dto.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Reflection self-report DTO — class-validator boundary.
|
||||||
|
*
|
||||||
|
* Validates the agent-supplied self-report input (the optional
|
||||||
|
* `$REFLECTION_INPUT` file, default `<repo>/.mosaic/reflection-input.json`)
|
||||||
|
* before it is merged into a `reflection.v1` record. This is the only
|
||||||
|
* externally-authored input on the reflection path, so it gets a DTO per the
|
||||||
|
* Mosaic module-boundary rule.
|
||||||
|
*
|
||||||
|
* Class-validator only (no class-transformer `@Type`) — matching `chat.dto.ts`
|
||||||
|
* — so the module is safe to import without a `reflect-metadata` shim. Deep
|
||||||
|
* nested validation of `most_likely_wrong` is owned by the zod
|
||||||
|
* `ReflectionSelfReportSchema` in `reflection.ts`, which is what the Stop hook
|
||||||
|
* actually enforces at runtime.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsNumber,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { REVIEW_SURFACES } from './reflection.js';
|
||||||
|
|
||||||
|
/** Shape of `most_likely_wrong`; validated structurally by zod at runtime. */
|
||||||
|
export class MostLikelyWrongDto {
|
||||||
|
@IsIn(REVIEW_SURFACES as unknown as string[])
|
||||||
|
surface!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4_000)
|
||||||
|
description!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReflectionSelfReportDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@Max(1)
|
||||||
|
confidence?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject()
|
||||||
|
most_likely_wrong?: MostLikelyWrongDto;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(8_000)
|
||||||
|
known_not_in_diff?: string;
|
||||||
|
}
|
||||||
90
packages/types/src/reflection/reflection.ts
Normal file
90
packages/types/src/reflection/reflection.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Agent reflection (v1) — wire schema.
|
||||||
|
*
|
||||||
|
* Runtime source of truth for the `reflection.v1` sidecar emitted at end-of-run
|
||||||
|
* by the Stop hook (design §10 step 1). The JSON Schema artifact at
|
||||||
|
* `@mosaicstack/macp` `src/schemas/reflection.v1.schema.json` is the documented
|
||||||
|
* contract; this zod schema is the executable one and MUST agree with it.
|
||||||
|
*
|
||||||
|
* Field provenance:
|
||||||
|
* - MECHANICAL (risk, files_changed, ids, provenance): written by the hook.
|
||||||
|
* - SELF-REPORTED (confidence, most_likely_wrong, known_not_in_diff): merged
|
||||||
|
* from an optional agent-supplied input; null when absent.
|
||||||
|
*
|
||||||
|
* Pure — no NestJS, no DB, no Node-only APIs. Safe for browser/edge.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** Review surfaces, ordered most- to least-sensitive. Mirrors macp risk-floor. */
|
||||||
|
export const REVIEW_SURFACES = [
|
||||||
|
'auth',
|
||||||
|
'data',
|
||||||
|
'infra',
|
||||||
|
'build',
|
||||||
|
'ui',
|
||||||
|
'test',
|
||||||
|
'docs',
|
||||||
|
'none',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ReviewSurfaceSchema = z.enum(REVIEW_SURFACES);
|
||||||
|
export type ReviewSurface = z.infer<typeof ReviewSurfaceSchema>;
|
||||||
|
|
||||||
|
/** SELF-REPORTED: the single most-likely way the work is wrong. */
|
||||||
|
export const MostLikelyWrongSchema = z.object({
|
||||||
|
surface: ReviewSurfaceSchema,
|
||||||
|
description: z.string(),
|
||||||
|
});
|
||||||
|
export type MostLikelyWrong = z.infer<typeof MostLikelyWrongSchema>;
|
||||||
|
|
||||||
|
/** MECHANICAL: output of the diff risk-floor (see `@mosaicstack/macp`). */
|
||||||
|
export const ReflectionRiskSchema = z.object({
|
||||||
|
needs_review: z.boolean(),
|
||||||
|
score: z.number().min(0).max(1),
|
||||||
|
surface: ReviewSurfaceSchema,
|
||||||
|
reason: z.string(),
|
||||||
|
});
|
||||||
|
export type ReflectionRisk = z.infer<typeof ReflectionRiskSchema>;
|
||||||
|
|
||||||
|
export const ReflectionModeSchema = z.enum(['off', 'solo', 'orchestrated']);
|
||||||
|
export type ReflectionMode = z.infer<typeof ReflectionModeSchema>;
|
||||||
|
|
||||||
|
export const ReflectionProvenanceSchema = z.object({
|
||||||
|
source: z.literal('stop-hook'),
|
||||||
|
reflection_attempt: z.number().int().min(1),
|
||||||
|
degraded: z.boolean(),
|
||||||
|
reflection_mode: ReflectionModeSchema,
|
||||||
|
});
|
||||||
|
export type ReflectionProvenance = z.infer<typeof ReflectionProvenanceSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The self-reported half of a reflection. Supplied by the agent out-of-band
|
||||||
|
* (e.g. `<repo>/.mosaic/reflection-input.json`) and merged by the hook. All
|
||||||
|
* fields optional; missing fields become `null` in the assembled record.
|
||||||
|
*/
|
||||||
|
export const ReflectionSelfReportSchema = z.object({
|
||||||
|
confidence: z.number().min(0).max(1).nullable().optional(),
|
||||||
|
most_likely_wrong: MostLikelyWrongSchema.nullable().optional(),
|
||||||
|
known_not_in_diff: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
export type ReflectionSelfReport = z.infer<typeof ReflectionSelfReportSchema>;
|
||||||
|
|
||||||
|
/** The full assembled `reflection.v1` sidecar. */
|
||||||
|
export const ReflectionV1Schema = z.object({
|
||||||
|
schema: z.literal('reflection.v1'),
|
||||||
|
task_ref: z.string(),
|
||||||
|
agent: z.string(),
|
||||||
|
session_id: z.string(),
|
||||||
|
timestamp: z.string(),
|
||||||
|
repo: z.string(),
|
||||||
|
confidence: z.number().min(0).max(1).nullable(),
|
||||||
|
most_likely_wrong: MostLikelyWrongSchema.nullable(),
|
||||||
|
known_not_in_diff: z.string().nullable(),
|
||||||
|
risk: ReflectionRiskSchema,
|
||||||
|
files_changed: z.array(z.string()),
|
||||||
|
provenance: ReflectionProvenanceSchema,
|
||||||
|
});
|
||||||
|
export type ReflectionV1 = z.infer<typeof ReflectionV1Schema>;
|
||||||
|
|
||||||
|
export const REFLECTION_SCHEMA_ID = 'reflection.v1' as const;
|
||||||
111
scripts/analysis/reflect-board-history.sh
Executable file
111
scripts/analysis/reflect-board-history.sh
Executable file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# reflect-board-history.sh — Phase-0 experiment P3 (outcome detectability)
|
||||||
|
#
|
||||||
|
# Question: for completed tasks, how often does a machine-detectable
|
||||||
|
# correct/wrong outcome signal appear within a follow-up window (default 30d)?
|
||||||
|
# If the base rate is too low, predicted-vs-actual calibration (design §7) has
|
||||||
|
# nothing to score against, so the kernel should capture caveat-notes only.
|
||||||
|
#
|
||||||
|
# Method: consume a board/task export (JSONL, one task object per line) OR fall
|
||||||
|
# back to scanning the git history of a `data/` task directory. For each task
|
||||||
|
# that reached a "done"-like state, decide whether a later signal marks it
|
||||||
|
# correct or wrong (reopen, revert, follow-up "fix"/"regression", explicit
|
||||||
|
# outcome field). Emit the detectable-outcome base rate. HARNESS + RUBRIC.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/analysis/reflect-board-history.sh --jsonl FILE [--window-days N] [--json|--md]
|
||||||
|
# scripts/analysis/reflect-board-history.sh --data-dir DIR [--window-days N] [--json|--md]
|
||||||
|
#
|
||||||
|
# JSONL fields used (best-effort): .id .status .completed_at .outcome
|
||||||
|
# .reopened_at .followups[] (free-form). Missing fields are tolerated.
|
||||||
|
#
|
||||||
|
# Requirements: jq (for --jsonl), git (for --data-dir), awk.
|
||||||
|
#
|
||||||
|
# PRE-REGISTERED KILL CONDITION:
|
||||||
|
# detectable-outcome base rate < 20% ⇒ do NOT build §7 calibration loop;
|
||||||
|
# capture caveat-notes only.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
JSONL=""
|
||||||
|
DATA_DIR=""
|
||||||
|
WINDOW_DAYS=30
|
||||||
|
FORMAT="json"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--jsonl) JSONL="$2"; shift 2 ;;
|
||||||
|
--data-dir) DATA_DIR="$2"; shift 2 ;;
|
||||||
|
--window-days) WINDOW_DAYS="$2"; shift 2 ;;
|
||||||
|
--json) FORMAT="json"; shift ;;
|
||||||
|
--md) FORMAT="md"; shift ;;
|
||||||
|
-h|--help) sed -n '2,32p' "$0"; exit 0 ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
KILL_CONDITION='detectable-outcome base rate < 20% ⇒ do NOT build §7 calibration loop'
|
||||||
|
echo "# pre-registered kill condition: ${KILL_CONDITION}" >&2
|
||||||
|
|
||||||
|
done_total=0
|
||||||
|
detectable=0
|
||||||
|
|
||||||
|
if [[ -n "$JSONL" ]]; then
|
||||||
|
command -v jq >/dev/null 2>&1 || { echo "jq required for --jsonl" >&2; exit 3; }
|
||||||
|
[[ -r "$JSONL" ]] || { echo "cannot read $JSONL" >&2; exit 3; }
|
||||||
|
# Count done tasks and those with a machine-detectable outcome signal.
|
||||||
|
done_total="$(jq -rs '[.[] | select((.status // "") | test("done|complete|closed"; "i"))] | length' "$JSONL" 2>/dev/null || echo 0)"
|
||||||
|
detectable="$(jq -rs '
|
||||||
|
[ .[]
|
||||||
|
| select((.status // "") | test("done|complete|closed"; "i"))
|
||||||
|
| select(
|
||||||
|
(.outcome // null) != null
|
||||||
|
or (.reopened_at // null) != null
|
||||||
|
or ((.followups // []) | length) > 0
|
||||||
|
)
|
||||||
|
] | length' "$JSONL" 2>/dev/null || echo 0)"
|
||||||
|
elif [[ -n "$DATA_DIR" ]]; then
|
||||||
|
command -v git >/dev/null 2>&1 || { echo "git required for --data-dir" >&2; exit 3; }
|
||||||
|
[[ -d "$DATA_DIR" ]] || { echo "no such dir: $DATA_DIR" >&2; exit 3; }
|
||||||
|
# Proxy: a task file later touched by a commit whose subject signals a
|
||||||
|
# correction is a "detectable outcome".
|
||||||
|
while IFS= read -r file; do
|
||||||
|
[[ -z "$file" ]] && continue
|
||||||
|
done_total=$((done_total + 1))
|
||||||
|
if git -C "$DATA_DIR" log --since="${WINDOW_DAYS} days ago" --pretty='%s' -- "$file" 2>/dev/null \
|
||||||
|
| grep -qiE 'reopen|revert|fix|regression|wrong|incorrect|redo'; then
|
||||||
|
detectable=$((detectable + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$DATA_DIR" -type f -name '*.json' 2>/dev/null)
|
||||||
|
else
|
||||||
|
echo "provide --jsonl FILE or --data-dir DIR" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
rate="$(awk "BEGIN{ if ($done_total==0) print \"0.0\"; else printf \"%.1f\", 100*$detectable/$done_total }")"
|
||||||
|
verdict="$(awk "BEGIN{print ($rate < 20.0) ? \"KILL §7 — caveat-notes only\" : \"signal present — proceed\"}")"
|
||||||
|
|
||||||
|
if [[ "$FORMAT" == "md" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
## P3 — outcome detectability
|
||||||
|
|
||||||
|
- done-like tasks: **${done_total}**
|
||||||
|
- with machine-detectable outcome (window ${WINDOW_DAYS}d): **${detectable}**
|
||||||
|
- base rate: **${rate}%**
|
||||||
|
- kill condition: ${KILL_CONDITION}
|
||||||
|
- verdict: **${verdict}**
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
awk -v dt="$done_total" -v d="$detectable" -v r="$rate" -v w="$WINDOW_DAYS" \
|
||||||
|
-v v="$verdict" -v kc="$KILL_CONDITION" 'BEGIN{
|
||||||
|
printf "{\n"
|
||||||
|
printf " \"experiment\": \"P3-board-history\",\n"
|
||||||
|
printf " \"window_days\": %d,\n", w
|
||||||
|
printf " \"done_tasks\": %d,\n", dt
|
||||||
|
printf " \"detectable_outcomes\": %d,\n", d
|
||||||
|
printf " \"base_rate_pct\": %s,\n", r
|
||||||
|
printf " \"kill_condition\": \"%s\",\n", kc
|
||||||
|
printf " \"verdict\": \"%s\"\n", v
|
||||||
|
printf "}\n"
|
||||||
|
}'
|
||||||
|
fi
|
||||||
117
scripts/analysis/reflect-calibration.sh
Executable file
117
scripts/analysis/reflect-calibration.sh
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# reflect-calibration.sh — Phase-0 experiment P1 (confidence signal)
|
||||||
|
#
|
||||||
|
# Question: does an agent's self-reported confidence discriminate correct from
|
||||||
|
# incorrect work — especially on the self-rated-HIGH subset, where a closed
|
||||||
|
# loop would actually trust it? If confidence ≈ chance on the high subset, the
|
||||||
|
# signal is useless and design §7–§8 should not be built.
|
||||||
|
#
|
||||||
|
# Method: consume a labelled corpus — JSONL of {confidence: 0..1, correct:
|
||||||
|
# true|false}. Compute discrimination as ROC AUC over all rows, plus the
|
||||||
|
# correct-rate (lift) on the high-confidence subset (>= threshold), and compare
|
||||||
|
# to the pre-registered chance baseline (the overall correct-rate). HARNESS +
|
||||||
|
# RUBRIC; the labelled corpus is supplied later.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/analysis/reflect-calibration.sh --jsonl FILE [--high 0.8] [--json|--md]
|
||||||
|
#
|
||||||
|
# Requirements: jq, awk.
|
||||||
|
#
|
||||||
|
# PRE-REGISTERED KILL CONDITION:
|
||||||
|
# AUC <= 0.60 OR high-subset lift <= +5pp over base rate
|
||||||
|
# ⇒ confidence is not a usable routing signal; do NOT build §7–§8.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
JSONL=""
|
||||||
|
HIGH=0.8
|
||||||
|
FORMAT="json"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--jsonl) JSONL="$2"; shift 2 ;;
|
||||||
|
--high) HIGH="$2"; shift 2 ;;
|
||||||
|
--json) FORMAT="json"; shift ;;
|
||||||
|
--md) FORMAT="md"; shift ;;
|
||||||
|
-h|--help) sed -n '2,27p' "$0"; exit 0 ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
KILL_CONDITION='AUC <= 0.60 OR high-subset lift <= +5pp ⇒ do NOT build §7–§8'
|
||||||
|
echo "# pre-registered kill condition: ${KILL_CONDITION}" >&2
|
||||||
|
|
||||||
|
command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 3; }
|
||||||
|
[[ -r "$JSONL" ]] || { echo "provide a readable --jsonl FILE" >&2; exit 2; }
|
||||||
|
|
||||||
|
# Normalise to "<confidence> <0|1>" rows; tolerate bad lines.
|
||||||
|
ROWS="$(jq -rs '
|
||||||
|
[ .[] | select((.confidence|type)=="number") |
|
||||||
|
"\(.confidence) \((.correct==true) | if . then 1 else 0 end)" ]
|
||||||
|
| .[]' "$JSONL" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -z "$ROWS" ]]; then
|
||||||
|
echo '{ "experiment": "P1-calibration", "error": "no usable rows" }'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# AUC via the Mann–Whitney U relation (rank-based); base rate; high-subset lift.
|
||||||
|
read -r N POS BASE AUC HIGH_N HIGH_CORRECT HIGH_RATE LIFT <<EOF
|
||||||
|
$(printf '%s\n' "$ROWS" | awk -v high="$HIGH" '
|
||||||
|
{ c=$1; y=$2; conf[NR]=c; lab[NR]=y; n++;
|
||||||
|
if (y==1) pos++; else neg++;
|
||||||
|
if (c>=high) { hn++; if (y==1) hc++ } }
|
||||||
|
END{
|
||||||
|
base = (n>0)? pos/n : 0;
|
||||||
|
# Rank-sum AUC: average ranks (ties → average rank).
|
||||||
|
# sort indices by confidence
|
||||||
|
for (i=1;i<=n;i++) idx[i]=i;
|
||||||
|
for (i=1;i<=n;i++) for (j=i+1;j<=n;j++) if (conf[idx[i]]>conf[idx[j]]) { t=idx[i]; idx[i]=idx[j]; idx[j]=t }
|
||||||
|
i=1;
|
||||||
|
while (i<=n) {
|
||||||
|
j=i; while (j<n && conf[idx[j+1]]==conf[idx[i]]) j++;
|
||||||
|
avg=(i+j)/2.0;
|
||||||
|
for (k=i;k<=j;k++) rank[idx[k]]=avg;
|
||||||
|
i=j+1;
|
||||||
|
}
|
||||||
|
rsum=0; for (i=1;i<=n;i++) if (lab[i]==1) rsum+=rank[i];
|
||||||
|
if (pos>0 && neg>0) auc=(rsum - pos*(pos+1)/2.0)/(pos*neg); else auc=0.5;
|
||||||
|
hrate=(hn>0)? hc/hn : 0;
|
||||||
|
lift=hrate-base;
|
||||||
|
printf "%d %d %.4f %.4f %d %d %.4f %.4f", n, pos, base, auc, hn, hc, hrate, lift
|
||||||
|
}')
|
||||||
|
EOF
|
||||||
|
|
||||||
|
verdict="$(awk -v auc="$AUC" -v lift="$LIFT" 'BEGIN{
|
||||||
|
print (auc <= 0.60 || lift <= 0.05) ? "KILL §7–§8 — confidence not usable" : "signal present — proceed"
|
||||||
|
}')"
|
||||||
|
|
||||||
|
if [[ "$FORMAT" == "md" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
## P1 — confidence calibration
|
||||||
|
|
||||||
|
- rows: **${N}** (positives ${POS}) · base correct-rate **$(awk "BEGIN{printf \"%.1f\", 100*${BASE}}")%**
|
||||||
|
- ROC AUC: **${AUC}**
|
||||||
|
- high-confidence subset (>= ${HIGH}): n=${HIGH_N}, correct=${HIGH_CORRECT}, rate=$(awk "BEGIN{printf \"%.1f\", 100*${HIGH_RATE}}")%
|
||||||
|
- lift over base: **$(awk "BEGIN{printf \"%+.1f\", 100*${LIFT}}")pp**
|
||||||
|
- kill condition: ${KILL_CONDITION}
|
||||||
|
- verdict: **${verdict}**
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
awk -v n="$N" -v pos="$POS" -v base="$BASE" -v auc="$AUC" -v hn="$HIGH_N" \
|
||||||
|
-v hc="$HIGH_CORRECT" -v hr="$HIGH_RATE" -v lift="$LIFT" -v high="$HIGH" \
|
||||||
|
-v v="$verdict" -v kc="$KILL_CONDITION" 'BEGIN{
|
||||||
|
printf "{\n"
|
||||||
|
printf " \"experiment\": \"P1-calibration\",\n"
|
||||||
|
printf " \"rows\": %d,\n", n
|
||||||
|
printf " \"positives\": %d,\n", pos
|
||||||
|
printf " \"base_rate\": %.4f,\n", base
|
||||||
|
printf " \"auc\": %.4f,\n", auc
|
||||||
|
printf " \"high_threshold\": %s,\n", high
|
||||||
|
printf " \"high_subset\": { \"n\": %d, \"correct\": %d, \"rate\": %.4f },\n", hn, hc, hr
|
||||||
|
printf " \"lift_over_base\": %.4f,\n", lift
|
||||||
|
printf " \"kill_condition\": \"%s\",\n", kc
|
||||||
|
printf " \"verdict\": \"%s\"\n", v
|
||||||
|
printf "}\n"
|
||||||
|
}'
|
||||||
|
fi
|
||||||
110
scripts/analysis/reflect-git-history.sh
Executable file
110
scripts/analysis/reflect-git-history.sh
Executable file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# reflect-git-history.sh — Phase-0 experiment P2 ("only-self-reflection" bucket)
|
||||||
|
#
|
||||||
|
# Question: of the failures visible in git history, what fraction would ONLY
|
||||||
|
# have been caught by end-of-run self-reflection — i.e. NOT by CI and NOT by
|
||||||
|
# independent human review? If that bucket is near-empty, the closed
|
||||||
|
# calibration / skill-synthesis loop (design §7–§8) is not worth building.
|
||||||
|
#
|
||||||
|
# Method: scan `git log` over a window for failure signals (reverts, and
|
||||||
|
# fix:/hotfix commits landing shortly after a feature merge). Classify each by
|
||||||
|
# the gate most likely to have caught it, using a pre-registered heuristic.
|
||||||
|
# This is a HARNESS + RUBRIC; the classifier is deliberately simple and the
|
||||||
|
# real corpus/labelling is wired later. It emits a structured tally.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/analysis/reflect-git-history.sh [--repo PATH] [--since SINCE] [--json|--md]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --repo PATH repo to analyse (default: current repo)
|
||||||
|
# --since SINCE git log --since value (default: "6 months ago")
|
||||||
|
# --json emit JSON (default)
|
||||||
|
# --md emit markdown
|
||||||
|
#
|
||||||
|
# Requirements: git, awk.
|
||||||
|
#
|
||||||
|
# PRE-REGISTERED KILL CONDITION:
|
||||||
|
# bucket "only_self_reflection" is near-empty (< 10% of classified failures)
|
||||||
|
# ⇒ do NOT build design §7–§8 (closed loop). Caveat-notes capture only.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO="."
|
||||||
|
SINCE="6 months ago"
|
||||||
|
FORMAT="json"
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--repo) REPO="$2"; shift 2 ;;
|
||||||
|
--since) SINCE="$2"; shift 2 ;;
|
||||||
|
--json) FORMAT="json"; shift ;;
|
||||||
|
--md) FORMAT="md"; shift ;;
|
||||||
|
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
KILL_CONDITION='bucket only_self_reflection < 10% of classified failures ⇒ do NOT build §7–§8'
|
||||||
|
echo "# pre-registered kill condition: ${KILL_CONDITION}" >&2
|
||||||
|
|
||||||
|
command -v git >/dev/null 2>&1 || { echo "git required" >&2; exit 3; }
|
||||||
|
|
||||||
|
# Collect candidate failure commits: reverts + fix/hotfix subjects.
|
||||||
|
mapfile -t LINES < <(
|
||||||
|
git -C "$REPO" log --since="$SINCE" --pretty='%H%x09%s' 2>/dev/null \
|
||||||
|
| grep -iE 'revert|hotfix|hot-fix|regression|fix(\(|:|!| )' || true
|
||||||
|
)
|
||||||
|
|
||||||
|
total=0; ci=0; human=0; selfonly=0
|
||||||
|
for line in "${LINES[@]}"; do
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
subj="${line#*$'\t'}"
|
||||||
|
total=$((total + 1))
|
||||||
|
# Pre-registered classification heuristic (gate most likely to have caught it):
|
||||||
|
# - build/test/lint/type/ci signals → CI would have caught it
|
||||||
|
# - security/auth/permission/data/migration → human review would flag it
|
||||||
|
# - everything else (logic/UX/assumption/edge) → only-self-reflection bucket
|
||||||
|
if printf '%s' "$subj" | grep -qiE 'test|lint|type|build|ci|compile|typo'; then
|
||||||
|
ci=$((ci + 1))
|
||||||
|
elif printf '%s' "$subj" | grep -qiE 'security|auth|permission|rbac|secret|migration|data|sql|injection'; then
|
||||||
|
human=$((human + 1))
|
||||||
|
else
|
||||||
|
selfonly=$((selfonly + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
pct() { awk "BEGIN{ if ($2==0) print \"0.0\"; else printf \"%.1f\", 100*$1/$2 }"; }
|
||||||
|
self_pct="$(pct "$selfonly" "$total")"
|
||||||
|
verdict="$(awk "BEGIN{print ($self_pct < 10.0) ? \"KILL §7–§8\" : \"signal present — proceed to deeper labelling\"}")"
|
||||||
|
|
||||||
|
if [[ "$FORMAT" == "md" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
## P2 — git-history failure-gate attribution
|
||||||
|
|
||||||
|
- window: \`${SINCE}\` · repo: \`${REPO}\`
|
||||||
|
- classified failures: **${total}**
|
||||||
|
|
||||||
|
| gate | count | share |
|
||||||
|
|---|---:|---:|
|
||||||
|
| CI would catch | ${ci} | $(pct "$ci" "$total")% |
|
||||||
|
| human review would catch | ${human} | $(pct "$human" "$total")% |
|
||||||
|
| only-self-reflection | ${selfonly} | ${self_pct}% |
|
||||||
|
|
||||||
|
- kill condition: ${KILL_CONDITION}
|
||||||
|
- verdict: **${verdict}**
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
awk -v t="$total" -v c="$ci" -v h="$human" -v s="$selfonly" -v sp="$self_pct" \
|
||||||
|
-v v="$verdict" -v since="$SINCE" -v repo="$REPO" -v kc="$KILL_CONDITION" 'BEGIN{
|
||||||
|
printf "{\n"
|
||||||
|
printf " \"experiment\": \"P2-git-history\",\n"
|
||||||
|
printf " \"repo\": \"%s\",\n", repo
|
||||||
|
printf " \"since\": \"%s\",\n", since
|
||||||
|
printf " \"classified_failures\": %d,\n", t
|
||||||
|
printf " \"buckets\": { \"ci\": %d, \"human_review\": %d, \"only_self_reflection\": %d },\n", c, h, s
|
||||||
|
printf " \"only_self_reflection_pct\": %s,\n", sp
|
||||||
|
printf " \"kill_condition\": \"%s\",\n", kc
|
||||||
|
printf " \"verdict\": \"%s\"\n", v
|
||||||
|
printf "}\n"
|
||||||
|
}'
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user