Compare commits

..

9 Commits

Author SHA1 Message Date
jason.woltje
feb0d8a58b fix(framework/tools): wrapper body-safety + login-resolution hardening (#559, #560)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
#559 — Markdown body safety / eval removal:
- Add test-issue-create-body-safety.sh: feeds a hostile Markdown body
  ($(...), backticks, quotes, $vars, pipes) through issue-create.sh and
  asserts no command substitution runs and the body reaches tea verbatim.
- Convert issue-comment.sh from unquoted $(get_gitea_repo_args) word-splitting
  to an argv array with an explicit loud login-resolution error.
- Confirmed: zero eval usages remain across tools/git/*.sh; the other
  body-carrying wrappers (issue-create, pr-create, issue-edit, issue-assign)
  already use argv arrays.

#560 — host-derived Gitea login + loud failure:
- detect-platform.sh: add print_gitea_login_diagnostic and emit it on the
  get_gitea_login_for_host failure path (stderr only) — names the unresolved
  host, lists available tea logins, and gives the GITEA_LOGIN override +
  tea-login-add fix. Replaces the previous silent failure.
- Extend test-gitea-login-resolution.sh: assert the diagnostic fires and lists
  logins, login is derived from origin host for both mosaicstack and usc (scoped
  second tea mock), and a valid GITEA_LOGIN override is honored.

Also gitignore the .mosaic-test-work/ shell-harness scratch dir.
Scope: wrapper surface only. All wrapper test harnesses pass locally.
2026-06-20 04:51:54 -05:00
Hermes Agent
9b7e63f6c3 fix(pr-ci-wait): CI-history primary tier — close webhook-lag false-green (#550)
F-06 follow-up per Mos ruling. The no-CI fast-exit was a pure empty-poll streak
(NO_CI_MAX×interval ≈ 45s), so a slow-to-register pipeline (webhook/queue lag)
looked like 'no CI' and could false-green a merge gate before the pipeline existed.

Two-tier no-CI determination:
- PRIMARY: probe the repo's DEFAULT BRANCH commit status once at startup. If it
  has CI history, the repo runs CI → an empty status on the PR head means the
  pipeline has not REGISTERED yet → never fast-green; poll until it registers or
  timeout (both safe). Closes the webhook-lag false-green.
- SECONDARY: the empty-poll streak fast-exit now applies ONLY to genuinely CI-less
  repos (default branch also has no CI history). Preserves the original no-CI win.
- Probe failure → conservative REPO_HAS_CI=1 (assume CI; wait-then-timeout beats
  false-green). All early returns are explicit 'return 0' + guarded call so the
  probe can never abort under set -e.

Verified: bash -n + shellcheck clean; behavioral harness covers established-repo
(stays 1), CI-less (→0), empty-branch/probe-fail (conservative 1), and the
no-status gate (has-CI never fast-greens, CI-less fast-exits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt2D8TsnDwhtzEAPijsNmR
2026-06-20 04:35:54 -05:00
Hermes Agent
b23a7e81ae fix(framework/tools): wrapper hardening — TLS validation, cred-path fallback, no-CI fast-exit (#550)
F-03: validate TLS by default. New _mosaic_tls_opt helper in _lib/credentials.sh
returns -k only for private-network IP literals (trusted LAN) or an explicit
MOSAIC_INSECURE_TLS opt-in; generic mosaic_http/_post/_patch helpers now use
`curl -sS $_tls` instead of `curl -sk`. Woodpecker scripts (_lib.sh,
pipeline-status/list/trigger.sh) talk only to the two public/valid CI hosts, so
`-sk` is changed to `-sS` (straight -k removal, no helper).

F-02: credentials.sh resolves MOSAIC_CREDENTIALS_FILE via a fallback chain —
env first, then ~/.config/mosaic/credentials.json, then the legacy
~/src/jarvis-brain/credentials.json retained as final fallback so the running
fleet keeps working.

F-06: pr-ci-wait.sh distinguishes a genuine no-CI condition (empty state AND no
statuses) as a new `no-status` state and fast-exits 0 after 3 consecutive empty
polls with a clear "no CI configured" message. Repos that DO have pipelines are
unaffected — any pipeline signal resets the streak and pending still waits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kt2D8TsnDwhtzEAPijsNmR
2026-06-20 04:35:54 -05:00
87f561c1f8 fix(launch): include Pi native skill roots in 'all' mode; dedup 'discover' force-loads (#556)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 19:58:09 +00:00
8c45857859 feat(launch): force-load fleet-critical Pi skills + reconcile skill docs (#555)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-19 18:31:02 +00:00
605221d42f docs(framework/tools): lead TOOLS.md with high-salience fleet-tools cheatsheet (#554)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-19 18:03:03 +00:00
ee584ab48c fix(framework/tools): prettier-format woodpecker README — restore main format gate (#553)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-18 22:39:35 +00:00
ab4e138003 feat(framework/tools): orchestration helpers — lane-brief.sh + ci-wait.sh (#547)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 22:08:40 +00:00
719c6ac3db fix(framework/tools): eval injection, broken JSON, tmpfile leak (#549)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-18 21:35:32 +00:00
33 changed files with 1248 additions and 95 deletions

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ 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/

View File

@@ -0,0 +1,87 @@
# 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.

View File

@@ -5,10 +5,39 @@ 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) |

View File

@@ -29,7 +29,21 @@ Pi supports `--models` for Ctrl+P model cycling during a session. Use cheaper mo
### Skills
Mosaic skills are loaded natively via Pi's `--skill` flag. Skills are discovered from:
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:
- `~/.config/mosaic/skills/` (Mosaic global skills)
- `~/.pi/agent/skills/` (Pi global skills)

View File

@@ -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/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`).
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`).
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/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
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).
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/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/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/rails/codex/codex-code-review.sh --uncommitted`
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted`
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`
3. If blockers/critical findings: remediation task created
4. If clean: task marked done

View File

@@ -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/rails/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/tools/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/rails/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/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/rails/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/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/rails/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
```
**Fallback:** If Codex is unavailable, use Claude's built-in review skills.

View File

@@ -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/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`).
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`).
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/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
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).
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/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/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/rails/codex/codex-code-review.sh --uncommitted`
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted`
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`
3. If blockers/critical findings: remediation task created
4. If clean: task marked done

View File

@@ -159,10 +159,10 @@ Run independent reviews:
```bash
# Code quality review (Codex)
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
~/.config/mosaic/tools/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/rails/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/tools/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/rails/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/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/rails/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/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.

View File

@@ -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/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`).
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`).
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/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
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).
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/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/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/rails/codex/codex-code-review.sh --uncommitted`
2. **Codex security review** — `~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted`
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`
3. If blockers/critical findings: remediation task created
4. If clean: task marked done

View File

@@ -191,10 +191,10 @@ Run independent reviews:
```bash
# Code quality review (Codex)
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
~/.config/mosaic/tools/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/rails/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/tools/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/rails/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/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/rails/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/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.

View File

@@ -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/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`).
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`).
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/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
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).
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/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)

View File

@@ -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/rails/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/tools/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/rails/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/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/rails/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/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/rails/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
```
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.

View File

@@ -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/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`).
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`).
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/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
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).
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/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)

View File

@@ -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/rails/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/tools/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/rails/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/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/rails/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/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/rails/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
```
See `~/.config/mosaic/guides/CODE-REVIEW.md` for the full review checklist.

View File

@@ -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/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`).
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`).
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/rails/git/*.sh` wrappers first (no raw `gh`/`tea`/`glab` as first choice).
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).
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/rails/git/ci-queue-wait.sh --purpose push|merge -B main`.
8. Before push or merge, run CI queue guard: `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge -B main`.
## Container Release Strategy (When Applicable)

View File

@@ -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/rails/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/tools/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/rails/git/ci-queue-wait.sh --purpose push -B main`.
8. Before push, run CI queue guard: `~/.config/mosaic/tools/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/rails/git/ci-queue-wait.sh --purpose merge -B main`.
10. Before merge, run CI queue guard: `~/.config/mosaic/tools/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/rails/codex/codex-code-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
# Security review (Codex)
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
```
**Fallback:** If Codex is unavailable, use Claude's built-in review skills.

View File

@@ -16,7 +16,12 @@
# After loading, service-specific env vars are exported.
# Run `load_credentials --help` for details.
MOSAIC_CREDENTIALS_FILE="${MOSAIC_CREDENTIALS_FILE:-$HOME/src/jarvis-brain/credentials.json}"
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json" "$HOME/src/jarvis-brain/credentials.json"; do
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
done
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/src/jarvis-brain/credentials.json}"
fi
_mosaic_require_jq() {
if ! command -v jq &>/dev/null; then
@@ -34,6 +39,19 @@ _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() {
@@ -261,7 +279,8 @@ mosaic_http() {
local base_url="${4:-}"
local response
response=$(curl -sk -w "\n%{http_code}" -X "$method" \
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X "$method" \
-H "$auth_header" \
-H "Content-Type: application/json" \
"${base_url}${endpoint}")
@@ -279,7 +298,8 @@ mosaic_http_post() {
local base_url="${4:-}"
local response
response=$(curl -sk -w "\n%{http_code}" -X POST \
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X POST \
-H "$auth_header" \
-H "Content-Type: application/json" \
-d "$data" \
@@ -297,7 +317,8 @@ mosaic_http_patch() {
local base_url="${4:-}"
local response
response=$(curl -sk -w "\n%{http_code}" -X PATCH \
local _tls; _tls=$(_mosaic_tls_opt "${base_url}${endpoint}")
response=$(curl -sS $_tls -w "\n%{http_code}" -X PATCH \
-H "$auth_header" \
-H "Content-Type: application/json" \
-d "$data" \

View File

@@ -169,6 +169,43 @@ 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
@@ -190,6 +227,7 @@ get_gitea_login_for_host() {
return 0
fi
print_gitea_login_diagnostic "$host"
return 1
}

View File

@@ -53,7 +53,15 @@ if [[ "$PLATFORM" == "github" ]]; then
gh issue comment "$ISSUE_NUMBER" --body "$COMMENT"
echo "Added comment to GitHub issue #$ISSUE_NUMBER"
elif [[ "$PLATFORM" == "gitea" ]]; then
tea issue comment "$ISSUE_NUMBER" "$COMMENT" $(get_gitea_repo_args)
# 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"
echo "Added comment to Gitea issue #$ISSUE_NUMBER"
else
echo "Error: Unknown platform"

View File

@@ -0,0 +1,129 @@
#!/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)."

View File

@@ -72,6 +72,11 @@ 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
@@ -142,6 +147,21 @@ gitea_get_commit_status_json() {
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url"
}
gitea_get_default_branch() {
local host="$1"
local repo="$2"
local token="$3"
local url="https://${host}/api/v1/repos/${repo}"
curl -fsSL -H "User-Agent: curl/8" -H "Authorization: token ${token}" "$url" | python3 -c '
import json, sys
print((json.load(sys.stdin) or {}).get("default_branch", ""))
'
}
github_get_default_branch() {
gh api "repos/${OWNER}/${REPO}" --jq '.default_branch'
}
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--number)
@@ -245,6 +265,51 @@ 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
@@ -272,11 +337,35 @@ 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

View File

@@ -230,4 +230,81 @@ if grep -q -- 'tea issue close 536 .*--login mosaicstack' "$LOG_FILE"; then
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"

View File

@@ -0,0 +1,102 @@
#!/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"

View File

@@ -0,0 +1,114 @@
#!/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"

View File

@@ -27,10 +27,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 |
## Common Options
@@ -55,4 +56,7 @@ 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
```

View File

@@ -12,7 +12,7 @@ wp_resolve_repo_id() {
local full_name="$1"
local response http_code body repo_id
response=$(curl -sk -w "\n%{http_code}" \
response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/lookup/${full_name}")

View File

@@ -0,0 +1,86 @@
#!/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

View File

@@ -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 -sk -w "\n%{http_code}" \
response=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
"${WOODPECKER_URL}/api/repos/${REPO_ID}/pipelines?perPage=${LIMIT}")

View File

@@ -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 -sk -w "\n%{http_code}" \
resp=$(curl -sS -w "\n%{http_code}" \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
"$ep")
http_code=$(echo "$resp" | tail -n1)

View File

@@ -46,7 +46,7 @@ REPO_ID=$(wp_resolve_repo_id "$REPO") || exit 1
echo "Triggering pipeline for $REPO on branch $BRANCH..."
response=$(curl -sk -w "\n%{http_code}" -X POST \
response=$(curl -sS -w "\n%{http_code}" -X POST \
-H "Authorization: Bearer $WOODPECKER_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg b "$BRANCH" '{branch: $b}')" \

View File

@@ -0,0 +1,76 @@
#!/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"

View File

@@ -1,6 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
import { Command } from 'commander';
import { buildPiSkillArgs, registerRuntimeLaunchers, type RuntimeLaunchHandler } from './launch.js';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildPiSkillArgs,
enumerateSkillDirs,
piForceSkillNames,
registerRuntimeLaunchers,
type RuntimeLaunchHandler,
} from './launch.js';
/**
* Tests for the commander wiring between `mosaic <runtime>` / `mosaic yolo <runtime>`
@@ -23,6 +32,7 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
}
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
const fakeForced = ['--skill', '/skills/mosaic-tools'];
// `process.exit` returns `never`, so vi.spyOn demands a replacement with the
// same signature. We throw from the mock to short-circuit into test-land.
@@ -66,16 +76,42 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
});
describe('buildPiSkillArgs', () => {
it('defaults to disabling Pi skill discovery to keep startup context small', () => {
expect(buildPiSkillArgs([], {}, fakeSkills)).toEqual(['--no-skills']);
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/mosaic-tools',
]);
});
it('keeps explicit user skills while disabling automatic discovery', () => {
expect(buildPiSkillArgs(['--skill', '/tmp/custom'], {}, fakeSkills)).toEqual(['--no-skills']);
it('ignores _runtimeArgs (user --skill flags reach Pi via the launch handler, not here)', () => {
expect(buildPiSkillArgs(['--skill', '/tmp/custom'], {}, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/mosaic-tools',
]);
});
it('supports legacy all-skills mode without double-loading settings skills', () => {
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills)).toEqual([
it('emits only --no-skills when no forced skills are present on disk', () => {
expect(buildPiSkillArgs([], {}, fakeSkills, [])).toEqual(['--no-skills']);
});
it('all-skills mode merges the forced set in without duplicating discovered skills', () => {
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, fakeForced)).toEqual([
'--no-skills',
'--skill',
'/skills/test-driven-development',
'--skill',
'/skills/pdf',
'--skill',
'/skills/mosaic-tools',
]);
});
it('all-skills mode does not double-load a forced skill already discovered', () => {
expect(
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, ['--skill', '/skills/pdf']),
).toEqual([
'--no-skills',
'--skill',
'/skills/test-driven-development',
@@ -84,8 +120,117 @@ describe('buildPiSkillArgs', () => {
]);
});
it('supports native Pi discovery when explicitly requested', () => {
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills)).toEqual([]);
it('force-loads fleet skills under native Pi discovery when not already discoverable', () => {
// Empty native set => Pi would not find mosaic-tools on its own, so force it.
expect(
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced, new Set()),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
it('discover mode drops a forced skill Pi already discovers natively (no double-load)', () => {
// mosaic-tools is reachable from a Pi native root, so native discovery
// covers it — forcing it again would register the same skill twice.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/mosaic-tools']),
),
).toEqual([]);
});
it('discover mode keeps a forced skill that no native root provides', () => {
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
fakeForced,
new Set(['/skills/some-other-skill']),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
it('discover mode collapses a forced skill listed twice to a single --skill', () => {
// Mirror 'all' mode: intra-forced-set duplicates (same realpath) dedup.
expect(
buildPiSkillArgs(
[],
{ MOSAIC_PI_SKILL_MODE: 'discover' },
fakeSkills,
['--skill', '/skills/mosaic-tools', '--skill', '/skills/mosaic-tools'],
new Set(),
),
).toEqual(['--skill', '/skills/mosaic-tools']);
});
});
describe('enumerateSkillDirs (real FS)', () => {
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-skills-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function makeSkill(parent: string, name: string): string {
const dir = join(parent, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `# ${name}\n`);
return dir;
}
it('accepts a symlinked skill dir (regression: synced fleet skills are symlinks)', () => {
// Real skill lives under `canonical/`; the scanned root only has a symlink to it.
const canonical = makeSkill(join(root, 'canonical'), 'mosaic-tools');
const scanned = join(root, 'scanned');
mkdirSync(scanned, { recursive: true });
symlinkSync(canonical, join(scanned, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([scanned])).toEqual(['--skill', join(scanned, 'mosaic-tools')]);
});
it('dedups by real path when the same skill is reachable from two roots', () => {
// Root A holds the real dir; root B symlinks to it — one --skill, not two.
const rootA = join(root, 'a');
const rootB = join(root, 'b');
const real = makeSkill(rootA, 'mosaic-tools');
mkdirSync(rootB, { recursive: true });
symlinkSync(real, join(rootB, 'mosaic-tools'), 'dir');
expect(enumerateSkillDirs([rootA, rootB])).toEqual(['--skill', real]);
});
it('skips directories without a SKILL.md and missing roots', () => {
mkdirSync(join(root, 'present', 'not-a-skill'), { recursive: true });
makeSkill(join(root, 'present'), 'real-skill');
expect(enumerateSkillDirs([join(root, 'present'), join(root, 'does-not-exist')])).toEqual([
'--skill',
join(root, 'present', 'real-skill'),
]);
});
});
describe('piForceSkillNames', () => {
it('defaults to mosaic-tools when MOSAIC_PI_FORCE_SKILLS is unset', () => {
expect(piForceSkillNames({})).toEqual(['mosaic-tools']);
});
it('treats an empty string as "disable force-loading" (distinct from unset)', () => {
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: '' })).toEqual([]);
});
it('parses a colon list, trimming blanks and whitespace', () => {
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: 'mosaic-tools: mosaic-gitea ::' })).toEqual([
'mosaic-tools',
'mosaic-gitea',
]);
});
});

View File

@@ -6,7 +6,15 @@
*/
import { execFileSync, execSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
readdirSync,
realpathSync,
rmSync,
} from 'node:fs';
import { createRequire } from 'node:module';
import { homedir } from 'node:os';
import { join, dirname } from 'node:path';
@@ -428,25 +436,74 @@ function ensureRuntimeConfig(runtime: RuntimeName, destPath: string): void {
// ─── Pi skill/extension discovery ────────────────────────────────────────────
function discoverPiSkills(): string[] {
/** Resolve a skill dir to its canonical real path so symlinked duplicates
* (e.g. ~/.pi/agent/skills/X -> ~/.config/mosaic/skills/X) collapse to one key.
* Falls back to the literal path if it can't be resolved (e.g. broken link). */
function skillRealPath(dir: string): string {
try {
return realpathSync(dir);
} catch {
return dir;
}
}
/** Skill roots Pi auto-discovers natively (no `--skill` needed): its global
* skills dir and the project-local one relative to the launch cwd. */
function piNativeSkillRoots(cwd: string = process.cwd()): string[] {
return [join(homedir(), '.pi', 'agent', 'skills'), join(cwd, '.pi', 'skills')];
}
/** Enumerate skill dirs under a set of roots, deduped by real path. A directory
* counts as a skill when it (or its symlink target) contains a SKILL.md.
* Exported for tests (real-FS coverage of symlink acceptance + realpath dedup). */
export function enumerateSkillDirs(roots: string[]): string[] {
const seen = new Set<string>();
const args: string[] = [];
for (const skillsRoot of [join(MOSAIC_HOME, 'skills'), join(MOSAIC_HOME, 'skills-local')]) {
for (const skillsRoot of roots) {
if (!existsSync(skillsRoot)) continue;
try {
for (const entry of readdirSync(skillsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
// Synced fleet skills land as symlinks, so accept both dirs and links.
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
const skillDir = join(skillsRoot, entry.name);
if (existsSync(join(skillDir, 'SKILL.md'))) {
if (!existsSync(join(skillDir, 'SKILL.md'))) continue;
const key = skillRealPath(skillDir);
if (seen.has(key)) continue;
seen.add(key);
args.push('--skill', skillDir);
}
}
} catch {
// skip
// skip unreadable roots
}
}
return args;
}
/** Every skill dir Pi would link under `MOSAIC_PI_SKILL_MODE=all`: the Mosaic
* global/local catalog plus Pi's own native roots. `--no-skills` suppresses
* native auto-discovery, so 'all' must re-add the native roots explicitly or
* they would be silently dropped. Deduped by real path. */
function discoverPiSkills(cwd: string = process.cwd()): string[] {
return enumerateSkillDirs([
join(MOSAIC_HOME, 'skills'),
join(MOSAIC_HOME, 'skills-local'),
...piNativeSkillRoots(cwd),
]);
}
/** Real paths of skills Pi will auto-discover from its native roots. Used to
* drop redundant force-loads in 'discover' mode (which keeps native discovery
* on) so the same skill is not registered twice. */
function piNativeSkillRealPaths(cwd: string = process.cwd()): Set<string> {
const args = enumerateSkillDirs(piNativeSkillRoots(cwd));
const set = new Set<string>();
for (let i = 1; i < args.length; i += 2) {
const dir = args[i];
if (dir !== undefined) set.add(skillRealPath(dir));
}
return set;
}
type PiSkillMode = 'none' | 'all' | 'discover';
function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
@@ -455,22 +512,96 @@ function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
return 'none';
}
/**
* Fleet-critical Pi skills that are force-loaded on every Pi launch regardless
* of MOSAIC_PI_SKILL_MODE. They cover the highest-frequency cross-agent and
* git-provider operations where Pi workers historically improvised raw CLIs
* (raw `tmux send-keys`, raw `tea`/`gh`/`glab`) instead of the maintained
* `~/.config/mosaic/tools/` wrappers.
*
* An explicit `--skill <dir>` overrides `--no-skills` for that path, so forcing
* a single targeted skill surfaces the must-use toolkit without loading the full
* ~100-skill catalog (context bloat). Missing skills are skipped silently, so
* this is a no-op until the named skill is synced into ~/.config/mosaic/skills/.
*
* Override with MOSAIC_PI_FORCE_SKILLS (colon-separated skill dir names; set to
* an empty string to disable force-loading entirely).
*/
const DEFAULT_PI_FORCE_SKILLS = ['mosaic-tools'];
export function piForceSkillNames(env: NodeJS.ProcessEnv): string[] {
const override = env['MOSAIC_PI_FORCE_SKILLS'];
if (override === undefined) return DEFAULT_PI_FORCE_SKILLS;
return override
.split(':')
.map((name) => name.trim())
.filter(Boolean);
}
function forcedPiSkillArgs(env: NodeJS.ProcessEnv = process.env): string[] {
const args: string[] = [];
for (const name of piForceSkillNames(env)) {
const skillDir = join(MOSAIC_HOME, 'skills', name);
if (existsSync(join(skillDir, 'SKILL.md'))) {
args.push('--skill', skillDir);
}
}
return args;
}
/** Concatenate `--skill <dir>` arg groups, dropping any skill already seen.
* Dedup is by real path, so a forced skill and the same skill reached via a
* different (e.g. symlinked) directory collapse to a single `--skill`. */
function mergeSkillArgs(...groups: string[][]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const group of groups) {
for (let i = 0; i < group.length; i += 2) {
const dir = group[i + 1];
if (group[i] !== '--skill' || dir === undefined) continue;
const key = skillRealPath(dir);
if (seen.has(key)) continue;
seen.add(key);
out.push('--skill', dir);
}
}
return out;
}
export function buildPiSkillArgs(
_runtimeArgs: string[],
env: NodeJS.ProcessEnv = process.env,
discoveredSkillArgs: string[] = discoverPiSkills(),
forcedSkillArgs: string[] = forcedPiSkillArgs(env),
nativeSkillRealPaths: Set<string> = piNativeSkillRealPaths(),
): string[] {
const mode = normalizePiSkillMode(env);
if (mode === 'discover') {
return [];
// Native Pi discovery stays on, so only force-load fleet skills it will NOT
// already find under its native roots — otherwise the same skill is
// registered twice (once natively, once via --skill). mergeSkillArgs first
// collapses any intra-forced-set realpath duplicates, mirroring 'all' mode.
const deduped = mergeSkillArgs(forcedSkillArgs);
const out: string[] = [];
for (let i = 0; i < deduped.length; i += 2) {
const dir = deduped[i + 1];
if (deduped[i] !== '--skill' || dir === undefined) continue;
if (nativeSkillRealPaths.has(skillRealPath(dir))) continue;
out.push('--skill', dir);
}
return out;
}
if (mode === 'all') {
return ['--no-skills', ...discoveredSkillArgs];
// 'all' links the full catalog; merge in the forced set so fleet-critical
// skills are guaranteed present even if they live only under skills-local/.
// discoverPiSkills already covers Pi's native roots, which `--no-skills`
// would otherwise suppress.
return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)];
}
return ['--no-skills'];
return ['--no-skills', ...forcedSkillArgs];
}
function discoverPiExtension(): string[] {