Compare commits
1 Commits
feat/fleet
...
docs/merge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01b05614ff |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,6 +12,3 @@ docs/reports/
|
||||
|
||||
# Step-CA dev password — real file is gitignored; commit only the .example
|
||||
infra/step-ca/dev-password
|
||||
|
||||
# Scratch dirs created by the framework git-wrapper shell test harnesses
|
||||
.mosaic-test-work/
|
||||
|
||||
@@ -3,8 +3,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { AppserviceDaemon } from '../server.js';
|
||||
import type { DaemonConfig, DaemonRequest } from '../server.js';
|
||||
|
||||
const AGENTS_TYPE = 'org.uscllc.mosaic_as.agents';
|
||||
|
||||
const cfg: DaemonConfig = {
|
||||
homeserverUrl: 'https://hs.example',
|
||||
domain: 'hs.example',
|
||||
@@ -230,149 +228,6 @@ describe('AppserviceDaemon routing', () => {
|
||||
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 () => {
|
||||
const daemon = new AppserviceDaemon({ ...cfg, bridgeTokens: [] }, undefined, () => {});
|
||||
const res = await daemon.handle(
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import {
|
||||
AgentTokenStore,
|
||||
AppserviceIntent,
|
||||
TransactionHandler,
|
||||
validateBridgeMessage,
|
||||
validateBridgeTyping,
|
||||
validateProvisionRoom,
|
||||
validateRegisterAgent,
|
||||
validateRevokeAgent,
|
||||
} from '@mosaicstack/appservice';
|
||||
import type { AppserviceConfig, MatrixEvent } from '@mosaicstack/appservice';
|
||||
|
||||
@@ -40,13 +37,6 @@ const safeEqual = (a: string, b: string): boolean => timingSafeEqual(digest(a),
|
||||
|
||||
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
|
||||
* Application Service transactions endpoint (Synapse-facing) plus the
|
||||
@@ -56,7 +46,6 @@ export type BridgePrincipal = { kind: 'host' } | { kind: 'agent'; agentUserId: s
|
||||
export class AppserviceDaemon {
|
||||
readonly intent: AppserviceIntent;
|
||||
private readonly transactions: TransactionHandler;
|
||||
private readonly agents: AgentTokenStore;
|
||||
|
||||
constructor(
|
||||
private readonly cfg: DaemonConfig,
|
||||
@@ -64,7 +53,6 @@ export class AppserviceDaemon {
|
||||
private readonly log: (line: string) => void = (line) => console.log(line),
|
||||
) {
|
||||
this.intent = new AppserviceIntent(cfg, fetchImpl);
|
||||
this.agents = new AgentTokenStore(this.intent);
|
||||
this.transactions = new TransactionHandler({
|
||||
hsToken: cfg.hsToken,
|
||||
onEvent: (event) => this.onEvent(event),
|
||||
@@ -81,20 +69,10 @@ export class AppserviceDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the calling principal, or null when unauthorized. Fail-closed:
|
||||
* 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;
|
||||
private bridgeAuthorized(authorizationHeader: string | undefined): boolean {
|
||||
if (!authorizationHeader?.startsWith('Bearer ')) return false;
|
||||
const presented = authorizationHeader.slice('Bearer '.length);
|
||||
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;
|
||||
return this.cfg.bridgeTokens.some((token) => safeEqual(presented, token));
|
||||
}
|
||||
|
||||
async handle(req: DaemonRequest): Promise<DaemonResponse> {
|
||||
@@ -111,60 +89,12 @@ export class AppserviceDaemon {
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/bridge/v1/')) {
|
||||
const principal = await this.bridgeAuthorized(req.authorizationHeader);
|
||||
if (!principal) {
|
||||
if (!this.bridgeAuthorized(req.authorizationHeader)) {
|
||||
return { status: 403, body: { errcode: 'M_FORBIDDEN', error: 'bad bridge token' } };
|
||||
}
|
||||
try {
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/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') {
|
||||
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({
|
||||
roomId: req.body.room_id,
|
||||
agent: req.body.agent,
|
||||
@@ -177,15 +107,6 @@ export class AppserviceDaemon {
|
||||
}
|
||||
if (req.method === 'POST' && req.path === '/bridge/v1/typing') {
|
||||
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);
|
||||
return { status: 200, body: {} };
|
||||
}
|
||||
|
||||
@@ -64,7 +64,6 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
||||
21. `@mosaicstack/cli` — unified `mosaic` CLI
|
||||
22. Docker Compose deployment + bare-metal capability
|
||||
23. Agent log service — ingest, parse, tier, summarize agent interaction logs
|
||||
24. Local durable agent fleet canary — `mosaic fleet` / `mosaic agent` CLI for an isolated tmux-backed canary fleet using a named socket, with roster-driven local customization and rollback-safe verification
|
||||
|
||||
### Out of Scope (v0.1.0)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
3. [Provider Configuration](#provider-configuration)
|
||||
4. [MCP Server Configuration](#mcp-server-configuration)
|
||||
5. [Environment Variables Reference](#environment-variables-reference)
|
||||
6. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
5. [Adding New MCP Tools](#adding-new-mcp-tools)
|
||||
6. [Database Schema and Migrations](#database-schema-and-migrations)
|
||||
7. [API Endpoint Reference](#api-endpoint-reference)
|
||||
8. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# Local Fleet Canary
|
||||
|
||||
The local fleet canary runs a small tmux-backed Mosaic agent fleet on an
|
||||
isolated tmux socket. The default socket is `mosaic-factory`; the commands do
|
||||
not use or stop the default tmux server.
|
||||
|
||||
## Files
|
||||
|
||||
Product-owned defaults:
|
||||
|
||||
- `packages/mosaic/framework/fleet/roster.schema.json`
|
||||
- `packages/mosaic/framework/fleet/examples/minimal.yaml`
|
||||
- `packages/mosaic/framework/fleet/examples/local-canary.yaml`
|
||||
- `packages/mosaic/framework/systemd/user/mosaic-tmux-holder.service`
|
||||
- `packages/mosaic/framework/systemd/user/mosaic-agent@.service`
|
||||
- `packages/mosaic/framework/tools/fleet/start-agent-session.sh`
|
||||
- `packages/mosaic/framework/tools/tmux/agent-send.sh`
|
||||
- `packages/mosaic/framework/tools/tmux/send-message.sh`
|
||||
|
||||
Site-owned local roster:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/roster.yaml
|
||||
```
|
||||
|
||||
Do not put a host-specific full roster into product defaults. Start from an
|
||||
example and edit the local roster after `mosaic fleet init --write`.
|
||||
|
||||
## Install
|
||||
|
||||
Minimal canary:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile minimal --write
|
||||
# If a site-owned roster already exists, inspect it first; overwrite only explicitly:
|
||||
# mosaic fleet init --profile minimal --write --force
|
||||
mosaic fleet install-systemd
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start
|
||||
mosaic fleet verify
|
||||
```
|
||||
|
||||
Small dogfood roster:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile local-canary --write
|
||||
# Use --force only after preserving any site-owned roster changes.
|
||||
mosaic fleet install-systemd
|
||||
systemctl --user daemon-reload
|
||||
mosaic fleet start
|
||||
mosaic fleet status
|
||||
```
|
||||
|
||||
## Agent Operations
|
||||
|
||||
```bash
|
||||
mosaic agent roster
|
||||
mosaic agent status
|
||||
mosaic agent status canary-pi
|
||||
mosaic agent send canary-pi --message "status check"
|
||||
mosaic agent reset canary-pi --new
|
||||
mosaic agent tail canary-pi -n 80
|
||||
```
|
||||
|
||||
These commands read the roster and target the configured tmux socket. The
|
||||
generated systemd agent services use `start-agent-session.sh`; message delivery
|
||||
uses the tmux send tools with `-L mosaic-factory`.
|
||||
|
||||
## Verification
|
||||
|
||||
Use these checks before expanding the roster:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-factory ls
|
||||
tmux ls
|
||||
mosaic fleet verify
|
||||
systemctl --user status mosaic-tmux-holder.service
|
||||
```
|
||||
|
||||
Expected results:
|
||||
|
||||
- `tmux -L mosaic-factory ls` shows `_holder` and roster agent sessions.
|
||||
- `tmux ls` shows only the default tmux server sessions and is not changed by
|
||||
fleet start/stop operations.
|
||||
- `mosaic fleet verify` checks exact session targets on the isolated socket.
|
||||
|
||||
## Rollback
|
||||
|
||||
Stop the local canary:
|
||||
|
||||
```bash
|
||||
mosaic fleet stop
|
||||
systemctl --user disable mosaic-agent@canary-pi.service
|
||||
systemctl --user disable mosaic-tmux-holder.service
|
||||
systemctl --user daemon-reload
|
||||
```
|
||||
|
||||
For a full local cleanup of generated canary files:
|
||||
|
||||
```bash
|
||||
rm -f ~/.config/systemd/user/mosaic-agent@.service
|
||||
rm -f ~/.config/systemd/user/mosaic-tmux-holder.service
|
||||
rm -rf ~/.config/mosaic/fleet
|
||||
rm -rf ~/.config/mosaic/tools/fleet
|
||||
```
|
||||
|
||||
This rollback leaves the default tmux server untouched. If a canary session is
|
||||
still present after service stop, remove only the isolated socket server:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-factory kill-server
|
||||
```
|
||||
@@ -10,7 +10,6 @@
|
||||
6. [CLI Usage](#cli-usage)
|
||||
7. [Sub-package Commands](#sub-package-commands)
|
||||
8. [Telemetry](#telemetry)
|
||||
9. [Local Fleet Canary](./fleet-local-canary.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,52 +0,0 @@
|
||||
# Fleet CLI Local Canary Dogfood — 2026-06-20
|
||||
|
||||
## Objective
|
||||
|
||||
Move the durable tmux fleet PoC into a functional local canary on this server. This is **not** production deployment. It is a canary/dogfood path for a small local agent fleet using an isolated tmux socket.
|
||||
|
||||
## Issue
|
||||
|
||||
- Gitea issue: #562 — `feat(fleet): local CLI canary dogfood`
|
||||
|
||||
## Scope
|
||||
|
||||
Implement enough product surface to use the fleet locally:
|
||||
|
||||
- `mosaic fleet init/install/start/stop/restart/status/verify`
|
||||
- `mosaic agent roster/status/send/reset/tail`
|
||||
- roster schema and examples
|
||||
- local canary docs and rollback instructions
|
||||
- tests for CLI behavior where practical
|
||||
- canary verification on named tmux socket `mosaic-factory`
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No production rollout.
|
||||
- No migration of existing default tmux sessions.
|
||||
- No image build/deploy work.
|
||||
- No hardcoded USC/local roster as product default.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- CLI can initialize a minimal roster outside product defaults.
|
||||
- CLI can install user systemd units and fleet helper scripts to a configurable Mosaic home.
|
||||
- CLI can start/stop/status/verify a canary fleet using `mosaic-factory`.
|
||||
- `mosaic agent send` uses existing named-socket/exact-target tmux tooling.
|
||||
- `mosaic agent reset` targets only the named agent session on the named socket.
|
||||
- Verification proves default tmux sessions remain untouched.
|
||||
- Baseline repo gates pass.
|
||||
- PR CI is green before merge.
|
||||
- Local canary evidence is captured after merge/install.
|
||||
|
||||
## Budget / Routing
|
||||
|
||||
- Agent: codex preferred.
|
||||
- Estimate: 25K-40K tokens.
|
||||
- Worker owns implementation/tests/docs in branch `feat/fleet-cli-local-canary`.
|
||||
- Orchestrator owns `docs/TASKS.md`, issue/PR/merge, and local canary install verification.
|
||||
|
||||
## Progress
|
||||
|
||||
- 2026-06-20: #557 PoC primitives merged to `main` as `45e2c2a`.
|
||||
- 2026-06-20: issue #562 created for local CLI canary dogfood.
|
||||
- 2026-06-20: worktree created at `/home/jarvis/src/mosaicstack-stack-worktrees/fleet-cli-local-canary`.
|
||||
@@ -1,50 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,55 +0,0 @@
|
||||
# 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
|
||||
@@ -1,87 +0,0 @@
|
||||
# Wrapper hardening fold-in: #559 (eval removal) + #560 (host-derived login)
|
||||
|
||||
**Branch:** `fix/wrapper-hardening-tls-credpath-cicwait` (PR #551)
|
||||
**Worker:** coderlite0 (Sonnet lane) · coordinated by mos-claude
|
||||
**Date:** 2026-06-20
|
||||
**Scope:** `packages/mosaic/framework/tools/git/*.sh` only
|
||||
|
||||
## What the issues asked for vs. what was already landed
|
||||
|
||||
Both issues were largely satisfied by prior merged work; this fold-in closes the
|
||||
remaining gaps (regression tests + a loud diagnostic + one residual word-split site)
|
||||
rather than re-implementing finished functionality.
|
||||
|
||||
### #559 — remove `eval` from issue-create.sh (and siblings)
|
||||
|
||||
- `eval`-based command construction was already removed across the wrapper surface
|
||||
(landed in #549). A full scan of `tools/git/*.sh` finds **zero** `eval` usages.
|
||||
- `issue-create.sh`, `pr-create.sh`, `issue-edit.sh`, `issue-assign.sh` already build
|
||||
their `tea`/`gh` invocations as argv arrays (`CMD=(...)`, `"${CMD[@]}"`), so Markdown
|
||||
bodies pass through verbatim.
|
||||
- **Residual found & fixed:** `issue-comment.sh` still used unquoted
|
||||
`$(get_gitea_repo_args)` word-splitting (the comment body itself was already safely
|
||||
quoted, so no injection bug — but it was the inconsistent, fragile pattern #559 targets,
|
||||
and it failed silently when no login resolved). Converted to an argv array with an
|
||||
explicit, loud login-resolution error.
|
||||
- **Added regression test:** `test-issue-create-body-safety.sh` — feeds a hostile
|
||||
Markdown body (`$(touch SENTINEL)`, backticks, single/double quotes, `$HOME`/`${PATH}`,
|
||||
pipes/`&&`/`;`) through `issue-create.sh` and asserts (1) no command substitution
|
||||
executes (sentinel file never created) and (2) the `--description` `tea` receives is
|
||||
byte-for-byte the original body.
|
||||
|
||||
### #560 — auto-detect Gitea `--login` from repo origin host
|
||||
|
||||
- Centralized host→login resolution already exists in `detect-platform.sh`
|
||||
(`get_gitea_login_for_host` → `find_tea_login_for_host`, matching `urlparse(url).hostname`).
|
||||
Every wrapper routes through it (or `get_gitea_login` / `get_gitea_login_for_repo_override`);
|
||||
**no wrapper hardcodes `${GITEA_LOGIN:-mosaicstack}`**. Explicit `GITEA_LOGIN` wins only
|
||||
when it matches the host (`tea_login_matches_host`), so stale overrides are rejected.
|
||||
- **Gap fixed — silent failure → loud diagnostic:** the failure path of
|
||||
`get_gitea_login_for_host` returned non-zero with no message. Added
|
||||
`print_gitea_login_diagnostic`, emitted to **stderr** on resolution failure: names the
|
||||
unresolved host, lists available tea logins (name + host), and gives the `GITEA_LOGIN`
|
||||
override + `tea login add` fix. Stderr-only, so it never contaminates stdout (the
|
||||
resolved login name) or the log-grep assertions in the existing harnesses. Callers with
|
||||
an API fallback (pr-merge, issue-close, pr-create, issue-create) still follow with their
|
||||
own "using API fallback" line, giving a clear "no login → fallback" trail.
|
||||
- **Extended test:** `test-gitea-login-resolution.sh` now also asserts (a) the loud
|
||||
diagnostic fires and lists available logins for an unresolved host, (b) login is derived
|
||||
from origin host for **both** instances (mosaicstack + usc) via a scoped second `tea`
|
||||
mock, and (c) a valid `GITEA_LOGIN` override is honored. The scoped mock keeps the
|
||||
existing API-fallback assertions (which require mosaicstack to have _no_ tea login) valid.
|
||||
|
||||
## Files changed (wrapper surface only)
|
||||
|
||||
- `detect-platform.sh` — add `print_gitea_login_diagnostic`; call it on the
|
||||
`get_gitea_login_for_host` failure path.
|
||||
- `issue-comment.sh` — argv array + loud login-resolution error (was unquoted
|
||||
`$(get_gitea_repo_args)`).
|
||||
- `test-issue-create-body-safety.sh` — **new** (#559 regression).
|
||||
- `test-gitea-login-resolution.sh` — extended (#560 diagnostic + both-host + override).
|
||||
|
||||
## Verification
|
||||
|
||||
All wrapper harnesses pass locally:
|
||||
|
||||
- `test-issue-create-body-safety.sh` — PASS
|
||||
- `test-gitea-login-resolution.sh` — PASS
|
||||
- `test-pr-merge-gitea-empty-uid.sh` — PASS
|
||||
- `test-pr-metadata-gitea.sh` — PASS
|
||||
- `test-lane-brief-pr-linkage.sh` — PASS
|
||||
|
||||
## Open items flagged to mos-claude (orchestrator decisions)
|
||||
|
||||
1. **CHANGELOG absent.** The task said "update CHANGELOG (append-only), keep the existing
|
||||
#550/#551 entry." No CHANGELOG file exists anywhere in the repo, and #550/#551 are not
|
||||
recorded in one. **ASSUMPTION:** documenting #559/#560 in this scratchpad + the PR
|
||||
description (`Closes #559 Closes #560`) follows the repo's actual convention
|
||||
(`docs/scratchpads/`). Did not invent a new CHANGELOG structure.
|
||||
2. **`docs/TASKS.md` is orchestrator single-writer.** It carries a "Workers read but never
|
||||
modify" banner. As a worker I did **not** edit it; task tracking is via the linked Gitea
|
||||
issues #559/#560 + this scratchpad. Orchestrator may add a rollup row if desired.
|
||||
3. **Wrapper `test-*.sh` are not CI-wired.** `.woodpecker/ci.yml` runs `pnpm
|
||||
typecheck/lint/format:check/test` (`turbo run test`); the framework dir has no
|
||||
`package.json`, so these shell harnesses run **locally/manually only** — they do not gate
|
||||
the PR in Woodpecker. **ASSUMPTION:** out of scope to wire a shell-test step into CI in
|
||||
this PR (would broaden the diff beyond the wrapper surface). Flagging for a follow-up if
|
||||
the fleet wants these gated.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Fleet CLI Local Canary Review Fixes
|
||||
|
||||
## Objective
|
||||
|
||||
Fix only the two should-fix code review findings:
|
||||
|
||||
1. Ensure `@mosaicstack/mosaic` declares `yaml` and lockfile state is current.
|
||||
2. Validate `mosaic agent status [agent]` against the fleet roster before constructing/running the tmux target.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Do not modify `docs/TASKS.md`.
|
||||
- Leave changes uncommitted.
|
||||
- Run requested formatting and quality gates.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Inspect manifest/lockfile state for `yaml`.
|
||||
2. Add failing regression test for `mosaic agent status typo`.
|
||||
3. Patch `registerFleetAgentCommands` status validation.
|
||||
4. Format touched files.
|
||||
5. Run requested tests, typecheck, and lint.
|
||||
6. Review final diff.
|
||||
|
||||
## Progress
|
||||
|
||||
- Loaded required repo/global/runtime instructions.
|
||||
- Confirmed `packages/mosaic/package.json` already declares `yaml`.
|
||||
- Confirmed `pnpm-lock.yaml` already has `packages/mosaic` importer entry for `yaml`.
|
||||
- Found `registerFleetAgentCommands` status path does not validate agent before building tmux target.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
||||
failed before the production fix because `mosaic agent status typo` resolved instead of
|
||||
rejecting.
|
||||
- Focused green check: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts`
|
||||
passed after adding roster validation.
|
||||
- Formatting: `pnpm exec prettier --write packages/mosaic/src/commands/fleet.ts packages/mosaic/src/commands/fleet.spec.ts docs/scratchpads/fleet-cli-local-canary-review-fixes.md`
|
||||
completed with all files unchanged.
|
||||
- Requested tests: `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet.spec.ts src/cli-smoke.spec.ts`
|
||||
passed with 36 tests.
|
||||
- Baseline typecheck: `pnpm typecheck` passed.
|
||||
- Baseline lint: `pnpm lint` passed.
|
||||
- Independent review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
||||
returned approve with 0 findings. Note: reviewer reported broader context inspection was limited
|
||||
by its read-only sandbox, so review was based on the supplied diff.
|
||||
- `docs/TASKS.md` has no diff.
|
||||
|
||||
## Risks
|
||||
|
||||
- `docs/TASKS.md` intentionally untouched per user instruction.
|
||||
- Review finding 1 required no file edit: `packages/mosaic/package.json` already declares
|
||||
`yaml`, and the `packages/mosaic` importer in `pnpm-lock.yaml` already includes `yaml`.
|
||||
@@ -51,48 +51,3 @@ This repository currently has no root `CHANGELOG.md`; the scratchpad and `docs/T
|
||||
- PR #1908: `Dry run: would merge PR #1908 on git.uscllc.com with authenticated Gitea API fallback (base=main, method=squash).`
|
||||
- PR: `https://git.mosaicstack.dev/mosaicstack/stack/pulls/518`, branch `fix/t-a292e96f-gitea-pr-metadata`.
|
||||
- CI: Recent PR/push pipelines failed before clone/test execution due Woodpecker/Kubernetes PVC API timeout: `dial tcp 10.43.0.1:443: i/o timeout`. No repository test step executed in CI; local targeted verification above remains clean.
|
||||
|
||||
## 2026-06-18 — PR #549 functional blocker remediation
|
||||
|
||||
### Assignment
|
||||
|
||||
Coordinator `mos-claude` assigned remediation for PR #549: fix `packages/mosaic/framework/tools/git/pr-metadata.sh` tmpfile cleanup where an `EXIT` trap references function-local `body_file` after the function returns inside `RAW=$(...)`, producing `body_file: unbound variable` on the authenticated success path and failing to clean up safely on early `set -e` exits.
|
||||
|
||||
### Plan
|
||||
|
||||
1. Add a non-vacuous Gitea test that exercises `curl_gitea_pull` with stubbed `curl` and `GITEA_TOKEN` instead of `MOSAIC_GITEA_PR_METADATA_RAW_FILE`.
|
||||
2. Prove the new test is RED against the current PR head.
|
||||
3. Replace the function-local `EXIT` cleanup with robust function-scoped tmpfile cleanup.
|
||||
4. Re-run targeted tests, `bash -n`, and review gates; commit and push branch only. Do not merge.
|
||||
|
||||
### Constraints / assumptions
|
||||
|
||||
- Do not modify prior injection/JSON fixes in `issue-edit`, `issue-assign`, or `milestone-create`.
|
||||
- Worker role: do not modify `docs/TASKS.md`; orchestrator remains the single writer.
|
||||
- Budget: no explicit token cap provided; keep scope to shell wrapper + targeted regression harness.
|
||||
|
||||
### Remediation results
|
||||
|
||||
- Rebased `fix/tooling-eval-injection-jq-json` onto `origin/main`; branch was already current.
|
||||
- Added a curl-stub regression path that does not use `MOSAIC_GITEA_PR_METADATA_RAW_FILE`, so it exercises `curl_gitea_pull` and its temp body file.
|
||||
- RED evidence: copied the new harness next to the pre-fix `HEAD` version of `pr-metadata.sh`; `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-red-work .../test-pr-metadata-gitea.sh` failed with `body_file: unbound variable` on the curl success path.
|
||||
- Fix: replaced `EXIT` temp-file cleanup with a `RETURN`-scoped cleanup function that removes the body file while the function-local variable is still in scope, preserves the original return status, and clears the `RETURN` trap.
|
||||
- GREEN evidence:
|
||||
- `MOSAIC_TEST_WORK_DIR=$PWD/.mosaic-test-work/pr-metadata-gitea-current packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||
- `bash -n packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||
- `shellcheck -x -P . -e SC1090 packages/mosaic/framework/tools/git/pr-metadata.sh packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh` passed.
|
||||
|
||||
### Review remediation
|
||||
|
||||
- Codex review returned one should-fix: the early-exit test used `chmod 000`, which is not root-safe in container CI.
|
||||
- Remediation: changed the stubbed 2xx/cat-failure mode to replace the curl output with a broken symlink, which fails deterministically even as root and still validates cleanup via `rm -f -- "$body_file"`.
|
||||
|
||||
### Second review remediation
|
||||
|
||||
- Codex review found the 2xx `cat "$body_file"` read could be masked under command substitution semantics because the branch returned 0 unconditionally.
|
||||
- Remediation: both authenticated 2xx branches now use `cat "$body_file" || return $?` before returning success.
|
||||
- Strengthened the broken-symlink test to require the body-read failure and reject the later `Gitea API returned non-JSON` parse-failure path, so the test verifies the helper-level failure propagation rather than eventual downstream failure.
|
||||
|
||||
### Final review gate
|
||||
|
||||
- Codex review after remediation: approved (`0 blockers, 0 should-fix, 0 suggestions`).
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 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`.
|
||||
@@ -1,116 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
/** 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');
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,6 @@ export {
|
||||
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 {
|
||||
AppserviceConfig,
|
||||
EventHandler,
|
||||
|
||||
@@ -233,30 +233,4 @@ export class AppserviceIntent {
|
||||
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,11 +39,6 @@ export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './g
|
||||
|
||||
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
|
||||
export { nowISO, appendEvent, emitEvent } from './event-emitter.js';
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
{
|
||||
"$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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,39 +5,10 @@ Tool suites live at `~/.config/mosaic/tools/<suite>/`. This is the index only.
|
||||
read it (or the relevant service guide) when your task actually touches that service.
|
||||
Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
||||
|
||||
## ⚡ Most-used fleet tools (reach for these FIRST — don't hand-roll)
|
||||
|
||||
You are a Mosaic fleet agent. These cover the highest-frequency cross-agent and git-provider
|
||||
tasks — use them before improvising with raw `tmux send-keys`, raw `tea`/`gh`/`glab`, or `curl`.
|
||||
|
||||
**1. Message another agent** → `tools/tmux/agent-send.sh` (NOT raw `tmux send-keys`):
|
||||
|
||||
```bash
|
||||
tools/tmux/agent-send.sh -s <target-session> -m "message" # or -f <file> to send a file's contents
|
||||
```
|
||||
|
||||
The coordinator session is `mos-claude` — send status, findings, and questions there.
|
||||
|
||||
**2. Issues / PRs / milestones** → `tools/git/*.sh` wrappers (before raw `tea`/`gh`/`glab`):
|
||||
|
||||
```bash
|
||||
tools/git/pr-create.sh ... tools/git/issue-create.sh ... tools/git/pr-merge.sh ...
|
||||
tools/git/ci-queue-wait.sh --purpose push|merge # REQUIRED before any push/merge
|
||||
```
|
||||
|
||||
**GITEA_LOGIN gotcha** — the wrappers default to login `mosaicstack`; on a USC repo that fails with
|
||||
`gitea / Error: GetUserByName ... not found`. Pick the login from the repo's `origin` host first:
|
||||
|
||||
| origin host | login |
|
||||
| --------------------- | ---------------------------------------- |
|
||||
| `git.uscllc.com` | `export GITEA_LOGIN=usc` |
|
||||
| `git.mosaicstack.dev` | default `mosaicstack` (no export needed) |
|
||||
|
||||
## Suites (use wrappers first)
|
||||
|
||||
| Suite | Path | Purpose |
|
||||
| ---------- | ------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| tmux | `tools/tmux/agent-send.sh` | inter-agent messaging (see "Most-used" above) |
|
||||
| git | `tools/git/*.sh` | issues, PRs, milestones, CI queue guard (platform-auto-detected) |
|
||||
| woodpecker | `tools/woodpecker/*.sh` | CI pipelines (`-a mosaic`\|`usc`; match git remote host) |
|
||||
| portainer | `tools/portainer/*.sh` | Docker Swarm stacks (status/redeploy/list) |
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# Mosaic Fleet Rosters
|
||||
|
||||
The local fleet canary uses a product-owned roster schema with site-owned roster
|
||||
files. Product examples live here; active local rosters should live outside the
|
||||
package, normally at:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/roster.yaml
|
||||
```
|
||||
|
||||
The default tmux socket is `mosaic-factory` so fleet commands do not touch the
|
||||
default tmux server.
|
||||
|
||||
## Examples
|
||||
|
||||
- `examples/minimal.yaml` starts one local canary slot.
|
||||
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
||||
|
||||
Initialize a roster:
|
||||
|
||||
```bash
|
||||
mosaic fleet init --profile minimal --write
|
||||
mosaic fleet install-systemd
|
||||
mosaic fleet start
|
||||
mosaic fleet verify
|
||||
```
|
||||
@@ -1,27 +0,0 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~/src
|
||||
runtimes:
|
||||
claude:
|
||||
reset_command: /clear
|
||||
codex:
|
||||
reset_command: /clear
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: lead
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: coder0
|
||||
runtime: codex
|
||||
class: implementer
|
||||
reset_between_tasks: true
|
||||
- name: reviewer0
|
||||
runtime: pi
|
||||
class: reviewer
|
||||
reset_between_tasks: true
|
||||
@@ -1,15 +0,0 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~/src
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: canary-pi
|
||||
runtime: pi
|
||||
class: canary
|
||||
reset_between_tasks: true
|
||||
@@ -1,118 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://mosaicstack.dev/schemas/fleet-roster.schema.json",
|
||||
"title": "Mosaic Fleet Roster",
|
||||
"type": "object",
|
||||
"required": ["version", "transport", "agents"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"transport": {
|
||||
"const": "tmux"
|
||||
},
|
||||
"tmux": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"socket_name": {
|
||||
"type": "string",
|
||||
"default": "mosaic-factory"
|
||||
},
|
||||
"socketName": {
|
||||
"type": "string",
|
||||
"default": "mosaic-factory"
|
||||
},
|
||||
"holder_session": {
|
||||
"type": "string",
|
||||
"default": "_holder"
|
||||
},
|
||||
"holderSession": {
|
||||
"type": "string",
|
||||
"default": "_holder"
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"working_directory": {
|
||||
"type": "string",
|
||||
"default": "~/src"
|
||||
},
|
||||
"workingDirectory": {
|
||||
"type": "string",
|
||||
"default": "~/src"
|
||||
}
|
||||
}
|
||||
},
|
||||
"runtimes": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"reset_command": {
|
||||
"type": "string"
|
||||
},
|
||||
"resetCommand": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "runtime"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||
},
|
||||
"runtime": {
|
||||
"type": "string"
|
||||
},
|
||||
"class": {
|
||||
"type": "string"
|
||||
},
|
||||
"working_directory": {
|
||||
"type": "string"
|
||||
},
|
||||
"workingDirectory": {
|
||||
"type": "string"
|
||||
},
|
||||
"model_hint": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelHint": {
|
||||
"type": "string"
|
||||
},
|
||||
"persistent_persona": {
|
||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||
},
|
||||
"persistentPersona": {
|
||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||
},
|
||||
"reset_between_tasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resetBetweenTasks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"kickstart_template": {
|
||||
"type": "string"
|
||||
},
|
||||
"kickstartTemplate": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,17 +34,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "~/.config/mosaic/tools/qa/reflect-stop-hook.sh",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"enabledPlugins": {
|
||||
|
||||
@@ -29,21 +29,7 @@ Pi supports `--models` for Ctrl+P model cycling during a session. Use cheaper mo
|
||||
|
||||
### Skills
|
||||
|
||||
By default the launcher starts Pi with `--no-skills` to keep startup context small, then
|
||||
force-loads a small set of fleet-critical skills via explicit `--skill` flags (an explicit
|
||||
`--skill` overrides `--no-skills` for that path). The default forced set is `mosaic-tools`
|
||||
(the must-use `~/.config/mosaic/tools/` cheatsheet: inter-agent messaging + git wrappers).
|
||||
|
||||
Tune skill loading with environment variables:
|
||||
|
||||
- `MOSAIC_PI_FORCE_SKILLS` — colon-separated skill dir names to force-load (default: `mosaic-tools`;
|
||||
set to an empty string to disable force-loading). Missing skills are skipped silently.
|
||||
- `MOSAIC_PI_SKILL_MODE=all` — link every skill found in `~/.config/mosaic/{skills,skills-local}/`
|
||||
(full catalog; larger context).
|
||||
- `MOSAIC_PI_SKILL_MODE=discover` — let Pi discover skills natively (no `--no-skills`), still
|
||||
force-loading the fleet set on top.
|
||||
|
||||
Skills are discovered from:
|
||||
Mosaic skills are loaded natively via Pi's `--skill` flag. Skills are discovered from:
|
||||
|
||||
- `~/.config/mosaic/skills/` (Mosaic global skills)
|
||||
- `~/.pi/agent/skills/` (Pi global skills)
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# Mosaic tmux Fleet PoC
|
||||
|
||||
This directory contains the first durable tmux-backed fleet primitives for the
|
||||
Mosaic software-factory model.
|
||||
|
||||
The lifecycle model follows the organization-neutral AI Guide playbook
|
||||
`mosaicstack/aiguide:playbooks/tmux-fleet.md` (commit `2a0b0b5`): a dedicated
|
||||
holder owns the tmux server/socket; agent units join it and stop only their own
|
||||
exact-match session.
|
||||
|
||||
## Layout
|
||||
|
||||
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
||||
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
|
||||
- `test-fleet-units.sh` — validates unit syntax and required relationships.
|
||||
|
||||
The agent template calls:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/tools/fleet/start-agent-session.sh <agent-name>
|
||||
```
|
||||
|
||||
which starts or reuses a tmux session on `MOSAIC_TMUX_SOCKET`.
|
||||
|
||||
## Local customization
|
||||
|
||||
Per-agent overrides live outside the package in:
|
||||
|
||||
```text
|
||||
~/.config/mosaic/fleet/agents/<agent>.env
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```dotenv
|
||||
MOSAIC_TMUX_SOCKET=mosaic-factory
|
||||
MOSAIC_AGENT_RUNTIME=claude
|
||||
MOSAIC_AGENT_WORKDIR=/home/jarvis/src/mosaic-stack
|
||||
# Optional escape hatch for PoC/canary agents:
|
||||
# MOSAIC_AGENT_COMMAND=mosaic yolo claude
|
||||
```
|
||||
|
||||
## Manual canary sequence
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||
cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/
|
||||
chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start mosaic-tmux-holder.service
|
||||
systemctl --user start mosaic-agent@canary.service
|
||||
tmux -L mosaic-factory ls
|
||||
```
|
||||
|
||||
Do not use `tmux kill-server` without `-L mosaic-factory`; this pattern is meant
|
||||
to avoid disturbing the user's default tmux server.
|
||||
@@ -1,20 +0,0 @@
|
||||
[Unit]
|
||||
Description=Mosaic tmux fleet agent %i
|
||||
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||
Requires=mosaic-tmux-holder.service
|
||||
After=mosaic-tmux-holder.service
|
||||
PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
|
||||
Environment=MOSAIC_AGENT_NAME=%i
|
||||
Environment=MOSAIC_AGENT_RUNTIME=pi
|
||||
Environment=MOSAIC_AGENT_WORKDIR=%h
|
||||
EnvironmentFile=-%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-agent-session.sh %i
|
||||
ExecStop=-/bin/bash -lc 'tmux -L "${MOSAIC_TMUX_SOCKET:-mosaic-factory}" kill-session -t "=%i"'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,15 +0,0 @@
|
||||
[Unit]
|
||||
Description=Mosaic tmux fleet holder
|
||||
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||
After=default.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
Environment=MOSAIC_TMUX_SOCKET=mosaic-factory
|
||||
Environment=MOSAIC_TMUX_HOLDER=_holder
|
||||
ExecStart=/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${MOSAIC_TMUX_HOLDER}:0.0" 2>/dev/null || tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$MOSAIC_TMUX_HOLDER" "while true; do sleep 3600; done"'
|
||||
ExecStop=-/bin/bash -lc 'tmux -L "$MOSAIC_TMUX_SOCKET" kill-server'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
||||
|
||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||
grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
cat /tmp/mosaic-fleet-systemd-verify.log >&2
|
||||
fail "systemd-analyze verify failed"
|
||||
}
|
||||
fi
|
||||
|
||||
echo "ok - fleet systemd unit templates"
|
||||
@@ -9,8 +9,8 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
|
||||
@@ -58,7 +58,7 @@ ${QUALITY_GATES}
|
||||
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
|
||||
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
|
||||
4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
|
||||
|
||||
## Documentation Contract
|
||||
@@ -88,7 +88,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
@@ -138,8 +138,8 @@ When completing an orchestrated task:
|
||||
### Post-Coding Review
|
||||
After implementing changes, code review is REQUIRED for any source-code modification.
|
||||
For orchestrated tasks, the orchestrator will run:
|
||||
1. **Codex code review** — `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
||||
2. **Codex security review** — `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted`
|
||||
1. **Codex code review** — `~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted`
|
||||
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted`
|
||||
3. If blockers/critical findings: remediation task created
|
||||
4. If clean: task marked done
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ ${QUALITY_GATES}
|
||||
## Issue Tracking
|
||||
|
||||
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
|
||||
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
|
||||
|
||||
@@ -147,9 +147,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
@@ -176,10 +176,10 @@ Run independent reviews:
|
||||
|
||||
```bash
|
||||
# Code quality review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
|
||||
# Security review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
**Fallback:** If Codex is unavailable, use Claude's built-in review skills.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
|
||||
@@ -68,7 +68,7 @@ ruff check . && mypy . && pytest tests/
|
||||
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
|
||||
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
|
||||
4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
|
||||
|
||||
## Documentation Contract
|
||||
@@ -97,7 +97,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
@@ -139,8 +139,8 @@ Use `${TASK_PREFIX}` for orchestrated tasks (e.g., `${TASK_PREFIX}-SEC-001`).
|
||||
### Post-Coding Review
|
||||
After implementing changes, code review is REQUIRED for any source-code modification.
|
||||
For orchestrated tasks, the orchestrator will run:
|
||||
1. **Codex code review** — `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
||||
2. **Codex security review** — `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted`
|
||||
1. **Codex code review** — `~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted`
|
||||
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted`
|
||||
3. If blockers/critical findings: remediation task created
|
||||
4. If clean: task marked done
|
||||
|
||||
|
||||
@@ -159,10 +159,10 @@ Run independent reviews:
|
||||
|
||||
```bash
|
||||
# Code quality review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
|
||||
# Security review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.
|
||||
@@ -186,7 +186,7 @@ See `~/.config/mosaic/guides/DOCUMENTATION.md` for required documentation delive
|
||||
## Issue Tracking
|
||||
|
||||
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
|
||||
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
|
||||
|
||||
@@ -198,9 +198,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
|
||||
@@ -72,7 +72,7 @@ pnpm typecheck && pnpm lint && pnpm test
|
||||
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
|
||||
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
|
||||
4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
|
||||
|
||||
## Documentation Contract
|
||||
@@ -101,7 +101,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
@@ -143,8 +143,8 @@ Use `${TASK_PREFIX}` for orchestrated tasks (e.g., `${TASK_PREFIX}-SEC-001`).
|
||||
### Post-Coding Review
|
||||
After implementing changes, code review is REQUIRED for any source-code modification.
|
||||
For orchestrated tasks, the orchestrator will run:
|
||||
1. **Codex code review** — `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`
|
||||
2. **Codex security review** — `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted`
|
||||
1. **Codex code review** — `~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted`
|
||||
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted`
|
||||
3. If blockers/critical findings: remediation task created
|
||||
4. If clean: task marked done
|
||||
|
||||
|
||||
@@ -191,10 +191,10 @@ Run independent reviews:
|
||||
|
||||
```bash
|
||||
# Code quality review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
|
||||
# Security review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.
|
||||
@@ -218,7 +218,7 @@ See `~/.config/mosaic/guides/DOCUMENTATION.md` for required documentation delive
|
||||
## Issue Tracking
|
||||
|
||||
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
|
||||
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
|
||||
|
||||
@@ -230,9 +230,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
|
||||
@@ -58,7 +58,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
|
||||
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
|
||||
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
|
||||
4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
|
||||
|
||||
## Documentation Contract
|
||||
@@ -87,7 +87,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
|
||||
## Issue Tracking
|
||||
|
||||
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
|
||||
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
|
||||
|
||||
@@ -146,9 +146,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
@@ -171,8 +171,8 @@ If you modify source code, independent code review is REQUIRED before completion
|
||||
Run independent reviews:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
|
||||
@@ -55,7 +55,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
|
||||
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
|
||||
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
|
||||
4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
|
||||
|
||||
## Documentation Contract
|
||||
@@ -84,7 +84,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ uv run ruff check src/ tests/ && uv run ruff format --check src/ && uv run mypy
|
||||
## Issue Tracking
|
||||
|
||||
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
|
||||
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
|
||||
|
||||
@@ -136,9 +136,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
@@ -161,8 +161,8 @@ If you modify source code, independent code review is REQUIRED before completion
|
||||
Run independent reviews:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
2. Do NOT ask for routine confirmation before required push/merge/issue-close/release/tag actions.
|
||||
3. Completion is forbidden at PR-open stage.
|
||||
4. Completion requires merged PR to `main` + terminal green CI + linked issue/internal task closed.
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/tools/git/*.sh`).
|
||||
5. Before push or merge, run queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
6. For issue/PR/milestone operations, use Mosaic wrappers first (`~/.config/mosaic/rails/git/*.sh`).
|
||||
7. If any required wrapper command fails: report `blocked` with the exact failed wrapper command and stop.
|
||||
8. Do NOT stop at "PR created" and do NOT ask "should I merge?" for routine flow.
|
||||
|
||||
@@ -56,7 +56,7 @@ ${QUALITY_GATES}
|
||||
2. If external git provider is available (Gitea/GitHub/GitLab), create/update issue(s) before coding and map them in `docs/TASKS.md`.
|
||||
3. If no external provider is available, use internal refs in `docs/TASKS.md` (example: `TASKS:T1`).
|
||||
4. Keep `docs/TASKS.md` status in sync with actual progress until completion.
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
5. For issue/PR/milestone actions, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
|
||||
6. If wrapper-driven merge/CI/issue-closure fails, report blocker with the exact failed wrapper command and stop (do not claim completion).
|
||||
|
||||
## Documentation Contract
|
||||
@@ -85,7 +85,7 @@ Reference:
|
||||
5. Do not mark implementation complete until PR is merged.
|
||||
6. Do not mark implementation complete until CI/pipeline status is terminal green.
|
||||
7. Close linked issues/tasks only after merge + green CI.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
8. Before push or merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
|
||||
|
||||
## Container Release Strategy (When Applicable)
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ ${QUALITY_GATES}
|
||||
## Issue Tracking
|
||||
|
||||
Use external git provider issues when available. If no external provider exists, `docs/TASKS.md` is the canonical tracker for tasks, milestones, and issue-equivalent work.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/tools/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
For issue/PR/milestone operations, detect platform and use `~/.config/mosaic/rails/git/*.sh` wrappers first; do not use raw `gh`/`tea`/`glab` as first choice.
|
||||
If wrapper-driven merge/CI/issue-closure fails, report blocker with exact failed wrapper command and stop.
|
||||
Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close the issue?" for routine delivery flow.
|
||||
|
||||
@@ -133,9 +133,9 @@ Do NOT stop at "PR created" and do NOT ask "should I merge?" or "should I close
|
||||
5. Ensure `docs/PRD.md` or `docs/PRD.json` exists and is current before coding.
|
||||
6. Create scratchpad: `docs/scratchpads/{task-id}-{short-name}.md` and include issue/internal ref.
|
||||
7. Update `docs/TASKS.md` status + issue/internal ref before coding.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
8. Before push, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push -B main`.
|
||||
9. Open PR to `main` for delivery changes (no direct push to `main`).
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
10. Before merge, run CI queue guard: `~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose merge -B main`.
|
||||
11. Merge PRs that pass required checks and review gates with squash strategy only.
|
||||
12. Reference issues/internal refs in commits (`Fixes #123`, `Refs #123`, or `Refs TASKS:T1`).
|
||||
13. Close issue/internal task only after testing and documentation gates pass, PR merge is complete, and CI/pipeline status is terminal green.
|
||||
@@ -159,10 +159,10 @@ Run independent reviews:
|
||||
|
||||
```bash
|
||||
# Code quality review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
||||
|
||||
# Security review (Codex)
|
||||
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
||||
```
|
||||
|
||||
**Fallback:** If Codex is unavailable, use Claude's built-in review skills.
|
||||
|
||||
@@ -16,12 +16,7 @@
|
||||
# After loading, service-specific env vars are exported.
|
||||
# Run `load_credentials --help` for details.
|
||||
|
||||
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_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
|
||||
|
||||
_mosaic_require_jq() {
|
||||
if ! command -v jq &>/dev/null; then
|
||||
@@ -39,19 +34,6 @@ _mosaic_read_cred() {
|
||||
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
|
||||
# Only writes when values differ to avoid unnecessary disk writes.
|
||||
_mosaic_sync_woodpecker_env() {
|
||||
@@ -279,8 +261,7 @@ mosaic_http() {
|
||||
local base_url="${4:-}"
|
||||
|
||||
local response
|
||||
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X "$method" \
|
||||
-H "$auth_header" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${base_url}${endpoint}")
|
||||
@@ -298,8 +279,7 @@ mosaic_http_post() {
|
||||
local base_url="${4:-}"
|
||||
|
||||
local response
|
||||
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X POST \
|
||||
-H "$auth_header" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
@@ -317,8 +297,7 @@ mosaic_http_patch() {
|
||||
local base_url="${4:-}"
|
||||
|
||||
local response
|
||||
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
|
||||
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X PATCH \
|
||||
-H "$auth_header" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory}
|
||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||
|
||||
if [ -z "$AGENT_NAME" ]; then
|
||||
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux is required" >&2
|
||||
exit 69
|
||||
fi
|
||||
|
||||
if tmux -L "$MOSAIC_TMUX_SOCKET" has-session -t "=${AGENT_NAME}:0.0" 2>/dev/null; then
|
||||
echo "Mosaic agent session already running: $AGENT_NAME on socket $MOSAIC_TMUX_SOCKET"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
exec tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" "$MOSAIC_AGENT_COMMAND"
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
START="$SCRIPT_DIR/start-agent-session.sh"
|
||||
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
||||
AGENT="agent-$RANDOM"
|
||||
WORKDIR=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT"
|
||||
|
||||
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
||||
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
|
||||
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir"
|
||||
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT" >/tmp/mosaic-start-agent-idempotent.out
|
||||
|
||||
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
|
||||
|
||||
echo "ok - start-agent-session"
|
||||
@@ -137,7 +137,7 @@ gitea_get_branch_head_sha() {
|
||||
local branch="$3"
|
||||
local token="$4"
|
||||
local url="https://${host}/api/v1/repos/${repo}/branches/${branch}"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
curl -fsSL -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
commit = data.get("commit") or {}
|
||||
@@ -151,7 +151,7 @@ gitea_get_commit_status_json() {
|
||||
local sha="$3"
|
||||
local token="$4"
|
||||
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
curl -fsSL -H "Authorization: token ${token}" "$url"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
||||
@@ -55,154 +55,6 @@ function Get-GitRepoInfo {
|
||||
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 {
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
@@ -78,249 +78,10 @@ get_repo_slug() {
|
||||
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
|
||||
}
|
||||
|
||||
# Emit an actionable diagnostic to stderr when no tea login resolves for a host.
|
||||
# Callers that have a working API fallback may ignore the non-zero return of
|
||||
# get_gitea_login_for_host; this turns the previously SILENT failure into a loud,
|
||||
# greppable hint (available logins + override + add-login instructions). Printed to
|
||||
# stderr only, so it never contaminates stdout (the resolved login name) or log
|
||||
# assertions that capture tea/curl invocations.
|
||||
print_gitea_login_diagnostic() {
|
||||
local host="${1:-<unknown>}"
|
||||
local available
|
||||
available=$(
|
||||
command -v tea >/dev/null 2>&1 || { echo "(tea CLI not installed)"; exit 0; }
|
||||
logins_json=$(tea login list --output json 2>/dev/null) || { echo "(could not query tea login list)"; exit 0; }
|
||||
TEA_LOGINS_JSON="$logins_json" python3 - <<'PY'
|
||||
import json, os
|
||||
from urllib.parse import urlparse
|
||||
try:
|
||||
logins = json.loads(os.environ.get("TEA_LOGINS_JSON", "[]"))
|
||||
except Exception:
|
||||
logins = []
|
||||
rows = []
|
||||
for login in logins if isinstance(logins, list) else []:
|
||||
name = str(login.get("name") or login.get("Name") or "")
|
||||
url = str(login.get("url") or login.get("URL") or "")
|
||||
host = urlparse(url).hostname or "?"
|
||||
if name:
|
||||
rows.append(f"{name} (host: {host})")
|
||||
print("; ".join(rows) if rows else "(none configured)")
|
||||
PY
|
||||
)
|
||||
{
|
||||
echo "Error: no Gitea tea login matches host '$host'."
|
||||
echo " Available tea logins: ${available}"
|
||||
echo " Fix: set GITEA_LOGIN to a login whose URL host is '$host',"
|
||||
echo " or add one: tea login add --name <name> --url https://$host --token <token>"
|
||||
} >&2
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
print_gitea_login_diagnostic "$host"
|
||||
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() {
|
||||
local repo host login
|
||||
local repo
|
||||
repo=$(get_repo_slug) || return 1
|
||||
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)"
|
||||
printf -- '--repo %q --login %q' "$repo" "${GITEA_LOGIN:-mosaicstack}"
|
||||
}
|
||||
|
||||
get_remote_host() {
|
||||
@@ -330,8 +91,7 @@ get_remote_host() {
|
||||
return 1
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^https?://([^/]+)/ ]]; then
|
||||
local host="${BASH_REMATCH[1]}"
|
||||
echo "${host##*@}"
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||
|
||||
@@ -75,11 +75,6 @@ switch ($platform) {
|
||||
Write-Host "Issue #$Issue updated successfully"
|
||||
}
|
||||
"gitea" {
|
||||
$repoArgs = @(Get-GiteaRepoArgs)
|
||||
if ($repoArgs.Length -eq 0) {
|
||||
Write-Error "Could not resolve Gitea repo/login for remote host"
|
||||
exit 1
|
||||
}
|
||||
$needsEdit = $false
|
||||
$cmd = @("tea", "issue", "edit", $Issue)
|
||||
|
||||
@@ -92,7 +87,7 @@ switch ($platform) {
|
||||
$needsEdit = $true
|
||||
}
|
||||
if ($Milestone) {
|
||||
$milestoneList = tea milestones list @repoArgs 2>$null
|
||||
$milestoneList = tea milestones list 2>$null
|
||||
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
||||
if ($milestoneId) {
|
||||
$cmd += @("--milestone", $milestoneId)
|
||||
@@ -103,7 +98,6 @@ switch ($platform) {
|
||||
}
|
||||
|
||||
if ($needsEdit) {
|
||||
$cmd += $repoArgs
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
Write-Host "Issue #$Issue updated successfully"
|
||||
} else {
|
||||
|
||||
@@ -98,32 +98,23 @@ case "$PLATFORM" in
|
||||
;;
|
||||
gitea)
|
||||
# tea issue edit syntax
|
||||
REPO_SLUG=$(get_repo_slug) || {
|
||||
echo "Error: Could not resolve Gitea repo slug from remote" >&2
|
||||
exit 1
|
||||
}
|
||||
REPO_LOGIN=$(get_gitea_login) || {
|
||||
echo "Error: Could not resolve Gitea login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$REPO_LOGIN")
|
||||
CMD=(tea issue edit "$ISSUE" "${REPO_ARGS[@]}")
|
||||
CMD="tea issue edit $ISSUE"
|
||||
NEEDS_EDIT=false
|
||||
|
||||
if [[ -n "$ASSIGNEE" ]]; then
|
||||
# tea uses --assignees flag
|
||||
CMD+=(--assignees "$ASSIGNEE")
|
||||
CMD="$CMD --assignees \"$ASSIGNEE\""
|
||||
NEEDS_EDIT=true
|
||||
fi
|
||||
if [[ -n "$LABELS" ]]; then
|
||||
# tea uses --labels flag (replaces existing)
|
||||
CMD+=(--labels "$LABELS")
|
||||
CMD="$CMD --labels \"$LABELS\""
|
||||
NEEDS_EDIT=true
|
||||
fi
|
||||
if [[ -n "$MILESTONE" ]]; then
|
||||
MILESTONE_ID=$(tea milestones list "${REPO_ARGS[@]}" 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
||||
MILESTONE_ID=$(tea milestones list 2>/dev/null | grep -E "^\s*[0-9]+" | grep "$MILESTONE" | awk '{print $1}' | head -1)
|
||||
if [[ -n "$MILESTONE_ID" ]]; then
|
||||
CMD+=(--milestone "$MILESTONE_ID")
|
||||
CMD="$CMD --milestone $MILESTONE_ID"
|
||||
NEEDS_EDIT=true
|
||||
else
|
||||
echo "Warning: Could not find milestone '$MILESTONE'" >&2
|
||||
@@ -131,7 +122,7 @@ case "$PLATFORM" in
|
||||
fi
|
||||
|
||||
if [[ "$NEEDS_EDIT" == true ]]; then
|
||||
"${CMD[@]}"
|
||||
eval "$CMD"
|
||||
echo "Issue #$ISSUE updated successfully"
|
||||
else
|
||||
echo "No changes specified"
|
||||
|
||||
@@ -44,43 +44,10 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
fi
|
||||
|
||||
# Detect platform and close issue
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
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_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 [[ -n "$COMMENT" ]]; then
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
@@ -88,19 +55,10 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue close "$ISSUE_NUMBER"
|
||||
echo "Closed GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login || true)
|
||||
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
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
|
||||
fi
|
||||
tea issue close "$ISSUE_NUMBER" --repo "$OWNER/$REPO" --login "${GITEA_LOGIN:-mosaicstack}"
|
||||
echo "Closed Gitea issue #$ISSUE_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -47,21 +47,13 @@ if [[ -z "$COMMENT" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
|
||||
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# Build the invocation as an argv array (not unquoted $(get_gitea_repo_args)
|
||||
# word-splitting) so the comment body — including Markdown backticks, $(...),
|
||||
# and quotes — is passed verbatim and never re-split or shell-evaluated.
|
||||
REPO_SLUG=$(get_repo_slug)
|
||||
GITEA_LOGIN_NAME=$(get_gitea_login) || {
|
||||
echo "Error: could not resolve a Gitea login for this repo; cannot comment on issue #$ISSUE_NUMBER." >&2
|
||||
exit 1
|
||||
}
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" --repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME"
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -58,17 +58,12 @@ switch ($platform) {
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
"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)
|
||||
if ($Body) { $cmd += @("--description", $Body) }
|
||||
if ($Labels) { $cmd += @("--labels", $Labels) }
|
||||
if ($Milestone) {
|
||||
# Try to get milestone ID by name
|
||||
$milestoneList = tea milestones list @repoArgs 2>$null
|
||||
$milestoneList = tea milestones list 2>$null
|
||||
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
||||
if ($milestoneId) {
|
||||
$cmd += @("--milestone", $milestoneId)
|
||||
@@ -76,7 +71,6 @@ switch ($platform) {
|
||||
Write-Warning "Could not find milestone '$Milestone', creating without milestone"
|
||||
}
|
||||
}
|
||||
$cmd += $repoArgs
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
default {
|
||||
|
||||
@@ -48,7 +48,6 @@ PY
|
||||
|
||||
url="https://${host}/api/v1/repos/${repo}/issues"
|
||||
curl -fsS -X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
@@ -122,12 +121,7 @@ case "$PLATFORM" in
|
||||
gitea)
|
||||
if command -v tea >/dev/null 2>&1; then
|
||||
REPO_SLUG=$(get_repo_slug)
|
||||
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")
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--labels "$LABELS")
|
||||
|
||||
@@ -60,31 +60,23 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
CMD=(gh issue edit "$ISSUE_NUMBER")
|
||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--body "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--add-label "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
"${CMD[@]}"
|
||||
CMD="gh issue edit $ISSUE_NUMBER"
|
||||
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
||||
[[ -n "$BODY" ]] && CMD="$CMD --body \"$BODY\""
|
||||
[[ -n "$LABELS" ]] && CMD="$CMD --add-label \"$LABELS\""
|
||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
||||
eval $CMD
|
||||
echo "Updated GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_SLUG=$(get_repo_slug) || {
|
||||
echo "Error: Could not resolve Gitea repo slug from remote" >&2
|
||||
exit 1
|
||||
}
|
||||
REPO_LOGIN=$(get_gitea_login) || {
|
||||
echo "Error: Could not resolve Gitea login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
CMD=(tea issue edit "$ISSUE_NUMBER" --repo "$REPO_SLUG" --login "$REPO_LOGIN")
|
||||
[[ -n "$TITLE" ]] && CMD+=(--title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$LABELS" ]] && CMD+=(--add-labels "$LABELS")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestone "$MILESTONE")
|
||||
"${CMD[@]}"
|
||||
CMD="tea issue edit $ISSUE_NUMBER"
|
||||
[[ -n "$TITLE" ]] && CMD="$CMD --title \"$TITLE\""
|
||||
[[ -n "$BODY" ]] && CMD="$CMD --description \"$BODY\""
|
||||
[[ -n "$LABELS" ]] && CMD="$CMD --add-labels \"$LABELS\""
|
||||
[[ -n "$MILESTONE" ]] && CMD="$CMD --milestone \"$MILESTONE\""
|
||||
eval $CMD
|
||||
echo "Updated Gitea issue #$ISSUE_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -63,15 +63,9 @@ switch ($platform) {
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
"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)
|
||||
if ($Label) { $cmd += @("--labels", $Label) }
|
||||
if ($Milestone) { $cmd += @("--milestones", $Milestone) }
|
||||
$cmd += $repoArgs
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
if ($Assignee) {
|
||||
Write-Warning "Assignee filtering may require manual review for Gitea"
|
||||
|
||||
@@ -98,18 +98,7 @@ case "$PLATFORM" in
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
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")
|
||||
CMD=(tea issues list --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}" --state "$STATE" --limit "$LIMIT")
|
||||
[[ -n "$LABEL" ]] && CMD+=(--labels "$LABEL")
|
||||
[[ -n "$MILESTONE" ]] && CMD+=(--milestones "$MILESTONE")
|
||||
# Note: tea may not support assignee filter directly in all versions.
|
||||
|
||||
@@ -42,42 +42,7 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
}
|
||||
detect_platform
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
@@ -86,19 +51,10 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue reopen "$ISSUE_NUMBER"
|
||||
echo "Reopened GitHub issue #$ISSUE_NUMBER"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
REPO_ARGS=$(get_gitea_repo_args || true)
|
||||
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
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
fi
|
||||
tea issue reopen "$ISSUE_NUMBER" $(get_gitea_repo_args)
|
||||
echo "Reopened Gitea issue #$ISSUE_NUMBER"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -29,9 +29,9 @@ gitea_issue_view_api() {
|
||||
|
||||
url="https://${host}/api/v1/repos/${repo}/issues/${ISSUE_NUMBER}"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -m json.tool
|
||||
curl -fsS -H "Authorization: token ${token}" "$url" | python3 -m json.tool
|
||||
else
|
||||
curl -fsS -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
|
||||
curl -fsS -H "Authorization: token ${token}" "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ if [[ -z "$ISSUE_NUMBER" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh issue view "$ISSUE_NUMBER"
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# lane-brief.sh — live dispatch brief for a repo "lane" (milestone/label), straight
|
||||
# from current Gitea state. Defeats stale worker self-report: workers brief from
|
||||
# static notes and routinely report issues "todo" that are already CLOSED, forcing
|
||||
# the orchestrator to re-verify each one before dispatch. This returns the CURRENT
|
||||
# open set, classified for dispatch, in one call.
|
||||
#
|
||||
# Usage:
|
||||
# lane-brief.sh -r <owner/repo> [-m <milestone>] [-l <label>] [-L <login>] [-n <limit>]
|
||||
# lane-brief.sh -r usc/uconnect -m "M2M Part Search (0.0.45)"
|
||||
# lane-brief.sh -r usc/uconnect -l domain/6-security
|
||||
#
|
||||
# Reliable signals (closed issues are excluded by definition — that's the point):
|
||||
# - open-vs-closed : authoritative; this is the stale-intake failure mode.
|
||||
# - PR-linkage : an open PR referencing the issue = work underway.
|
||||
# Assignees/dependencies are intentionally NOT trusted as "available" signals —
|
||||
# fleets that track work-state out-of-band (tmux board, issue text) leave them
|
||||
# empty in Gitea. Output therefore partitions by PR presence and the OPEN-NO-PR set
|
||||
# is "dispatch candidates to cross-check against the live fleet", not a blind list.
|
||||
#
|
||||
# Login resolution order: -L flag > $GITEA_LOGIN > owner inference (usc->usc,
|
||||
# mosaicstack/mosaic->mosaicstack) > detect-platform.sh default-login fallback.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=/dev/null
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
REPO="" MILESTONE="" LABEL="" LOGIN="" LIMIT=100
|
||||
while getopts "r:m:l:L:n:h" opt; do
|
||||
case "$opt" in
|
||||
r) REPO="$OPTARG" ;;
|
||||
m) MILESTONE="$OPTARG" ;;
|
||||
l) LABEL="$OPTARG" ;;
|
||||
L) LOGIN="$OPTARG" ;;
|
||||
n) LIMIT="$OPTARG" ;;
|
||||
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) echo "see -h" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
|
||||
|
||||
# Resolve login: explicit -L, then $GITEA_LOGIN, then owner inference, then the
|
||||
# shared default-login resolver. Owner inference comes before the shared fallback
|
||||
# because the latter is not owner-aware (picks the default tea login), which is
|
||||
# wrong for cross-instance lanes.
|
||||
if [[ -z "$LOGIN" ]]; then
|
||||
if [[ -n "${GITEA_LOGIN:-}" ]]; then
|
||||
LOGIN="$GITEA_LOGIN"
|
||||
else
|
||||
case "${REPO%%/*}" in
|
||||
usc|USC) LOGIN=usc ;;
|
||||
mosaicstack|mosaic) LOGIN=mosaicstack ;;
|
||||
*) LOGIN="$(get_gitea_login_for_repo_override 2>/dev/null || true)" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
[[ -n "$LOGIN" ]] || { echo "FATAL: could not resolve a Gitea login for $REPO (pass -L or set GITEA_LOGIN)" >&2; exit 2; }
|
||||
|
||||
command -v tea >/dev/null || { echo "FATAL: tea not found" >&2; exit 1; }
|
||||
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 1; }
|
||||
|
||||
ISSUES_JSON="$(tea issues list --repo "$REPO" --login "$LOGIN" --state open --limit "$LIMIT" \
|
||||
--fields index,title,assignees,milestone,labels --output json 2>/dev/null)" || {
|
||||
echo "FATAL: tea issues list failed for $REPO (login=$LOGIN)" >&2; exit 1; }
|
||||
|
||||
# Open PRs, to cross-ref which issues already have work in flight. An issue is
|
||||
# "work underway" if an open PR links to it. Two link signals are honored:
|
||||
# (a) a closing keyword in the PR BODY — Gitea's auto-close set (close/closes/
|
||||
# closed, fix/fixes/fixed, resolve/resolves/resolved), case-insensitive,
|
||||
# directly preceding `#N`. This is the AUTHORITATIVE link Gitea itself uses
|
||||
# to associate a PR with the issue it resolves; a body-only "Closes #546"
|
||||
# is the common case and MUST count. The earlier version inspected only the
|
||||
# PR index/title/head TSV (never the body or Gitea linkage), so a body-only
|
||||
# reference was invisible and the linked OPEN issue was misclassified as a
|
||||
# dispatch candidate — re-dispatchable in-flight work (the #546/#547 defect).
|
||||
# (b) a bare #N in the PR title, or an issue number embedded in the head branch
|
||||
# (feat/546-x, fix-546) — the weaker heuristic preserved from prior behavior.
|
||||
# Bare #N mentions in the BODY are deliberately NOT treated as links: PR bodies
|
||||
# routinely name unrelated issues in prose ("relevant to the #538 line of work"),
|
||||
# and counting those would wrongly mark live, dispatchable issues as in-flight.
|
||||
# Only the closing-keyword form is a commitment to resolve that issue. Requiring
|
||||
# `#` to directly follow the keyword also keeps cross-repo `owner/repo#N` forms
|
||||
# from leaking a foreign issue number into this per-repo lane (cross-repo lanes
|
||||
# are run per-repo). JSON (not TSV) is used so multi-line bodies parse cleanly.
|
||||
PRS_JSON="$(tea pulls list --repo "$REPO" --login "$LOGIN" --state open \
|
||||
--fields index,title,head,body --output json 2>/dev/null || echo '[]')"
|
||||
[[ -n "$PRS_JSON" ]] || PRS_JSON='[]'
|
||||
|
||||
# \b anchors the keyword to a word start so embedded substrings do not match
|
||||
# (e.g. "prefix #5", "disclosed #7" must NOT be read as "fix #5" / "closed #7").
|
||||
GITEA_CLOSE_KW='close[sd]?|fix(e[sd])?|resolve[sd]?'
|
||||
PR_BODY_REFS="$(printf '%s' "$PRS_JSON" | jq -r '.[] | .body // ""' 2>/dev/null \
|
||||
| grep -oiE "\\b(${GITEA_CLOSE_KW})[[:space:]:]+#[0-9]+" | grep -oE '[0-9]+' || true)"
|
||||
PR_TITLE_HEAD_REFS="$(printf '%s' "$PRS_JSON" \
|
||||
| jq -r '.[] | [ (.title // ""), (.head // "" | if type=="object" then (.ref // "") else . end) ] | join(" ")' 2>/dev/null \
|
||||
| grep -oE '#[0-9]+|[/-][0-9]{3,}' | grep -oE '[0-9]+' || true)"
|
||||
PR_ISSUE_REFS="$(printf '%s\n%s\n' "$PR_BODY_REFS" "$PR_TITLE_HEAD_REFS" | grep -E '^[0-9]+$' | sort -u || true)"
|
||||
|
||||
ts="$(date -u '+%Y-%m-%d %H:%MZ' 2>/dev/null || echo '?')"
|
||||
filt="$REPO"; [[ -n "$MILESTONE" ]] && filt="$filt · milestone:'$MILESTONE'"; [[ -n "$LABEL" ]] && filt="$filt · label:'$LABEL'"
|
||||
echo "LANE BRIEF — $filt · $ts (login=$LOGIN)"
|
||||
echo "(open issues only; closed are excluded by definition — that's the point)"
|
||||
echo
|
||||
|
||||
# Label match is exact-token against tea's space-separated labels string (so -l
|
||||
# "security" does NOT match label "domain/6-security"). Caveat: label names that
|
||||
# themselves contain spaces aren't distinguishable in tea's string form.
|
||||
printf '%s' "$ISSUES_JSON" | jq -r --arg ms "$MILESTONE" --arg lb "$LABEL" --arg prs "$PR_ISSUE_REFS" '
|
||||
($prs | split("\n") | map(select(length>0))) as $prrefs
|
||||
| map(
|
||||
select( ($ms=="" or .milestone==$ms)
|
||||
and ($lb=="" or ((.labels//"") | split(" ") | index($lb) != null)) )
|
||||
| . + { assigned: ((.assignees//"")|length>0),
|
||||
haspr: (.index as $ix | ($prrefs | index($ix)) != null) }
|
||||
)
|
||||
| (map(select(.haspr|not))) as $candidates
|
||||
| (map(select(.haspr))) as $inflight
|
||||
| "DISPATCH CANDIDATES (open · no open PR) — \($candidates|length) [cross-check vs live fleet]:",
|
||||
( $candidates[] | " #\(.index) \(.title[0:90])\(if .assigned then " (gitea-assignee set)" else "" end)" ),
|
||||
"",
|
||||
"WORK UNDERWAY (open · PR in flight) — \($inflight|length):",
|
||||
( $inflight[] | " #\(.index) \(.title[0:80]) [PR open]" )
|
||||
'
|
||||
echo
|
||||
echo "Closed issues are excluded — do NOT take a worker's self-reported 'todo' on faith."
|
||||
echo "Candidates = open + no PR; confirm against the live fleet before dispatch"
|
||||
echo "(fleets that don't self-assign in Gitea leave 'unassigned' meaningless)."
|
||||
@@ -36,17 +36,13 @@ if [[ -z "$TITLE" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
|
||||
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
|
||||
echo "Closed GitHub milestone: $TITLE"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
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
|
||||
tea milestone close "$TITLE"
|
||||
echo "Closed Gitea milestone: $TITLE"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
|
||||
@@ -59,12 +59,7 @@ if ($List) {
|
||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)`t\(.title)`t\(.state)`t\(.open_issues)/\(.closed_issues) issues"'
|
||||
}
|
||||
"gitea" {
|
||||
$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
|
||||
tea milestones list
|
||||
}
|
||||
default {
|
||||
Write-Error "Could not detect git platform"
|
||||
@@ -90,15 +85,9 @@ switch ($platform) {
|
||||
Write-Host "Milestone '$Title' created successfully"
|
||||
}
|
||||
"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)
|
||||
if ($Description) { $cmd += @("--description", $Description) }
|
||||
if ($Due) { $cmd += @("--deadline", $Due) }
|
||||
$cmd += $repoArgs
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
Write-Host "Milestone '$Title' created successfully"
|
||||
}
|
||||
|
||||
@@ -77,11 +77,7 @@ if [[ "$LIST_ONLY" == true ]]; then
|
||||
gh api repos/:owner/:repo/milestones --jq '.[] | "\(.number)\t\(.title)\t\(.state)\t\(.open_issues)/\(.closed_issues) issues"'
|
||||
;;
|
||||
gitea)
|
||||
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
|
||||
tea milestones list
|
||||
;;
|
||||
*)
|
||||
echo "Error: Could not detect git platform" >&2
|
||||
@@ -99,28 +95,19 @@ fi
|
||||
case "$PLATFORM" in
|
||||
github)
|
||||
# GitHub uses the API for milestone creation
|
||||
# Use jq to safely construct JSON so titles/descriptions containing
|
||||
# quotes or special characters do not corrupt the payload (F-07).
|
||||
JSON_PAYLOAD=$(jq -n \
|
||||
--arg t "$TITLE" \
|
||||
--arg d "$DESCRIPTION" \
|
||||
--arg due "${DUE_DATE}" \
|
||||
'{"title": $t}
|
||||
+ (if $d != "" then {"description": $d} else {} end)
|
||||
+ (if $due != "" then {"due_on": ($due + "T00:00:00Z")} else {} end)')
|
||||
JSON_PAYLOAD="{\"title\":\"$TITLE\""
|
||||
[[ -n "$DESCRIPTION" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"description\":\"$DESCRIPTION\""
|
||||
[[ -n "$DUE_DATE" ]] && JSON_PAYLOAD="$JSON_PAYLOAD,\"due_on\":\"${DUE_DATE}T00:00:00Z\""
|
||||
JSON_PAYLOAD="$JSON_PAYLOAD}"
|
||||
|
||||
gh api repos/:owner/:repo/milestones --method POST --input - <<< "$JSON_PAYLOAD"
|
||||
echo "Milestone '$TITLE' created successfully"
|
||||
;;
|
||||
gitea)
|
||||
REPO_ARGS=$(get_gitea_repo_args) || {
|
||||
echo "Error: Could not resolve Gitea repo/login for remote host" >&2
|
||||
exit 1
|
||||
}
|
||||
CMD=(tea milestones create --title "$TITLE")
|
||||
[[ -n "$DESCRIPTION" ]] && CMD+=(--description "$DESCRIPTION")
|
||||
[[ -n "$DUE_DATE" ]] && CMD+=(--deadline "$DUE_DATE")
|
||||
"${CMD[@]}" $REPO_ARGS
|
||||
CMD="tea milestones create --title \"$TITLE\""
|
||||
[[ -n "$DESCRIPTION" ]] && CMD="$CMD --description \"$DESCRIPTION\""
|
||||
[[ -n "$DUE_DATE" ]] && CMD="$CMD --deadline \"$DUE_DATE\""
|
||||
eval "$CMD"
|
||||
echo "Milestone '$TITLE' created successfully"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -31,16 +31,12 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh api "/repos/{owner}/{repo}/milestones?state=$STATE" --jq '.[] | "\(.title) (\(.state)) - \(.open_issues) open, \(.closed_issues) closed"'
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
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
|
||||
tea milestone list
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
|
||||
@@ -11,7 +11,6 @@ PR_NUMBER=""
|
||||
TIMEOUT_SEC=1800
|
||||
INTERVAL_SEC=15
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
@@ -20,7 +19,6 @@ Usage: $(basename "$0") -n <pr_number> [-t timeout_sec] [-i interval_sec]
|
||||
Options:
|
||||
-n, --number NUMBER PR number (required)
|
||||
-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)
|
||||
-i, --interval SECONDS Poll interval in seconds (default: 15)
|
||||
-h, --help Show this help
|
||||
@@ -72,11 +70,6 @@ elif values and all(v == "success" for v in values):
|
||||
print("success")
|
||||
elif any(v in {"pending", "running", "queued", "waiting"} for v in values):
|
||||
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:
|
||||
print("unknown")
|
||||
PY
|
||||
@@ -131,7 +124,7 @@ gitea_get_pr_head_sha() {
|
||||
local repo="$2"
|
||||
local token="$3"
|
||||
local url="https://${host}/api/v1/repos/${repo}/pulls/${PR_NUMBER}"
|
||||
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
curl -fsSL -H "Authorization: token ${token}" "$url" | python3 -c '
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
print((data.get("head") or {}).get("sha", ""))
|
||||
@@ -144,22 +137,7 @@ gitea_get_commit_status_json() {
|
||||
local token="$3"
|
||||
local sha="$4"
|
||||
local url="https://${host}/api/v1/repos/${repo}/commits/${sha}/status"
|
||||
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'
|
||||
curl -fsSL -H "Authorization: token ${token}" "$url"
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
@@ -172,10 +150,6 @@ while [[ $# -gt 0 ]]; do
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
HOST_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-t|--timeout)
|
||||
TIMEOUT_SEC="$2"
|
||||
shift 2
|
||||
@@ -237,19 +211,7 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
fi
|
||||
echo "[pr-ci-wait] Platform=github PR=#${PR_NUMBER} head_sha=${HEAD_SHA}"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
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
|
||||
HOST=$(get_remote_host 2>/dev/null || echo "git.mosaicstack.dev")
|
||||
TOKEN=$(get_gitea_token "$HOST") || {
|
||||
echo "Error: Gitea token not found. Set GITEA_TOKEN or configure ~/.git-credentials." >&2
|
||||
exit 1
|
||||
@@ -265,51 +227,6 @@ else
|
||||
exit 1
|
||||
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
|
||||
NOW_TS=$(date +%s)
|
||||
if (( NOW_TS > DEADLINE_TS )); then
|
||||
@@ -337,35 +254,11 @@ while true; do
|
||||
echo "Error: CI reported ${STATE} for PR #$PR_NUMBER." >&2
|
||||
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)
|
||||
# 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"
|
||||
;;
|
||||
*)
|
||||
echo "[pr-ci-wait] Unrecognized state '${STATE}', continuing to poll..."
|
||||
NO_CI_STREAK=0
|
||||
sleep "$INTERVAL_SEC"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -42,7 +42,7 @@ if [[ -z "$PR_NUMBER" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
|
||||
@@ -9,6 +9,7 @@ param(
|
||||
[Alias("b")]
|
||||
[string]$Body,
|
||||
|
||||
[Alias("B")]
|
||||
[string]$Base,
|
||||
|
||||
[Alias("H")]
|
||||
@@ -100,11 +101,6 @@ switch ($platform) {
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
"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)
|
||||
if ($Body) { $cmd += @("--description", $Body) }
|
||||
if ($Base) { $cmd += @("--base", $Base) }
|
||||
@@ -112,7 +108,7 @@ switch ($platform) {
|
||||
if ($Labels) { $cmd += @("--labels", $Labels) }
|
||||
|
||||
if ($Milestone) {
|
||||
$milestoneList = tea milestones list @repoArgs 2>$null
|
||||
$milestoneList = tea milestones list 2>$null
|
||||
$milestoneId = ($milestoneList | Select-String "^\s*(\d+).*$Milestone" | ForEach-Object { $_.Matches.Groups[1].Value } | Select-Object -First 1)
|
||||
if ($milestoneId) {
|
||||
$cmd += @("--milestone", $milestoneId)
|
||||
@@ -125,7 +121,6 @@ switch ($platform) {
|
||||
Write-Warning "Draft PR may not be supported by your tea version"
|
||||
}
|
||||
|
||||
$cmd += $repoArgs
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
default {
|
||||
|
||||
@@ -56,7 +56,6 @@ PY
|
||||
|
||||
url="https://${host}/api/v1/repos/${repo}/pulls"
|
||||
curl -fsS -X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token ${token}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
@@ -178,12 +177,7 @@ case "$PLATFORM" in
|
||||
# is unreliable in Mosaic worktrees/profile shells. Use arrays instead
|
||||
# of eval so markdown backticks/body content are not shell-executed.
|
||||
REPO_SLUG=$(get_repo_slug)
|
||||
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")
|
||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "${GITEA_LOGIN:-mosaicstack}")
|
||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
[[ -n "$BASE_BRANCH" ]] && CMD+=(--base "$BASE_BRANCH")
|
||||
|
||||
@@ -11,7 +11,6 @@ source "$SCRIPT_DIR/detect-platform.sh"
|
||||
PR_NUMBER=""
|
||||
OUTPUT_FILE=""
|
||||
REPO_OVERRIDE=""
|
||||
HOST_OVERRIDE=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -27,17 +26,12 @@ while [[ $# -gt 0 ]]; do
|
||||
REPO_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
HOST_OVERRIDE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [--host host] [-o <output_file>]"
|
||||
echo "Usage: pr-diff.sh -n <pr_number> [-r owner/repo] [-o <output_file>]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -n, --number PR number (required)"
|
||||
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 " -h, --help Show this help"
|
||||
exit 0
|
||||
@@ -75,28 +69,16 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
fi
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
# tea doesn't have a direct diff command — use the API
|
||||
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
|
||||
HOST=$(get_remote_host 2>/dev/null || echo "git.mosaicstack.dev")
|
||||
|
||||
DIFF_URL="https://${HOST}/api/v1/repos/${REPO_INFO}/pulls/${PR_NUMBER}.diff"
|
||||
|
||||
GITEA_API_TOKEN=$(get_gitea_token "$HOST" || true)
|
||||
|
||||
if [[ -n "$GITEA_API_TOKEN" ]]; then
|
||||
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" -H "Authorization: token $GITEA_API_TOKEN" "$DIFF_URL")
|
||||
DIFF_CONTENT=$(curl -sS -H "Authorization: token $GITEA_API_TOKEN" "$DIFF_URL")
|
||||
else
|
||||
DIFF_CONTENT=$(curl -sS -H "User-Agent: curl/8" "$DIFF_URL")
|
||||
DIFF_CONTENT=$(curl -sS "$DIFF_URL")
|
||||
fi
|
||||
|
||||
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||
|
||||
@@ -58,11 +58,6 @@ switch ($platform) {
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
"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)
|
||||
|
||||
if ($Label) {
|
||||
@@ -72,7 +67,6 @@ switch ($platform) {
|
||||
Write-Warning "Author filtering may require manual review for Gitea"
|
||||
}
|
||||
|
||||
$cmd += $repoArgs
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
default {
|
||||
|
||||
@@ -93,18 +93,7 @@ case "$PLATFORM" in
|
||||
"${CMD[@]}"
|
||||
;;
|
||||
gitea)
|
||||
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")
|
||||
CMD=(tea pr list --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}" --state "$STATE" --limit "$LIMIT")
|
||||
|
||||
# tea filtering may be limited
|
||||
if [[ -n "$LABEL" ]]; then
|
||||
|
||||
@@ -74,11 +74,6 @@ switch ($platform) {
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
"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) {
|
||||
$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 }
|
||||
@@ -92,7 +87,6 @@ switch ($platform) {
|
||||
Write-Warning "Branch deletion after merge may need to be done separately with tea"
|
||||
}
|
||||
|
||||
$cmd += $repoArgs
|
||||
& $cmd[0] $cmd[1..($cmd.Length-1)]
|
||||
}
|
||||
default {
|
||||
|
||||
@@ -106,6 +106,34 @@ PLATFORM=$(detect_platform)
|
||||
OWNER=$(get_repo_owner)
|
||||
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() {
|
||||
local error_file="$1"
|
||||
|
||||
@@ -136,7 +164,6 @@ merge_gitea_with_api() {
|
||||
if [[ -n "$token" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H "Authorization: token $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
@@ -152,7 +179,6 @@ merge_gitea_with_api() {
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" \
|
||||
-X POST \
|
||||
-u "$basic_auth" \
|
||||
-H "User-Agent: curl/8" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_url" || true)
|
||||
@@ -188,7 +214,7 @@ if [[ "$DRY_RUN" == true ]]; then
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}"
|
||||
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)."
|
||||
else
|
||||
@@ -211,7 +237,7 @@ case "$PLATFORM" in
|
||||
echo "Error: Cannot determine host from origin remote URL" >&2
|
||||
exit 1
|
||||
}
|
||||
TEA_LOGIN="$(get_gitea_login_for_host "$HOST" || true)"
|
||||
TEA_LOGIN="${GITEA_LOGIN:-$(find_tea_login_for_host "$HOST" || true)}"
|
||||
|
||||
if [[ -n "$TEA_LOGIN" ]]; then
|
||||
mkdir -p "${AGENT_WORK_ROOT:-/home/hermes/agent-work}"
|
||||
|
||||
@@ -57,20 +57,12 @@ curl_gitea_pull() {
|
||||
local token basic_auth raw_code body_file http_code
|
||||
body_file=$(mktemp)
|
||||
|
||||
# shellcheck disable=SC2329 # Invoked by the RETURN trap below.
|
||||
cleanup_gitea_pull_body() {
|
||||
local status=$?
|
||||
rm -f -- "$body_file"
|
||||
trap - RETURN
|
||||
return "$status"
|
||||
}
|
||||
trap cleanup_gitea_pull_body RETURN
|
||||
|
||||
token=$(get_gitea_token "$HOST" || true)
|
||||
if [[ -n "$token" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" -H "Authorization: token $token" "$api_url" || true)
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "Authorization: token $token" "$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
cat "$body_file" || return $?
|
||||
cat "$body_file"
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
http_code="$raw_code"
|
||||
@@ -78,16 +70,17 @@ curl_gitea_pull() {
|
||||
|
||||
basic_auth=$(get_gitea_basic_auth "$HOST" || true)
|
||||
if [[ -n "$basic_auth" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" -H "User-Agent: curl/8" "$api_url" || true)
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -u "$basic_auth" "$api_url" || true)
|
||||
if [[ "$raw_code" =~ ^2 ]]; then
|
||||
cat "$body_file" || return $?
|
||||
cat "$body_file"
|
||||
rm -f "$body_file"
|
||||
return 0
|
||||
fi
|
||||
http_code="$raw_code"
|
||||
fi
|
||||
|
||||
if [[ -z "${http_code:-}" ]]; then
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" -H "User-Agent: curl/8" "$api_url" || true)
|
||||
raw_code=$(curl -sS -w '%{http_code}' -o "$body_file" "$api_url" || true)
|
||||
http_code="$raw_code"
|
||||
fi
|
||||
|
||||
@@ -103,6 +96,7 @@ except Exception:
|
||||
message = open(path, encoding="utf-8", errors="replace").read()[:200] or "empty response"
|
||||
print(f"Error: Gitea pull request API request failed with HTTP {code}: {message}")
|
||||
PY
|
||||
rm -f "$body_file"
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ if [[ -z "$ACTION" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
detect_platform
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
case $ACTION in
|
||||
|
||||
@@ -58,18 +58,7 @@ fi
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
gh pr view "$PR_NUMBER" --repo "$REPO_INFO"
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
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"
|
||||
tea pr "$PR_NUMBER" --repo "$REPO_INFO" --login "${GITEA_LOGIN:-mosaicstack}"
|
||||
else
|
||||
echo "Error: Unknown platform"
|
||||
exit 1
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
#!/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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #560: loud diagnostic + host-derived login for BOTH instances + override-wins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Loud diagnostic: a host with no matching tea login must emit an actionable
|
||||
# error to stderr (the previous behavior was a SILENT failure). The original
|
||||
# mock defines only usc/evil-usc logins, so mosaicstack resolution fails here.
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
diag_stderr=$(run_in_repo bash -c '
|
||||
source "'"$SCRIPT_DIR"'/detect-platform.sh"
|
||||
get_gitea_login_for_host git.mosaicstack.dev
|
||||
' 2>&1 1>/dev/null || true)
|
||||
if ! grep -q "no Gitea tea login matches host 'git.mosaicstack.dev'" <<<"$diag_stderr"; then
|
||||
echo "Expected loud diagnostic naming the unresolved host; got: $diag_stderr" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "Available tea logins:" <<<"$diag_stderr"; then
|
||||
echo "Expected diagnostic to list available tea logins; got: $diag_stderr" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Both-instance host derivation + override-wins, using a mock that DOES define a
|
||||
# mosaicstack login. Scoped to this section so the API-fallback assertions above
|
||||
# (which rely on mosaicstack having NO tea login) remain valid.
|
||||
BIN_DIR2="$WORK_DIR/bin2"
|
||||
mkdir -p "$BIN_DIR2"
|
||||
cp "$BIN_DIR/curl" "$BIN_DIR2/curl"
|
||||
cat > "$BIN_DIR2/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"},
|
||||
{"name":"usc","url":"https://git.uscllc.com","user":"jason.woltje"}
|
||||
]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR2/tea"
|
||||
|
||||
run_in_repo2() {
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR2:$PATH" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
mosaic_login=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
|
||||
if [[ "$mosaic_login" != "mosaicstack" ]]; then
|
||||
echo "Expected mosaicstack origin to derive login 'mosaicstack'; got '$mosaic_login'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
usc_login_derived=$(run_in_repo2 bash -c 'source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
|
||||
if [[ "$usc_login_derived" != "usc" ]]; then
|
||||
echo "Expected usc origin to derive login 'usc'; got '$usc_login_derived'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Explicit GITEA_LOGIN override is honored when it matches the host.
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
override_wins=$(run_in_repo2 bash -c 'export GITEA_LOGIN=mosaicstack; source "'"$SCRIPT_DIR"'/detect-platform.sh"; get_gitea_login')
|
||||
if [[ "$override_wins" != "mosaicstack" ]]; then
|
||||
echo "Expected valid GITEA_LOGIN override to win on mosaicstack host; got '$override_wins'" >&2
|
||||
exit 1
|
||||
fi
|
||||
git -C "$REPO_DIR" remote set-url origin https://git.uscllc.com/USC/uconnect.git
|
||||
|
||||
echo "Gitea login resolution regression harness passed"
|
||||
@@ -1,102 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for issue-create.sh Markdown-body safety (#559).
|
||||
#
|
||||
# Guards against reintroduction of eval-based command construction. The wrapper
|
||||
# builds its tea/gh invocation as an argv array, so a body containing command
|
||||
# substitution ($(...)), backticks, quotes, and dollar signs MUST reach tea
|
||||
# verbatim and MUST NOT be shell-evaluated. This test asserts both:
|
||||
# 1. No command-substitution side effect (an injected `touch SENTINEL` never runs).
|
||||
# 2. The --description value tea receives is byte-for-byte the original body.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-body-safety}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
SENTINEL="$WORK_DIR/INJECTION_SENTINEL"
|
||||
BODY_FILE="$WORK_DIR/body.txt"
|
||||
RECEIVED_FILE="$WORK_DIR/received-description.txt"
|
||||
|
||||
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.mosaicstack.dev/mosaicstack/stack.git
|
||||
|
||||
# Hostile Markdown body. The unquoted heredoc expands $SENTINEL (a real path we
|
||||
# want embedded) but every shell metacharacter we care about is backslash-escaped
|
||||
# so the TEST shell writes them literally into the file — the bytes the wrapper
|
||||
# must then preserve.
|
||||
cat > "$BODY_FILE" <<EOF
|
||||
# Release notes
|
||||
|
||||
Inline code: \`rm -rf /\` must stay literal.
|
||||
Command sub attempt: \$(touch $SENTINEL)
|
||||
Backtick cmd attempt: \`touch $SENTINEL\`
|
||||
Dollars: \$HOME \${PATH} \$5.00 and 100% done
|
||||
Quotes: "double" and 'single' and \`mixed\`
|
||||
Trailing pipe-ish: foo | bar && baz ; qux
|
||||
EOF
|
||||
|
||||
BODY="$(cat "$BODY_FILE")"
|
||||
|
||||
# Mock tea: resolve a mosaicstack login, then capture the --description verbatim.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"name":"mosaicstack","url":"https://git.mosaicstack.dev","user":"jason.woltje"}
|
||||
]
|
||||
JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||
desc=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--description) desc="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$desc" > "$MOSAIC_TEST_RECEIVED"
|
||||
echo "#1 created"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
MOSAIC_TEST_RECEIVED="$RECEIVED_FILE" \
|
||||
"$SCRIPT_DIR/issue-create.sh" -t "Body safety test" -b "$BODY"
|
||||
) >/dev/null
|
||||
|
||||
# 1. No command substitution executed anywhere in the pipeline.
|
||||
if [[ -e "$SENTINEL" ]]; then
|
||||
echo "FAIL: injected command substitution executed (sentinel file created): $SENTINEL" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. tea actually received the body (issue create path taken, not silently dropped).
|
||||
if [[ ! -f "$RECEIVED_FILE" ]]; then
|
||||
echo "FAIL: tea issue create was never invoked with a --description" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. The description tea received is byte-for-byte the original body.
|
||||
if [[ "$(cat "$RECEIVED_FILE")" != "$BODY" ]]; then
|
||||
echo "FAIL: body was not preserved verbatim through issue-create.sh" >&2
|
||||
echo "--- expected ---" >&2; printf '%s\n' "$BODY" >&2
|
||||
echo "--- received ---" >&2; cat "$RECEIVED_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "issue-create.sh Markdown body-safety regression harness passed"
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for lane-brief.sh PR->issue linkage classification.
|
||||
#
|
||||
# Covers the #546/#547 defect: lane-brief.sh inspected only the PR index/title/head
|
||||
# fields and never the PR BODY, so an open PR whose body says "Closes #546" did not
|
||||
# mark issue #546 as work-underway — #546 was listed as a DISPATCH CANDIDATE and was
|
||||
# re-dispatchable in-flight work.
|
||||
#
|
||||
# Asserts:
|
||||
# 1. an open issue closed-keyword-linked from a PR BODY ("Closes #546") is
|
||||
# classified WORK UNDERWAY, not a dispatch candidate.
|
||||
# 2. a BARE "#777" prose mention in a PR body does NOT classify #777 as
|
||||
# work-underway (only Gitea closing keywords are a real link) — #777 stays a
|
||||
# dispatch candidate.
|
||||
# 3. NON-VACUITY / RED-ON-REVERT: a copy of the script with the body-scan removed
|
||||
# misclassifies #546 as a dispatch candidate — proving the body-scan is exactly
|
||||
# what fixes the defect and that assertion 1 fails if the fix is reverted.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LANE_BRIEF="$SCRIPT_DIR/lane-brief.sh"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/lane-brief-pr-linkage}"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
# --- fake `tea`: serves a fixed open-issue set and one open PR. ----------------
|
||||
# PR #547 body uses a closing keyword for #546 ("Closes #546") and a BARE mention
|
||||
# of #777 ("the #777 line of work"). #777 must NOT be treated as linked.
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
case "${1:-} ${2:-}" in
|
||||
"issues list")
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"index":"546","title":"lane-brief + ci-wait orchestration tooling","assignees":[],"milestone":null,"labels":""},
|
||||
{"index":"777","title":"unrelated downstream item","assignees":[],"milestone":null,"labels":""},
|
||||
{"index":"999","title":"item only named inside the word hotfix","assignees":[],"milestone":null,"labels":""}
|
||||
]
|
||||
JSON
|
||||
;;
|
||||
"pulls list")
|
||||
cat <<'JSON'
|
||||
[
|
||||
{"index":"547","title":"feat(framework/tools): orchestration helpers","head":"feat/orchestration-tools-lane-brief-ci-wait","body":"Two additive orchestration tools.\n\nCloses #546.\n\nLogin resolution is relevant to the #777 line of work but does not touch it.\nThis shipped as a hotfix #999 earlier — that bare reference must not link it.\n\nFixes #546\n"}
|
||||
]
|
||||
JSON
|
||||
;;
|
||||
*)
|
||||
echo "fake-tea: unhandled: $*" >&2; exit 1 ;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
run_brief() { # $1 = script path
|
||||
PATH="$BIN_DIR:$PATH" "$1" -r mosaic/stack -L test-login 2>/dev/null
|
||||
}
|
||||
|
||||
# Extract the issue numbers under a named section header until the next blank line.
|
||||
section_nums() { # $1 = output $2 = header-prefix
|
||||
printf '%s\n' "$1" | awk -v h="$2" '
|
||||
index($0,h)==1 {grab=1; next}
|
||||
grab && /^[[:space:]]*$/ {grab=0}
|
||||
grab && match($0, /#[0-9]+/) { print substr($0, RSTART+1, RLENGTH-1) }
|
||||
'
|
||||
}
|
||||
|
||||
fail() { echo "FAIL: $1" >&2; exit 1; }
|
||||
contains() { printf '%s\n' "$1" | grep -qx "$2"; }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixed (current) script behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
OUT="$(run_brief "$LANE_BRIEF")"
|
||||
CAND="$(section_nums "$OUT" 'DISPATCH CANDIDATES')"
|
||||
UNDER="$(section_nums "$OUT" 'WORK UNDERWAY')"
|
||||
|
||||
echo "--- lane-brief output (fixed) ---"; printf '%s\n' "$OUT"
|
||||
echo "--- candidates: [$(printf '%s' "$CAND" | tr '\n' ' ')] underway: [$(printf '%s' "$UNDER" | tr '\n' ' ')] ---"
|
||||
|
||||
contains "$UNDER" 546 || fail "#546 (PR body 'Closes #546') should be WORK UNDERWAY"
|
||||
contains "$CAND" 546 && fail "#546 must NOT be a dispatch candidate (it has an open PR)"
|
||||
contains "$CAND" 777 || fail "#777 (only a bare prose mention) should remain a dispatch candidate"
|
||||
contains "$UNDER" 777 && fail "#777 must NOT be work-underway — bare body mentions are not links"
|
||||
contains "$CAND" 999 || fail "#999 ('hotfix #999' — keyword is a substring) should remain a candidate"
|
||||
contains "$UNDER" 999 && fail "#999 must NOT be work-underway — word-boundary must reject 'hotfix'"
|
||||
echo "PASS: body closing-keyword link classifies #546 underway; bare #777 / substring #999 stay candidates"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NON-VACUITY: revert the body-scan and prove #546 regresses to a candidate.
|
||||
# ---------------------------------------------------------------------------
|
||||
REVERTED="$SCRIPT_DIR/.lane-brief.reverted.$$.sh"
|
||||
trap 'rm -f "$REVERTED"' EXIT
|
||||
# Drop the PR_BODY_REFS contribution from the union (simulates the pre-fix script
|
||||
# that only looked at index/title/head). Sibling `source detect-platform.sh` still
|
||||
# resolves because the copy lives in the same dir.
|
||||
# shellcheck disable=SC2016 # single-quoted on purpose: sed needs the literal $PR_BODY_REFS
|
||||
sed 's/"\$PR_BODY_REFS"/""/' "$LANE_BRIEF" > "$REVERTED"
|
||||
chmod +x "$REVERTED"
|
||||
grep -q 'PR_BODY_REFS' "$REVERTED" || fail "revert sed anchor not found — test is stale"
|
||||
|
||||
ROUT="$(run_brief "$REVERTED")"
|
||||
RCAND="$(section_nums "$ROUT" 'DISPATCH CANDIDATES')"
|
||||
RUNDER="$(section_nums "$ROUT" 'WORK UNDERWAY')"
|
||||
echo "--- candidates(reverted): [$(printf '%s' "$RCAND" | tr '\n' ' ')] underway: [$(printf '%s' "$RUNDER" | tr '\n' ' ')] ---"
|
||||
|
||||
contains "$RCAND" 546 || fail "non-vacuity broken: reverted script should misclassify #546 as a candidate"
|
||||
contains "$RUNDER" 546 && fail "non-vacuity broken: reverted script should NOT mark #546 underway"
|
||||
echo "PASS (RED-on-revert): without the body-scan, #546 regresses to a dispatch candidate"
|
||||
|
||||
echo "ALL PASS: test-lane-brief-pr-linkage.sh"
|
||||
@@ -23,10 +23,6 @@ cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$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
|
||||
echo 'user does not exist [uid: 0, name: ]' >&2
|
||||
exit 1
|
||||
@@ -103,7 +99,6 @@ git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||
export PATH="$MOCK_BIN:$PATH"
|
||||
export PR_MERGE_TEST_LOG="$LOG_FILE"
|
||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
||||
export GITEA_URL="https://git.mosaicstack.dev"
|
||||
export GITEA_TOKEN="redacted-test-token"
|
||||
|
||||
OUTPUT="$SANDBOX/output.log"
|
||||
@@ -132,10 +127,6 @@ cat > "$MOCK_BIN/tea" <<'EOF'
|
||||
set -euo pipefail
|
||||
printf 'tea %q ' "$@" >> "$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
|
||||
echo 'tea network timeout' >&2
|
||||
exit 2
|
||||
|
||||
@@ -7,10 +7,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-metadata-gitea}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
FIXTURE_DIR="$WORK_DIR/fixtures"
|
||||
STUB_DIR="$WORK_DIR/stubs"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR" "$STUB_DIR"
|
||||
mkdir -p "$REPO_DIR" "$FIXTURE_DIR"
|
||||
|
||||
git -C "$REPO_DIR" init -q
|
||||
git -C "$REPO_DIR" remote add origin https://git.uscllc.com/USC/uconnect.git
|
||||
@@ -57,150 +56,6 @@ cat > "$FIXTURE_DIR/gitea-error.json" <<'JSON'
|
||||
{"message": "user does not exist [uid: 0, name: ]", "url": "https://git.uscllc.com/api/swagger"}
|
||||
JSON
|
||||
|
||||
cat > "$STUB_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output_file=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o)
|
||||
output_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-w|-H|-u)
|
||||
shift 2
|
||||
;;
|
||||
-s|-S|-sS)
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$output_file" ]]; then
|
||||
echo "curl stub expected -o <output_file>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "${MOSAIC_STUB_CURL_MODE:-success}" in
|
||||
success)
|
||||
cat > "$output_file" <<'JSON'
|
||||
{
|
||||
"number": 1910,
|
||||
"title": "Live curl path",
|
||||
"state": "open",
|
||||
"user": {"login": "edith"},
|
||||
"head": {"ref": "fix/live-curl-path"},
|
||||
"base": {"ref": "main"},
|
||||
"html_url": "https://git.example.test/acme/widgets/pulls/1910"
|
||||
}
|
||||
JSON
|
||||
printf '200'
|
||||
;;
|
||||
cat-fails-after-2xx)
|
||||
rm -f -- "$output_file"
|
||||
ln -s /nonexistent/pr-metadata-body "$output_file"
|
||||
printf '200'
|
||||
;;
|
||||
*)
|
||||
echo "unknown MOSAIC_STUB_CURL_MODE=${MOSAIC_STUB_CURL_MODE:-}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$STUB_DIR/curl"
|
||||
|
||||
assert_tmpdir_empty() {
|
||||
local tmpdir="$1" leftover
|
||||
leftover=$(find "$tmpdir" -mindepth 1 -print -quit)
|
||||
if [[ -n "$leftover" ]]; then
|
||||
echo "Expected tmpfile cleanup, found leftover: $leftover" >&2
|
||||
find "$tmpdir" -mindepth 1 -maxdepth 1 -ls >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_curl_success_case() {
|
||||
local tmpdir="$WORK_DIR/tmp-success" stderr_file="$WORK_DIR/curl-success.stderr"
|
||||
local output status
|
||||
mkdir -p "$tmpdir"
|
||||
|
||||
set +e
|
||||
output=$(cd "$REPO_DIR" && \
|
||||
PATH="$STUB_DIR:$PATH" \
|
||||
TMPDIR="$tmpdir" \
|
||||
GITEA_TOKEN="stub-token" \
|
||||
GITEA_URL="https://git.example.test" \
|
||||
MOSAIC_STUB_CURL_MODE="success" \
|
||||
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if [[ "$status" -ne 0 ]]; then
|
||||
echo "Expected curl success path to pass, got status $status" >&2
|
||||
cat "$stderr_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "unbound variable" "$stderr_file"; then
|
||||
echo "curl success path emitted unbound-variable cleanup noise" >&2
|
||||
cat "$stderr_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_tmpdir_empty "$tmpdir"
|
||||
|
||||
PR_METADATA_OUTPUT="$output" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
data = json.loads(os.environ["PR_METADATA_OUTPUT"])
|
||||
assert data["number"] == 1910, data
|
||||
assert data["baseRefName"] == "main", data
|
||||
assert data["headRefName"] == "fix/live-curl-path", data
|
||||
PY
|
||||
}
|
||||
|
||||
run_curl_early_exit_cleanup_case() {
|
||||
local tmpdir="$WORK_DIR/tmp-early-exit" stderr_file="$WORK_DIR/curl-early-exit.stderr"
|
||||
local output status
|
||||
mkdir -p "$tmpdir"
|
||||
|
||||
set +e
|
||||
output=$(cd "$REPO_DIR" && \
|
||||
PATH="$STUB_DIR:$PATH" \
|
||||
TMPDIR="$tmpdir" \
|
||||
GITEA_TOKEN="stub-token" \
|
||||
GITEA_URL="https://git.example.test" \
|
||||
MOSAIC_STUB_CURL_MODE="cat-fails-after-2xx" \
|
||||
"$SCRIPT_DIR/pr-metadata.sh" -n 1910 2>"$stderr_file")
|
||||
status=$?
|
||||
set -e
|
||||
|
||||
if [[ "$status" -eq 0 ]]; then
|
||||
echo "Expected unreadable 2xx body path to fail" >&2
|
||||
printf '%s\n' "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "unbound variable" "$stderr_file"; then
|
||||
echo "curl early-exit path emitted unbound-variable cleanup noise" >&2
|
||||
cat "$stderr_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q "No such file or directory" "$stderr_file"; then
|
||||
echo "Expected body-read failure from broken symlink path" >&2
|
||||
cat "$stderr_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q "Gitea API returned non-JSON" "$stderr_file"; then
|
||||
echo "curl helper masked body-read failure as later JSON parsing failure" >&2
|
||||
cat "$stderr_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_tmpdir_empty "$tmpdir"
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local fixture="$1" expected_number="$2" expected_head="$3"
|
||||
local output
|
||||
@@ -222,8 +77,6 @@ PY
|
||||
run_case "$FIXTURE_DIR/gitea-standard.json" 1905 edith/t_39ce717c-authentik-smoke-gate
|
||||
run_case "$FIXTURE_DIR/gitea-fallback.json" 1908 fix/fallback-head
|
||||
run_case "$FIXTURE_DIR/gitea-refs-pull-label.json" 1908 fix/t_23fa9e1d-portal-health-backend
|
||||
run_curl_success_case
|
||||
run_curl_early_exit_cleanup_case
|
||||
|
||||
if cd "$REPO_DIR" && MOSAIC_GITEA_PR_METADATA_RAW_FILE="$FIXTURE_DIR/gitea-error.json" "$SCRIPT_DIR/pr-metadata.sh" -n 1909 >/dev/null 2>"$WORK_DIR/error.log"; then
|
||||
echo "Expected API error fixture to fail" >&2
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
#!/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
|
||||
@@ -31,12 +31,9 @@ Prepends the preamble automatically (auto-detecting your own `host:session`) and
|
||||
delivers reliably to local OR remote panes.
|
||||
|
||||
```bash
|
||||
# Local target (same host, default tmux server)
|
||||
# Local target (same host)
|
||||
agent-send.sh -s <dst_session> -m "message"
|
||||
|
||||
# Local target on a Mosaic fleet socket
|
||||
agent-send.sh -L mosaic-factory -s '=coder0' -m "message"
|
||||
|
||||
# Remote target (over ssh)
|
||||
agent-send.sh -H user@host -s <dst_session> -m "message"
|
||||
|
||||
@@ -45,27 +42,10 @@ agent-send.sh -H user@host -s <dst_session> -f msg.txt
|
||||
echo "msg" | agent-send.sh -s <dst_session>
|
||||
```
|
||||
|
||||
Key flags: `-L` named tmux socket · `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
Key flags: `-s` dst session (required) · `-H` ssh target for remote · `-n` dst
|
||||
hostname for the preamble (else auto-resolved) · `-m`/`-f`/stdin body · `-S`
|
||||
override source label · `-v` verbose · `-r N` Enter-flush attempts.
|
||||
|
||||
For durable fleet use, prefer exact tmux targets such as `=coder0`. The helper
|
||||
normalizes exact session targets to pane-qualified targets internally so pane
|
||||
commands do not fall back to tmux's prefix matching behavior.
|
||||
|
||||
## Named socket isolation
|
||||
|
||||
Durable Mosaic fleets should use a dedicated tmux socket, for example:
|
||||
|
||||
```bash
|
||||
tmux -L mosaic-factory ls
|
||||
agent-send.sh -L mosaic-factory -s '=coder0' -m "status?"
|
||||
send-message.sh -L mosaic-factory -t '=coder0' -m "raw pane message"
|
||||
```
|
||||
|
||||
This keeps fleet operations away from the user's default tmux server. It is the
|
||||
safe rollout path on hosts that already have manual tmux sessions.
|
||||
|
||||
## Why a helper exists (the submission gotcha)
|
||||
|
||||
Pasting into an interactive REPL via raw `tmux send-keys` is unreliable: a
|
||||
@@ -87,7 +67,6 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
|
||||
|
||||
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
||||
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||
|
||||
## Distribution
|
||||
|
||||
|
||||
@@ -23,13 +23,12 @@
|
||||
# the remote host; only bash + tmux + base64 (standard).
|
||||
#
|
||||
# USAGE
|
||||
# agent-send.sh [-L socket] -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh [-L socket] -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh [-L socket] -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||
# echo "msg" | agent-send.sh [-L socket] -H user@host -s <dst_session>
|
||||
# agent-send.sh -s <dst_session> -m "message" # local target
|
||||
# agent-send.sh -H user@host -s <dst_session> -m "message" # remote target
|
||||
# agent-send.sh -H user@host -n <dst_hostname> -s <sess> -f msg.txt
|
||||
# echo "msg" | agent-send.sh -H user@host -s <dst_session>
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` on the target host
|
||||
# -s DST_SESSION target tmux session (or session:window.pane) [required]
|
||||
# -H SSH_TARGET ssh target (user@host) for a remote pane; omit for local
|
||||
# -n DST_HOST hostname to show in the preamble for the target.
|
||||
@@ -48,13 +47,12 @@ set -uo pipefail
|
||||
SELF_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SENDER="$SELF_DIR/send-message.sh"
|
||||
|
||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""; SOCKET_NAME=""
|
||||
DST_SESSION=""; SSH_TARGET=""; DST_HOST=""; MSG=""; FILE=""
|
||||
SRC_LABEL=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,44p' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:s:H:n:m:f:S:r:vh" o; do
|
||||
while getopts "s:H:n:m:f:S:r:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
s) DST_SESSION=$OPTARG ;; H) SSH_TARGET=$OPTARG ;; n) DST_HOST=$OPTARG ;;
|
||||
m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; S) SRC_LABEL=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
@@ -72,12 +70,8 @@ fi
|
||||
|
||||
# Source label: this agent's host:session (auto-detected, overridable).
|
||||
if [ -z "$SRC_LABEL" ]; then
|
||||
tmux_cmd=(tmux)
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
src_host=$(hostname -s 2>/dev/null || echo "?")
|
||||
src_sess=$("${tmux_cmd[@]}" display-message -p '#S' 2>/dev/null || echo "?")
|
||||
src_sess=$(tmux display-message -p '#S' 2>/dev/null || echo "?")
|
||||
SRC_LABEL="${src_host}:${src_sess}"
|
||||
fi
|
||||
|
||||
@@ -95,16 +89,12 @@ FULL="${PREAMBLE} ${MSG}"
|
||||
B64=$(printf '%s' "$FULL" | base64 -w0)
|
||||
|
||||
vflag=""; [ "$VERBOSE" = 1 ] && vflag="-v"
|
||||
socket_args=()
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
socket_args=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_TARGET" ]; then
|
||||
# Local pane: call the canonical sender directly.
|
||||
exec "$SENDER" "${socket_args[@]}" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
|
||||
exec "$SENDER" -t "$DST_SESSION" -b "$B64" -r "$RETRIES" $vflag
|
||||
else
|
||||
# Remote pane: ship the sender over ssh and run it local to the target.
|
||||
ssh -o ConnectTimeout=10 "$SSH_TARGET" \
|
||||
"bash -s -- ${socket_args[*]@Q} -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
"bash -s -- -t '$DST_SESSION' -b '$B64' -r '$RETRIES' $vflag" < "$SENDER"
|
||||
fi
|
||||
|
||||
@@ -13,13 +13,12 @@
|
||||
# no-op in Claude Code, so the double-Enter is safe.
|
||||
#
|
||||
# USAGE
|
||||
# send-message.sh [-L socket_name] -t <target> -m "message"
|
||||
# send-message.sh [-L socket_name] -t <target> -f <file>
|
||||
# echo "message" | send-message.sh [-L socket_name] -t <target>
|
||||
# ssh host bash -s -- -L socket -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
# send-message.sh -t <target> -m "message"
|
||||
# send-message.sh -t <target> -f <file>
|
||||
# echo "message" | send-message.sh -t <target>
|
||||
# ssh host bash -s -- -t <target> -b "$(base64 -w0 <<<msg)" < send-message.sh
|
||||
#
|
||||
# OPTIONS
|
||||
# -L NAME tmux socket name passed to `tmux -L NAME` (optional)
|
||||
# -t TARGET tmux target: session, or session:window.pane [required]
|
||||
# -m MESSAGE message text (single- or multi-line)
|
||||
# -f FILE read message from FILE instead of -m
|
||||
@@ -35,12 +34,11 @@
|
||||
# 3 usage error
|
||||
set -uo pipefail
|
||||
|
||||
SOCKET_NAME=""; TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
TARGET=""; MSG=""; FILE=""; B64=""; RETRIES=2; VERBOSE=0
|
||||
usage() { sed -n '2,34p' "$0"; exit "${1:-3}"; }
|
||||
|
||||
while getopts "L:t:m:f:b:r:vh" o; do
|
||||
while getopts "t:m:f:b:r:vh" o; do
|
||||
case "$o" in
|
||||
L) SOCKET_NAME=$OPTARG ;;
|
||||
t) TARGET=$OPTARG ;; m) MSG=$OPTARG ;; f) FILE=$OPTARG ;; b) B64=$OPTARG ;;
|
||||
r) RETRIES=$OPTARG ;; v) VERBOSE=1 ;; h) usage 0 ;; *) usage 3 ;;
|
||||
esac
|
||||
@@ -53,21 +51,8 @@ elif [ -z "$MSG" ] && [ ! -t 0 ]; then MSG=$(cat)
|
||||
fi
|
||||
[ -n "$MSG" ] || { echo "ERROR: empty message (use -m, -f, or stdin)" >&2; exit 3; }
|
||||
|
||||
tmux_cmd=(tmux)
|
||||
if [ -n "$SOCKET_NAME" ]; then
|
||||
tmux_cmd+=(-L "$SOCKET_NAME")
|
||||
fi
|
||||
|
||||
# tmux accepts `=session` for some commands, but pane-level commands such as
|
||||
# capture-pane require a pane-qualified target. Keep exact-session addressing
|
||||
# convenient while avoiding accidental prefix matches.
|
||||
EFFECTIVE_TARGET=$TARGET
|
||||
if [[ "$TARGET" == =* && "$TARGET" != *:* ]]; then
|
||||
EFFECTIVE_TARGET="${TARGET}:0.0"
|
||||
fi
|
||||
|
||||
# Target must resolve to a live pane.
|
||||
if ! "${tmux_cmd[@]}" list-panes -t "$EFFECTIVE_TARGET" >/dev/null 2>&1; then
|
||||
if ! tmux list-panes -t "$TARGET" >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux target not found: $TARGET" >&2; exit 1
|
||||
fi
|
||||
|
||||
@@ -77,18 +62,18 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
|
||||
|
||||
# 1) Paste the body as a bracketed paste so multi-line content does not submit
|
||||
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
|
||||
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
|
||||
printf '%s' "$MSG" | tmux load-buffer -b __mosaic_send -
|
||||
# -p = bracketed paste when the client supports it; fall back if not.
|
||||
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
|
||||
tmux paste-buffer -d -p -b __mosaic_send -t "$TARGET" 2>/dev/null \
|
||||
|| tmux paste-buffer -d -b __mosaic_send -t "$TARGET"
|
||||
sleep 0.5
|
||||
|
||||
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
||||
status="sent"
|
||||
for attempt in $(seq 1 $((RETRIES + 1))); do
|
||||
"${tmux_cmd[@]}" send-keys -t "$EFFECTIVE_TARGET" Enter
|
||||
tmux send-keys -t "$TARGET" Enter
|
||||
sleep 1.2
|
||||
pane=$("${tmux_cmd[@]}" capture-pane -t "$EFFECTIVE_TARGET" -p 2>/dev/null)
|
||||
pane=$(tmux capture-pane -t "$TARGET" -p 2>/dev/null)
|
||||
|
||||
if printf '%s' "$pane" | grep -qF "$QUEUED_RE"; then
|
||||
status="queued"; break
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
SEND_MESSAGE="$SCRIPT_DIR/send-message.sh"
|
||||
AGENT_SEND="$SCRIPT_DIR/agent-send.sh"
|
||||
SOCKET="mosaic-test-$RANDOM-$$"
|
||||
TARGET="target-$RANDOM"
|
||||
DEFAULT_TARGET="default-target-$RANDOM"
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; tmux kill-session -t "$DEFAULT_TARGET" >/dev/null 2>&1 || true; rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_tmux() {
|
||||
command -v tmux >/dev/null 2>&1 || fail "tmux is required"
|
||||
}
|
||||
|
||||
capture_named() {
|
||||
tmux -L "$SOCKET" capture-pane -t "=$TARGET:0.0" -p
|
||||
}
|
||||
|
||||
capture_default() {
|
||||
tmux capture-pane -t "=$DEFAULT_TARGET:0.0" -p
|
||||
}
|
||||
|
||||
require_tmux
|
||||
|
||||
tmux -L "$SOCKET" new-session -d -s "$TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i'
|
||||
tmux new-session -d -s "$DEFAULT_TARGET" -c "$TMPDIR" 'bash --noprofile --norc -i'
|
||||
|
||||
"$SEND_MESSAGE" -L "$SOCKET" -t "=$TARGET" -m "named socket hello" >/tmp/send-message-named.out
|
||||
sleep 0.2
|
||||
capture_named | grep -qF "named socket hello" || fail "send-message.sh did not deliver to named socket"
|
||||
if capture_default | grep -qF "named socket hello"; then
|
||||
fail "send-message.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
"$AGENT_SEND" -L "$SOCKET" -S "tester:source" -s "=$TARGET" -m "agent socket hello" >/tmp/agent-send-named.out
|
||||
sleep 0.2
|
||||
capture_named | grep -qF "[tester:source ->" || fail "agent-send.sh did not include preamble"
|
||||
capture_named | grep -qF "agent socket hello" || fail "agent-send.sh did not deliver to named socket"
|
||||
if capture_default | grep -qF "agent socket hello"; then
|
||||
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||
fi
|
||||
|
||||
echo "ok - named tmux socket send tools"
|
||||
@@ -26,12 +26,11 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
| --------------------- | -------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
| `ci-wait.sh` | Block until pipeline(s) reach terminal state |
|
||||
| Script | Purpose |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| `pipeline-list.sh` | List recent pipelines for a repo |
|
||||
| `pipeline-status.sh` | Get status of a specific or latest pipeline |
|
||||
| `pipeline-trigger.sh` | Trigger a new pipeline build |
|
||||
|
||||
## Common Options
|
||||
|
||||
@@ -56,7 +55,4 @@ A Woodpecker API token is required. To configure:
|
||||
|
||||
# Trigger a build on a specific branch
|
||||
~/.config/mosaic/tools/woodpecker/pipeline-trigger.sh -b feature/my-branch
|
||||
|
||||
# Block until one or more pipelines finish (event-driven CI wait)
|
||||
~/.config/mosaic/tools/woodpecker/ci-wait.sh -r usc/uconnect -n 3917 -n 3918
|
||||
```
|
||||
|
||||
@@ -12,7 +12,7 @@ wp_resolve_repo_id() {
|
||||
local full_name="$1"
|
||||
local response http_code body repo_id
|
||||
|
||||
response=$(curl -sS -w "\n%{http_code}" \
|
||||
response=$(curl -sk -w "\n%{http_code}" \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
"${WOODPECKER_URL}/api/repos/lookup/${full_name}")
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci-wait.sh — block until one or more Woodpecker pipelines reach terminal state.
|
||||
#
|
||||
# Problem it solves: orchestrators hand-author a `while true; curl .../repos/1/pipelines/$n
|
||||
# ...; sleep` loop for every CI wait. Those loops HARDCODE Woodpecker repo id 1 (only
|
||||
# correct for whichever repo happens to be id 1), re-implement URL building with raw
|
||||
# curl, and tend to get armed as tight <300s ScheduleWakeup polls (each poll = a full
|
||||
# wake+reload+recheck cycle). This encapsulates the loop once, on top of the existing
|
||||
# `pipeline-status.sh` wrapper (which resolves repo->id correctly and is instance-aware),
|
||||
# so a CI wait becomes a one-liner.
|
||||
#
|
||||
# Intended use: as the COMMAND of a Monitor / event-driven re-invoke (primary), paired
|
||||
# with a single long (>=1500s) timed fallback — NOT as a tight standalone poll.
|
||||
#
|
||||
# Usage:
|
||||
# ci-wait.sh -r <owner/repo> -n <num> [-n <num> ...] [-a <instance>] [-i <interval>] [-t <timeout>]
|
||||
# ci-wait.sh -r usc/uconnect -n 3917 -n 3918 # wait for both, infer instance
|
||||
# ci-wait.sh -r usc/uconnect -n 3922 -a usc -i 30 -t 2400
|
||||
#
|
||||
# Instance is inferred from the owner (usc->usc, mosaicstack/mosaic->mosaic) unless -a given.
|
||||
# Exit: 0 = all pipelines terminal AND all 'success'; 1 = >=1 terminal non-success;
|
||||
# 2 = usage/precondition error; 3 = timeout before all terminal.
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve pipeline-status.sh as a sibling, matching how the woodpecker tools source
|
||||
# _lib.sh — works under the installed runtime AND an in-repo checkout, no MOSAIC_HOME dep.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PS="$SCRIPT_DIR/pipeline-status.sh"
|
||||
|
||||
REPO="" INSTANCE="" INTERVAL=30 TIMEOUT=3600
|
||||
NUMS=()
|
||||
while getopts "r:n:a:i:t:h" opt; do
|
||||
case "$opt" in
|
||||
r) REPO="$OPTARG" ;;
|
||||
n) NUMS+=("$OPTARG") ;;
|
||||
a) INSTANCE="$OPTARG" ;;
|
||||
i) INTERVAL="$OPTARG" ;;
|
||||
t) TIMEOUT="$OPTARG" ;;
|
||||
h) grep '^#' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) echo "see -h" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[[ -n "$REPO" ]] || { echo "FATAL: -r <owner/repo> required" >&2; exit 2; }
|
||||
[[ ${#NUMS[@]} -gt 0 ]] || { echo "FATAL: at least one -n <pipeline-number> required" >&2; exit 2; }
|
||||
[[ -x "$PS" ]] || { echo "FATAL: pipeline-status.sh not found/executable at $PS" >&2; exit 2; }
|
||||
|
||||
# Infer Woodpecker instance from owner unless overridden (matches the git-wrapper convention).
|
||||
if [[ -z "$INSTANCE" ]]; then
|
||||
case "${REPO%%/*}" in
|
||||
usc|USC) INSTANCE=usc ;;
|
||||
mosaicstack|mosaic) INSTANCE=mosaic ;;
|
||||
*) echo "FATAL: cannot infer Woodpecker instance for owner '${REPO%%/*}' — pass -a <instance>" >&2; exit 2 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
command -v jq >/dev/null || { echo "FATAL: jq not found" >&2; exit 2; }
|
||||
|
||||
TERMINAL_RE='^(success|failure|error|killed|declined|blocked)$'
|
||||
declare -A STATE=() # num -> terminal status, once reached
|
||||
start=$(date +%s 2>/dev/null || echo 0)
|
||||
|
||||
echo "ci-wait: $REPO pipelines [${NUMS[*]}] (instance=$INSTANCE, every ${INTERVAL}s, timeout ${TIMEOUT}s)"
|
||||
while true; do
|
||||
for n in "${NUMS[@]}"; do
|
||||
[[ -n "${STATE[$n]:-}" ]] && continue
|
||||
s=$("$PS" -r "$REPO" -n "$n" -a "$INSTANCE" -f json 2>/dev/null | jq -r '.status // empty' 2>/dev/null || true)
|
||||
if [[ "$s" =~ $TERMINAL_RE ]]; then
|
||||
STATE[$n]="$s"
|
||||
echo " pipeline $n TERMINAL: $s"
|
||||
fi
|
||||
done
|
||||
# all terminal?
|
||||
if [[ ${#STATE[@]} -eq ${#NUMS[@]} ]]; then
|
||||
bad=0
|
||||
for n in "${NUMS[@]}"; do [[ "${STATE[$n]}" == "success" ]] || bad=1; done
|
||||
if [[ $bad -eq 0 ]]; then echo "ci-wait: ALL SUCCESS"; exit 0; fi
|
||||
echo "ci-wait: all terminal, NOT all success — $(for n in "${NUMS[@]}"; do printf '%s=%s ' "$n" "${STATE[$n]}"; done)"
|
||||
exit 1
|
||||
fi
|
||||
now=$(date +%s 2>/dev/null || echo 0)
|
||||
if [[ "$start" != 0 && $((now - start)) -ge $TIMEOUT ]]; then
|
||||
echo "ci-wait: TIMEOUT after ${TIMEOUT}s — pending: $(for n in "${NUMS[@]}"; do [[ -z "${STATE[$n]:-}" ]] && printf '%s ' "$n"; done)"
|
||||
exit 3
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
@@ -48,7 +48,7 @@ fi
|
||||
# Resolve owner/repo to numeric ID (Woodpecker v3 API)
|
||||
REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
||||
|
||||
response=$(curl -sS -w "\n%{http_code}" \
|
||||
response=$(curl -sk -w "\n%{http_code}" \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
|
||||
_wp_fetch() {
|
||||
local ep="$1"
|
||||
local resp http_code body
|
||||
resp=$(curl -sS -w "\n%{http_code}" \
|
||||
resp=$(curl -sk -w "\n%{http_code}" \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
"$ep")
|
||||
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..."
|
||||
|
||||
response=$(curl -sS -w "\n%{http_code}" -X POST \
|
||||
response=$(curl -sk -w "\n%{http_code}" -X POST \
|
||||
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for ci-wait.sh terminal-state aggregation and exit codes.
|
||||
#
|
||||
# ci-wait.sh wraps pipeline-status.sh and blocks until every requested pipeline
|
||||
# reaches a terminal Woodpecker state, then maps the aggregate to an exit code.
|
||||
# That contract is what callers arm a Monitor/timed-fallback around, so it must be
|
||||
# exact. This harness drives ci-wait.sh against a stub pipeline-status.sh whose
|
||||
# per-pipeline status is fixture-controlled, and asserts the full exit matrix:
|
||||
#
|
||||
# 0 = every pipeline terminal AND all 'success'
|
||||
# 1 = every pipeline terminal, at least one non-success
|
||||
# 2 = usage/precondition error (missing -n)
|
||||
# 3 = timeout before all pipelines terminal
|
||||
#
|
||||
# Non-vacuity: each case pins a DISTINCT exit code to a distinct fixture, so a
|
||||
# regression in success-aggregation (case 0 vs 1), terminal detection (case 3),
|
||||
# or arg validation (case 2) flips exactly one assertion RED.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CIW_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/ci-wait.sh"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/ci-wait-exit-matrix}"
|
||||
TOOL_DIR="$WORK_DIR/tool"
|
||||
|
||||
rm -rf "$WORK_DIR"
|
||||
mkdir -p "$TOOL_DIR"
|
||||
|
||||
# ci-wait.sh resolves pipeline-status.sh as a sibling ($SCRIPT_DIR/pipeline-status.sh),
|
||||
# so we run a COPY of ci-wait.sh next to a stub sibling we control.
|
||||
cp "$CIW_SRC" "$TOOL_DIR/ci-wait.sh"
|
||||
chmod +x "$TOOL_DIR/ci-wait.sh"
|
||||
|
||||
# Stub pipeline-status.sh: emits {"status":"<s>"} where <s> comes from env
|
||||
# CIW_STATUS_<num> (default "running" = non-terminal, drives the timeout path).
|
||||
cat > "$TOOL_DIR/pipeline-status.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
num=""
|
||||
while getopts "r:n:a:f:" opt; do case "$opt" in n) num="$OPTARG" ;; *) : ;; esac; done
|
||||
var="CIW_STATUS_${num}"
|
||||
printf '{"status":"%s"}\n' "${!var:-running}"
|
||||
SH
|
||||
chmod +x "$TOOL_DIR/pipeline-status.sh"
|
||||
|
||||
CIW="$TOOL_DIR/ci-wait.sh"
|
||||
|
||||
run_expect() { # $1 = expected exit $2 = label ; rest = args
|
||||
local want="$1" label="$2"; shift 2
|
||||
local rc=0
|
||||
"$CIW" "$@" >/dev/null 2>&1 || rc=$?
|
||||
if [[ "$rc" -ne "$want" ]]; then
|
||||
echo "FAIL [$label]: expected exit $want, got $rc" >&2; exit 1
|
||||
fi
|
||||
echo "PASS [$label]: exit $rc"
|
||||
}
|
||||
|
||||
# 0 — both pipelines terminal + success
|
||||
CIW_STATUS_100=success CIW_STATUS_101=success \
|
||||
run_expect 0 "all-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
|
||||
|
||||
# 1 — both terminal, one failure
|
||||
CIW_STATUS_100=success CIW_STATUS_101=failure \
|
||||
run_expect 1 "terminal-not-success" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
|
||||
|
||||
# 1 — other terminal non-success states still map to 1 (error/killed)
|
||||
CIW_STATUS_100=error CIW_STATUS_101=killed \
|
||||
run_expect 1 "terminal-error-killed" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 30
|
||||
|
||||
# 3 — a pipeline never reaches terminal state before timeout
|
||||
CIW_STATUS_100=success CIW_STATUS_101=running \
|
||||
run_expect 3 "timeout-pending" -r mosaic/stack -n 100 -n 101 -a mosaic -i 1 -t 0
|
||||
|
||||
# 2 — usage error: no -n
|
||||
run_expect 2 "usage-missing-n" -r mosaic/stack -a mosaic
|
||||
|
||||
echo "ALL PASS: test-ci-wait-exit-matrix.sh"
|
||||
@@ -13,7 +13,6 @@ import { registerStorageCommand } from '@mosaicstack/storage';
|
||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
import { registerUninstallCommand } from './commands/uninstall.js';
|
||||
// prdy is registered via launch.ts
|
||||
@@ -58,7 +57,7 @@ Command Groups:
|
||||
|
||||
Runtime: tui, login, sessions
|
||||
Gateway: gateway
|
||||
Framework: agent, bootstrap, coord, doctor, fleet, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
||||
Framework: agent, bootstrap, coord, doctor, init, launch, mission, prdy, seq, sync, upgrade, wizard, yolo
|
||||
Platform: update
|
||||
Runtimes: claude, codex, opencode, pi
|
||||
`,
|
||||
@@ -346,10 +345,6 @@ registerFederationCommand(program);
|
||||
|
||||
registerAgentCommand(program);
|
||||
|
||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||
|
||||
registerFleetCommand(program);
|
||||
|
||||
// ─── config ────────────────────────────────────────────────────────────
|
||||
|
||||
registerConfigCommand(program);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user