Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa659e3f4 |
@@ -4,14 +4,6 @@ pnpm-lock.yaml
|
||||
**/node_modules
|
||||
**/drizzle
|
||||
**/.next
|
||||
# Python build/test artifacts — same category as node_modules/dist/.next above.
|
||||
# Prettier must never scan generated trees; without these a local venv poisons
|
||||
# `pnpm format:check` with thousands of third-party files.
|
||||
**/venv
|
||||
**/__pycache__
|
||||
**/.mypy_cache
|
||||
**/.pytest_cache
|
||||
**/htmlcov
|
||||
.claude/
|
||||
docs/tess/TASKS.md
|
||||
docs/scratchpads/
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
| FCM-M3-002 | in-progress | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Canonical v2 named-socket + legacy-v1 default-server boundaries; fake adapters/temp fixtures only |
|
||||
| FCM-M4-001 | done | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | PR #788; final head `d63bb0206a1d312ab8352ec1d3ca3631146b0baa`; tree `4da210da9a71b035130d4160a4a2e691bdfde2da`; squash `9745bc3f29c26b021a478b7ad03cfb494f6c9de3`; descendant-main pipeline 1855 terminal success |
|
||||
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | HOLD: never starts a previously stopped agent or kills an unproven unmanaged session; not authorized by FCM-M5-001 |
|
||||
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 content squash 627cf2bb; de-flake repair PR#851/#849 squash 77c9a826; completion proof wp1937 @aa999daf push/ci step 49632 recovery_runtime_unittest.py 3/3 OK (closes wp1932 step 49576 Errno111) |
|
||||
| FCM-M5-001 | done | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | #789 squash `627cf2bb`; exact-head RoR (head `a39bafb8`) and PR/main terminal-green CI 1907; accepted fleet documentation IA, how-to/operations/migration references, and link/example validation delivered |
|
||||
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | HOLD: final #758 gate; quality, independent code/security review, validator certificate, merge-gate approval, and green CI remain out of M5-001 |
|
||||
|
||||
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# mos-remediation — LIVE BOARD (keep < 8 KB)
|
||||
|
||||
**Phase:** PLANNING — adversarial task decomposition IN FLIGHT.
|
||||
**Updated:** 2026-07-31 (mos-remediation orchestrator; seat active on `mosaic-fleet`).
|
||||
|
||||
## Head
|
||||
|
||||
- Mission charter + 15 decisions + 4-build plan: PERSISTED (`docs/remediation/MISSION.md`).
|
||||
- HOLD lifted for this workstream (Jason 2026-07-31). Nothing implemented yet — planning first.
|
||||
- Orchestrator seat `mos-remediation` is LIVE and owns the mission. Residency attestation: PASS.
|
||||
- TASK-0 (env + push mission package) IN PROGRESS — checkout deps repaired, gates being verified honestly.
|
||||
- TASK-1 (adversarial decomp) DISPATCHED for real, both planners on GUARANTEED-CLEAN context.
|
||||
|
||||
## In-flight
|
||||
|
||||
| Task | Owner | State |
|
||||
| ---------------------------------------------------- | --------------- | --------------------------------------------- |
|
||||
| TASK-0 env repair + push `remediation/mission-setup` | mos-remediation | IN PROGRESS — deps installed; gates verifying |
|
||||
| Decomp (robustness side) → `DECOMP-OPUS.md` | planner-opus | WORKING (clean session) |
|
||||
| Decomp (pragmatic side) → `DECOMP-SOL.md` | planner-sol | WORKING (reset to 0.0% ctx before dispatch) |
|
||||
| Reconcile both decomps → `docs/remediation/TASKS.md` | mos-remediation | BLOCKED on the two decomps |
|
||||
|
||||
## Fleet seats
|
||||
|
||||
- mos-remediation — project orchestrator (Claude, /src/mosaic-stack, socket `mosaic-fleet`) — ACTIVE
|
||||
- planner-opus — adversarial planner (robustness), Opus 5, socket `default` — WORKING
|
||||
- planner-sol — adversarial planner (pragmatic), gpt-5.6-sol, socket `default` — WORKING
|
||||
- rev-974 — mosaicstack reviewer identity (id 16, write:repository) — idle, on call
|
||||
- Mos (mos-claude) — lead coordinator, socket `default` — relay path to Jason
|
||||
|
||||
## Gate status
|
||||
|
||||
- Delivery gates active: author≠reviewer, diff-blind pre-registered checks, CI-green, merged-PR completion.
|
||||
- Freeze: LIFTED for this workstream only.
|
||||
- Git identity: `MOSAIC_GIT_IDENTITY=mos-dt-0` INTERIM. Mos ruled gate-16 HOLDS (author≠reviewer is what
|
||||
gate-16 protects; rev-974 reviews, mos-dt-0 never self-reviews). Dedicated identity TRACKED, Mos provisions.
|
||||
- Remote control: native `/remote-control` NOT wired in this runtime. Path is **Mos-relay**
|
||||
(Jason ↔ mos-claude via Discord ↔ mos-remediation via agent-send). Not a blocker.
|
||||
|
||||
## Sequencing (from MISSION.md)
|
||||
|
||||
1. Spine + choke-point service (MACP wiring @ mosaic_orchestrator.py::run_single_task) + PG/Redis
|
||||
2. Rotation daemon (finish Mission Control Plane, reuse packages/coord)
|
||||
3. Comms service (envelope→service→PG/Redis→adapters)
|
||||
4. Hygiene + conformance harness
|
||||
Cross-cutting retirements: flat-file tracking, 3 MACP islands, silent MOSAIC BYPASS.
|
||||
|
||||
## Dogfood evidence captured this session (live failure classes, not hypotheticals)
|
||||
|
||||
- **D-1 / P-ACTIVATION + hygiene — committed `.npmrc` hard-pins `store-dir=/root/.local/share/pnpm/store`.**
|
||||
Correct for the CI container (runs as root), fatal for EVERY non-root local checkout: `EACCES` on
|
||||
`/root/.local/share/pnpm/store/v10/server/server.json`. A committed config that only works on one
|
||||
runtime is exactly the activation-skew class. Fix candidate: make store-dir env-overridable, not hardcoded.
|
||||
- **D-2 / hygiene — husky `prepare` fails `EPERM` copying into root-owned `.husky/_/`.** Repo working
|
||||
tree has root-owned dirs (`.husky/`, repo root) under a non-root agent. Worked around with the
|
||||
intended `HUSKY=0` escape hatch (does NOT disable the existing pre-commit/pre-push hooks).
|
||||
- **D-3 / P-FLEET-001 — the seats running this mission are UNMANAGED.** `mos-remediation`, `rev-974`,
|
||||
`planner-opus`, `planner-sol` appear in NO roster (`~/.config/mosaic/fleet/roster.yaml`, `agents/`).
|
||||
Planners run on socket `default`; the roster declares `mosaic-fleet`. This is the exact
|
||||
"one roster-owned socket/host + quarantine unmanaged + stale GC" failure P-FLEET-001 indicts —
|
||||
observed on the remediation mission's own fleet. Prerequisite for INBOX identity-addressing.
|
||||
- **D-4 / P-LIFECYCLE + hygiene — a dispatched agent silently IGNORED an in-message context reset.**
|
||||
planner-sol was at 64.3%/372k; the brief asked it to reset first; it began work on dirty context anyway.
|
||||
Only an out-of-band `/new` driven by the orchestrator guaranteed clean state. Confirms the postmortem
|
||||
thesis: **instructions are not enforcement.** Reset must be a mechanical pre-dispatch step, not a request.
|
||||
|
||||
## Decisions log
|
||||
|
||||
- 2026-07-31 — Mission set up by Mos post-postmortem (15/15 decided). Dogfood posture active.
|
||||
- 2026-07-31 — Mos: stale `.mosaic/orchestrator/mission.json` is RESIDUE of the disabled Python
|
||||
orchestrator rail that this plan RETIRES. Do NOT invest in it; do NOT build on that rail. The 0/0
|
||||
milestone banner is cosmetic. (Supersedes any plan to repair it.)
|
||||
- 2026-07-31 — Mos: planners must be dispatched with GUARANTEED clean context, not requested-clean.
|
||||
Prior default-socket planner sessions predate this mission; dirty context is the indicted hygiene.
|
||||
- 2026-07-31 — mos-remediation: worker briefs forbid all git ops and restrict each worker to a single
|
||||
named output file, so two planners can share one checkout without a branch race (M2-era incident doctrine).
|
||||
@@ -1,44 +0,0 @@
|
||||
# mos-remediation — Orchestrator Kickstart / Compaction-Survival Resume
|
||||
|
||||
**You are `mos-remediation`, the project orchestrator for the Mosaic Stack remediation, launched in `/src/mosaic-stack`.**
|
||||
This file is your fail-closed resume procedure. Read it on EVERY fresh/cleared session and on the FIRST turn
|
||||
after any compaction. This mission's whole point is that manual compaction-survival is fragile — so follow this
|
||||
mechanically until Build 3 (rotation) makes it automatic.
|
||||
|
||||
## On resume (do in order, before any orchestration action)
|
||||
|
||||
1. `cd /src/mosaic-stack` and confirm you are on the remediation working branch.
|
||||
2. Read `docs/remediation/MISSION.md` — the charter (goal, 4 builds, 15 decisions, sequencing, directives).
|
||||
3. Read `docs/remediation/BOARD.md` — the LIVE state: current phase, in-flight tasks, fleet seat assignments,
|
||||
gate status. This is your single source of in-flight truth (kept small).
|
||||
4. Read the discussion checkpoint for full rationale if needed:
|
||||
`../jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md` (or the jarvis-brain repo path).
|
||||
5. **Residency attestation (fail-closed):** restate from the reloaded files — (a) the goal in one line, (b) the
|
||||
current build/phase, (c) the BOARD head (in-flight tasks + who owns them). If you cannot, HALT and re-read.
|
||||
Do NOT act on memory alone; a compaction may have dropped context silently.
|
||||
|
||||
## Standing invariants (never violate)
|
||||
|
||||
- **North star:** deterministic-right-answer → code/gate; LLM only for judgment.
|
||||
- **Delivery gates:** author≠reviewer; PRE-REGISTERED diff-blind checks committed before reading the diff;
|
||||
CI terminal-green; completion = merged PR + closed issue. rev-974 = the mosaicstack reviewer identity.
|
||||
- **Dogfooding:** every fix validated against its live seed case (MISSION.md lists them).
|
||||
- **Tracking → DB** (hard cutover); do NOT re-invest in flat-file tracking. jarvis-brain PDA is off-limits.
|
||||
- **Git identity:** export `MOSAIC_GIT_IDENTITY=<your-seat>` so wrappers author correctly and survive respawn.
|
||||
|
||||
## After every significant event
|
||||
|
||||
Overwrite stale lines in `BOARD.md`, keep it < 8 KB, commit + push. The board IS your checkpoint until the
|
||||
DB-backed rotation daemon (Build 3) exists. Persist typed state (phase, tasks, owners, gates) — never the transcript.
|
||||
|
||||
## Fleet
|
||||
|
||||
- Adversarial planners: `planner-opus` (robustness), `planner-sol` (pragmatic) — dispatch for task decomposition; reconcile their oppositional decomps.
|
||||
- Coders/reviewers: dispatch per roster + delivery gates. Comms: `~/.config/mosaic/tools/tmux/agent-send.sh`
|
||||
(`-L <socket> -s <dst> -S <yourhost>:<yourseat> --class <class>`); always pass `-S`.
|
||||
- Lead coordinator: Mos (`mos-claude`). Escalate only on the Constitution's escalation triggers.
|
||||
|
||||
## Remote control
|
||||
|
||||
On first startup, activate remote control for this session (`/remote-control`) so Jason can reach/drive you while
|
||||
away. If the command is unavailable in this runtime, report it to Mos and continue — it is not a blocker.
|
||||
@@ -1,98 +0,0 @@
|
||||
# MACP wiring investigation
|
||||
|
||||
**Scope:** `/src/mosaic-stack` inspected at HEAD `b79336a8c11e2a4646a47ff8d295a226e0c71404`; read-only. Existing dirty/untracked state was not touched.
|
||||
|
||||
## Verdict
|
||||
|
||||
**(c) STRANDED.** `packages/macp` is exported, unit-tested, and registered as a CLI command group, but no production dispatch/execution code invokes its credential resolver, gate runner, or event emitter.
|
||||
A separate MACP-named OpenClaw/orchestrator rail exists, but it redefines task/result types and gate/event logic instead of importing `@mosaicstack/macp`; direct `mosaic yolo|claude|codex|opencode|pi` also bypasses it.
|
||||
|
||||
## 1. Production call sites vs tests
|
||||
|
||||
### Production references to `@mosaicstack/macp`
|
||||
|
||||
| Surface | Evidence | Actual use |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Unified CLI | `packages/mosaic/src/cli.ts:8,385` | Imports and registers `registerMacpCommand`; no task/gate/event execution. |
|
||||
| Forge | `packages/forge/src/types.ts:1,17,68,79` | **Type-only** imports of `GateEntry` and `TaskResult`. Pipeline calls an injected abstract executor at `packages/forge/src/pipeline-runner.ts:189-190,299-300`, not MACP. |
|
||||
| Mosaic package metadata | `packages/mosaic/package.json:36`; `packages/mosaic/src/runtime/update-checker.ts:172` | Dependency/update inventory only. |
|
||||
| Agent | No match under production `packages/agent/src/**` | No MACP import/call. |
|
||||
| Coord | No match under production `packages/coord/src/**`; dependency list is only `@mosaicstack/types` at `packages/coord/package.json:25-27` | No MACP import/call. |
|
||||
| Plugins | No `@mosaicstack/macp` import under `plugins/**` | No package use; the MACP-named plugin is an independent implementation (below). |
|
||||
|
||||
**Repository-wide production call-site search result:** excluding `packages/macp/**`, tests, worktrees, and build output, there are **zero** calls to `runGate`, `runGates`, `emitEvent`, `appendEvent`, or `resolveCredentials`.
|
||||
|
||||
### `packages/macp` implementation is internally connected only
|
||||
|
||||
- Public exports: `packages/macp/src/index.ts:1-48` exports Task/GateEntry/MACPEvent/TaskResult, credential resolution, `runGate(s)`, risk-floor, and event emission.
|
||||
- Gate runner calls its own event emitter: `packages/macp/src/gate-runner.ts:187-236`.
|
||||
- Event persistence implementation appends NDJSON to a caller-supplied path: `packages/macp/src/event-emitter.ts:11-27`.
|
||||
- There is **no exported programmatic `submit` implementation** in `packages/macp/src/index.ts:1-48`; only the CLI placeholder named `submit`.
|
||||
|
||||
### Test-only invocations
|
||||
|
||||
- Gate runner: `packages/macp/__tests__/gate-runner.test.ts:96-242` invokes `runGate/runGates`.
|
||||
- Event ledger: `packages/macp/__tests__/event-emitter.test.ts:46-133` invokes `appendEvent/emitEvent` against temporary `events.ndjson` files.
|
||||
- Credential resolver: `packages/macp/__tests__/credential-resolver.test.ts` exercises resolver behavior.
|
||||
- CLI tests only verify command registration: `packages/macp/src/cli.spec.ts:37-73`; `packages/mosaic/src/cli-smoke.spec.ts:8` imports registration.
|
||||
|
||||
## 2. Gate on the live dispatch path
|
||||
|
||||
### Direct Mosaic runtime launch bypasses MACP
|
||||
|
||||
- Runtime commands dispatch directly to harness launch: `packages/mosaic/src/commands/launch.ts:730-801`.
|
||||
- Claude/Pi go through the lease broker, then spawn the runtime: `packages/mosaic/src/commands/launch.ts:817-843`.
|
||||
- Commander wiring sends `mosaic yolo <runtime>` and direct runtime commands to `launchRuntime`: `packages/mosaic/src/commands/launch.ts:1102-1157,1165-1167`.
|
||||
- None of those ranges imports/calls `@mosaicstack/macp`, `runGates`, or `emitEvent`.
|
||||
|
||||
**Result:** a direct `mosaic yolo`, `mosaic claude/codex/opencode/pi`, or underlying exec does not create a typed MACP Task, run the package gate-runner, or append a package MACPEvent.
|
||||
|
||||
### Coord bypasses MACP
|
||||
|
||||
- Coord reads/updates `docs/TASKS.md`: `packages/coord/src/runner.ts:6,306-386`; parser/writer is `packages/coord/src/tasks-file.ts:326-377`.
|
||||
- Coord launches a child process directly: `packages/coord/src/runner.ts:397-427`.
|
||||
- Mission state is its own `.mosaic/orchestrator/mission.json`/`next-task.json`: `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:15-16,355-384`.
|
||||
|
||||
**Result:** Coord task execution has no MACP Task validation, package gate runner, or event append.
|
||||
|
||||
### Forge bypasses MACP execution
|
||||
|
||||
- Forge defines its own `ForgeTask` and abstract `TaskExecutor`: `packages/forge/src/types.ts:48-80`.
|
||||
- The production CLI injects a **stub executor** that immediately reports completion with empty gates: `packages/forge/src/cli.ts:13-31,167,185`.
|
||||
|
||||
**Result:** even `mosaic forge run` does not execute MACP gates or persist MACP events.
|
||||
|
||||
### Separate MACP-named rail is not `packages/macp`
|
||||
|
||||
- OpenClaw plugin registers an ACP backend named `macp`: `plugins/macp/src/index.ts:1-18,72-102`.
|
||||
- It locally redefines `OrchestratorTask`, `TaskResult`, and gate-result shapes instead of importing package types: `plugins/macp/src/macp-runtime.ts:43-77`.
|
||||
- It appends directly to `.mosaic/orchestrator/tasks.json`, triggers an external controller, and polls `results/<task>.json`: `plugins/macp/src/macp-runtime.ts:290-329,437-483`.
|
||||
- The controller independently implements `append_event`, `emit_event`, shell execution, gate execution, and results: `packages/mosaic/framework/tools/orchestrator-matrix/controller/mosaic_orchestrator.py:29-91,126-276`.
|
||||
- Its gate loop runs raw string gates after worker success: `mosaic_orchestrator.py:213-235`; it does not support the package's structured `GateEntry`/AI-review behavior.
|
||||
- Current checkout disables this controller: `.mosaic/orchestrator/config.json:2` (`"enabled": false`).
|
||||
- Plugin references `tools/macp/dispatcher/pi_runner.ts` at `plugins/macp/src/macp-runtime.ts:85-91`, but `tools/macp/` does not exist in this checkout.
|
||||
|
||||
**Result:** there is a parallel, optionally enabled MACP-shaped rail, not package integration. It cannot make `packages/macp` the enforced path.
|
||||
|
||||
## 3. Event ledger status
|
||||
|
||||
- Package persistence exists only as a library primitive: `packages/macp/src/event-emitter.ts:11-27` appends JSON lines to an arbitrary `eventsPath`.
|
||||
- Package event emission is reached only from package `runGates`: `packages/macp/src/gate-runner.ts:204-236`.
|
||||
- No production caller invokes package `runGates/emitEvent/appendEvent`; therefore no runtime destination path is configured for the package ledger.
|
||||
- Test-only ledgers use temp paths: `packages/macp/__tests__/event-emitter.test.ts:35-133`; gate tests use temp `events.ndjson`: `packages/macp/__tests__/gate-runner.test.ts:171-242`.
|
||||
- The separate Python controller writes `.mosaic/orchestrator/events.ndjson`: `mosaic_orchestrator.py:129-133,159-161,219-235`; the Mosaic Framework plugin only **reads** that file for context at `plugins/mosaic-framework/src/index.ts:279-316,430-438`.
|
||||
- In this checkout, `.mosaic/orchestrator/events.ndjson` is absent and the controller is disabled (`.mosaic/orchestrator/config.json:2`).
|
||||
|
||||
**Conclusion:** `MACPEvent` from `packages/macp` is defined/tested but not emitted or persisted by live production call sites. The similarly shaped Python ledger is a duplicate island.
|
||||
|
||||
## 4. Coord link
|
||||
|
||||
- `packages/coord` has no `@mosaicstack/macp` dependency/import: `packages/coord/package.json:25-27`; no matches in `packages/coord/src/**`.
|
||||
- Coord's task model is Markdown `docs/TASKS.md` plus mission/session JSON: `packages/coord/src/tasks-file.ts:1-10,257-377`; `packages/coord/src/mission.ts:8-12`; `packages/coord/src/runner.ts:306-427`.
|
||||
- It does not consume `.mosaic/orchestrator/events.ndjson`, MACP Task, MACPEvent, GateEntry, or TaskResult.
|
||||
|
||||
**Conclusion:** Coord and `packages/macp` are disconnected islands.
|
||||
|
||||
## Shortest wiring gap
|
||||
|
||||
**Single integration point:** replace the duplicated execution/gate/event block in `mosaic_orchestrator.py::run_single_task` (`:126-276`) with one production Node `TaskExecutor` backed by `@mosaicstack/macp` (typed Task validation + `resolveCredentials` + `runGates` + `emitEvent`), and make Coord/Forge/OpenClaw submit through that executor. This queue/controller choke point is where `yolo|acp|exec` worker outcomes can be gated and journaled before completion is recorded.
|
||||
@@ -1,79 +0,0 @@
|
||||
# Mosaic Stack Remediation — Mission Charter
|
||||
|
||||
**Owner:** project orchestrator `mos-remediation` (Claude, launched in `/src/mosaic-stack`).
|
||||
**Origin:** 2026-07-16..31 fleet lifecycle postmortem. **Status:** PLANNING (task decomposition).
|
||||
**HOLD lifted** for this workstream by Jason, 2026-07-31 — "begin full mosaic fleet operation on this."
|
||||
|
||||
## Goal
|
||||
|
||||
Convert the 15 accepted postmortem remediation proposals into a working, **dogfooded** implementation.
|
||||
**North star:** anything with a deterministic right answer moves OUT of the LLM into a deterministic
|
||||
gate/program; the LLM handles only genuine judgment.
|
||||
|
||||
## Decision record (authoritative, immutable)
|
||||
|
||||
- **15/15 proposals decided: 13 accept, 2 modify (P-AUTHORITY-001, P-INBOX-001), 0 reject.**
|
||||
- Site + `annotations.json`: `jarvis-brain/docs/postmortem-spec/site/` (committed, origin/main).
|
||||
- Discussion checkpoint (rich rationale per proposal): `jarvis-brain/docs/scratchpads/postmortem/REMEDIATION-DISCUSSION-STATE.md`.
|
||||
- Postmortem report: mosaicstack/stack PR #107 (merged 88f4ee04).
|
||||
- MACP wiring scout (verdict c=STRANDED): `/tmp/macp-wiring-investigation.md` (copy into this dir — see TODO).
|
||||
|
||||
## The plan — 15 proposals collapse to 4 builds + hygiene
|
||||
|
||||
| Build | Absorbs | What it is |
|
||||
| ------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **1. One choke-point service** (mechanical enforcer) | MISSION, STATE, AUDIT, WRAPPER, QUEUE | Deterministic program every task/data mutation flows through. **Wire the stranded `@mosaicstack/macp`** in at `mosaic_orchestrator.py::run_single_task` — typed tasks, gate-runner, event ledger, credential binding, tri-state write outcomes. |
|
||||
| **2. One durable spine + hot path** | (storage under everything) | **PG system-of-record + Redis hot queue** (transactional-outbox). Mission/tasks/state-claims/audit-ledger/comms-inbox all land here. |
|
||||
| **3. Rotation lifecycle** (finish the Mission Control Plane) | LIFECYCLE, CONTRACT, GUIDE, RECOVERY | Coordinator daemon: contract-hash binding, compaction-detected → rotate-not-compact, checkpoint→fresh-session→rehydrate, broker-independent recovery. Deterministic, not an LLM. Reuse `packages/coord`; existing PRD at `docs/mission-control/`. |
|
||||
| **4. Comms service** | AUTHORITY, INBOX (+ versioning roadmap) | Envelope (comms/v1) → sole-path service → PG/Redis → pluggable adapters (tmux→Matrix/Discord/Slack/Telegram). Version the protocol, not participants. |
|
||||
| **+ Hygiene & proof** | FLEET, WORKFLOW, CONFORMANCE | One roster-owned socket/host + stale GC; allowlist auto-sync; the conformance harness that fault-injects the failure classes and proves builds 1–4 hold. |
|
||||
|
||||
## The finding that sets the cost
|
||||
|
||||
**Built-but-unwired disease.** `@mosaicstack/macp` is stranded (nothing calls it); `packages/coord` primitives
|
||||
exist; the Mission Control PRD exists; PG + Redis already run in-stack. Three duplicate MACP islands, an
|
||||
orphaned context loader, a fail-open bypass. **Work = wire + consolidate + retire, NOT greenfield. "Finish, don't re-spec."**
|
||||
|
||||
## Sequencing (skeleton — adversarial decomposition refines this)
|
||||
|
||||
1. **Spine + choke-point service** (builds 1+2) — foundation; unlocks MISSION/STATE/AUDIT/WRAPPER/QUEUE at one integration point.
|
||||
2. **Rotation daemon** (build 3) on that spine — the drift fix proper.
|
||||
3. **Comms service** (build 4) — envelope → service → PG/Redis → adapters; retire direct-tmux.
|
||||
4. **Hygiene + conformance** (build 5) — fleet convergence, allowlist sync, dogfood harness.
|
||||
|
||||
- **Cross-cutting retirements:** flat-file orchestration tracking (hard cutover to DB), the 3 duplicate MACP islands, the silent `MOSAIC BYPASS`.
|
||||
|
||||
## Standing directives (Jason, 2026-07-31)
|
||||
|
||||
- **Dogfooding:** validate EACH fix against the live fleet failure that motivated it. Seed acceptance tests:
|
||||
Pi brick (RECOVERY), scout-bounce (INBOX/FLEET), gate-6 inert + #1019 recursion (QUEUE), identity drift
|
||||
(WRAPPER), auto-sync sweep (WORKFLOW), #1018 stale-consumed (INBOX). The fleet is its own test bed.
|
||||
- **Orchestration tracking → DB**, hard cutover ("rip off the bandaid"), NO flat-file interim. jarvis-brain
|
||||
PDA flat-files untouched. Current flat-file tracking runs as-is/unhardened until DB tracking is real, then one clean replace.
|
||||
|
||||
## The 15 decisions (one-line; full rationale in the checkpoint)
|
||||
|
||||
1. **P-ACTIVATION-001** accept — transactional CLI+hooks+broker+version release; block launch on skew, fail-SAFE.
|
||||
2. **P-AUTHORITY-001** MODIFY — structured authenticated inbox; envelope carries comms-PROTOCOL version; version the protocol not participants; N-version window.
|
||||
3. **P-LIFECYCLE-001** accept — rotation not recursive compaction; pre-empt at token threshold; enforcer = deterministic coordinator; = finish Mission Control Plane.
|
||||
4. **P-MISSION-001** accept — bind lanes to mission+task ledger; convention exists, ENFORCEMENT is the gap; mission+tasks → DB spine (hard cutover).
|
||||
5. **P-QUEUE-001** accept — repair queue transport + exit-asserting non-null-case tests (gate-6 was INERT fleet-wide; #1019 fix recursed the same bug).
|
||||
6. **P-STATE-001** accept — typed claims (source/confidence/TTL) not prose blob; MACP typed record; integrity fail-closed HMAC; don't fork a 4th island.
|
||||
7. **P-AUDIT-001** accept — MACPEvent lifecycle ledger; EXTEND enum to lifecycle events; runtime-neutral (executor-emitted); retire duplicate Python ledger.
|
||||
8. **P-WRAPPER-001** accept — identity derives from seat name + survives respawn; tri-state write outcomes MANDATORY; name safe target metadata.
|
||||
9. **P-CONTRACT-001** accept — bind session to contract hash; re-anchor on policy-change OR compaction-detected; stale generation loses authority MECHANICALLY.
|
||||
10. **P-INBOX-001** MODIFY — sole-path comms SERVICE; PG durable SoR + Redis hot queue (outbox, reconciliation sweeper); pluggable adapters; protocol-first, PG-first-then-Redis.
|
||||
11. **P-RECOVERY-001** accept — broker-independent bootstrap recovery; honest capability labeling; break-glass LOUD+AUDITED+TEMPORARY not silent permanent bypass.
|
||||
12. **P-GUIDE-001** accept — delete `/compact and continue` from orchestrator path (keep for ephemeral); removal = substitution (wire rotation trigger).
|
||||
13. **P-FLEET-001** accept — one roster-owned socket/host; quarantine unmanaged; stale-session GC; prerequisite for INBOX identity-addressing.
|
||||
14. **P-WORKFLOW-001** accept — auto-sync ALLOWLIST not denylist; worktree/lease isolation for agent docs/source; DB-tracking obviates the flat-file-sweep criterion.
|
||||
15. **P-CONFORMANCE-001** accept — fleet lifecycle harness on REAL runtime artifacts + fault injection; the 100-rotations-lossless bar is a test; target the DB substrate.
|
||||
|
||||
## Fleet operating model
|
||||
|
||||
- **Project orchestrator** `mos-remediation` (this seat) owns the mission; coordinates under Mos (lead).
|
||||
- **Adversarial task decomposition:** `planner-opus` (robustness) + `planner-sol` (pragmatic) each decompose
|
||||
the plan independently; orchestrator reconciles into `TASKS.md`/DB tasks. Oppositional by design.
|
||||
- **Delivery gates (non-negotiable):** author≠reviewer, PRE-REGISTERED diff-blind acceptance checks committed
|
||||
before reading the diff, CI terminal-green, completion = merged PR + closed issue. rev-974 = mosaicstack reviewer.
|
||||
- **Compaction survival:** see `KICKSTART.md` in this dir — the resume procedure. Persist typed state, not transcript.
|
||||
@@ -1,58 +0,0 @@
|
||||
# Issue #812 — durable Gitea PR review comments
|
||||
|
||||
- **Lane:** ms-812
|
||||
- **Branch:** `fix/812-pr-review-comment`
|
||||
- **Issue:** mosaicstack/stack#812
|
||||
- **Budget:** 15K working estimate; single focused shell-wrapper/test/docs change.
|
||||
|
||||
## Objective
|
||||
|
||||
Make the Gitea `comment` action in `packages/mosaic/framework/tools/git/pr-review.sh` use the supported Gitea comments REST API and report success only after provider read-back verifies the created comment against the intended repository, PR, and exact body.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add and commit a failing shell regression harness before production changes.
|
||||
2. Verify RED against the nonexistent `tea pr comment` fallback false-positive.
|
||||
3. Implement the minimal supported write plus ID-based provider read-back.
|
||||
4. Document that wrapper write output is not durable provenance until read-back succeeds.
|
||||
5. Run focused regression tests, touched-package tests, and repository quality gates.
|
||||
6. Remediate review findings, queue-guard, and push for coordinator-owned independent review. Do not open or merge a PR.
|
||||
|
||||
## Progress checkpoints
|
||||
|
||||
- [x] RED regression committed and reported to mosaic-100 (rebased commit `770e3f57`)
|
||||
- [x] Initial minimal fix implemented (rebased commit `ea7f8c57`)
|
||||
- [x] Rebased cleanly onto main `627cf2bb387f7c84a532d88819903a7679ce0d72`
|
||||
- [x] Codex blocker remediated by replacing unsupported `tea api` with authenticated REST write/read-back
|
||||
- [x] Focused, package, and repository gates green
|
||||
- [ ] Coordinator-owned independent review pending after push
|
||||
- [x] No PR opened; no self-review or self-merge
|
||||
|
||||
## Tests run
|
||||
|
||||
- RED after rebase: the regression harness failed against `origin/main` with status 1 after reproducing the old `tea pr comment` zero-exit fallback and false success echo.
|
||||
- GREEN at resumed head: the same harness passed with REST POST 201 plus GET 200 read-back.
|
||||
- All `packages/mosaic/framework/tools/git/test-*.sh` harnesses passed.
|
||||
- `shellcheck -x` passed for the changed scripts; `bash -n` passed.
|
||||
- Manifest resolver returned `framework` for `tools/git/test-pr-review-gitea-comment.sh`.
|
||||
- `pnpm test` passed (43/43 Turbo tasks; Mosaic 75 files/1434 tests; Gateway 56 files/628 tests plus documented skips).
|
||||
- `pnpm typecheck` passed (42/42 tasks), `pnpm lint` passed (23/23), and `pnpm format:check` passed.
|
||||
- Firewall checks found no user-home paths or operator identities in changed shipped files; no token value is logged or echoed.
|
||||
|
||||
## Risks / blockers
|
||||
|
||||
- No active implementation blocker. #789 reached terminal merged state and the coordination hold was lifted.
|
||||
- Review round 1 found one portability blocker: the API base reconstructed `https://$host` and discarded configured schemes/path prefixes.
|
||||
- Review round 2 found a second subpath portability blocker: clone-derived `get_repo_slug` retained the deployment prefix, duplicating it under `/api/v1/repos/`.
|
||||
- Round 3 resolves owner/repo relative to the configured Gitea base path for HTTP(S) clones while preserving root-mounted and SSH clone forms. Host matching now compares non-default ports consistently.
|
||||
- REST transport failures, non-201 writes, malformed/missing created IDs, non-200 read-backs, and read-back mismatches all fail closed.
|
||||
- Existing approve/request-changes behavior remains covered.
|
||||
- Independent exact-head re-review remains coordinator-owned.
|
||||
|
||||
## Final verification evidence
|
||||
|
||||
- URL-portability regression was RED before remediation at the new `http://git.mosaicstack.dev` case and GREEN afterward.
|
||||
- Round-3 genuine subpath regression was RED against round-2 head `1b190201` and GREEN after the fix: `https://git.example/gitea/owner/repo.git` maps to API repository `owner/repo` under configured base `/gitea`.
|
||||
- Regression coverage verifies POST and read-back GET for root-mounted HTTP(S), path-prefixed HTTP(S), non-default HTTP port, scp-style SSH, and `ssh://` clone forms.
|
||||
- Focused shell checks, all git-wrapper harnesses, and full repository test/typecheck/lint/format gates passed after remediation.
|
||||
- Branch will be force-pushed with lease for coordinator re-verification; no PR opened.
|
||||
@@ -1,9 +0,0 @@
|
||||
# Git provider wrappers
|
||||
|
||||
These scripts provide host-aware GitHub and Gitea issue, pull-request, milestone, and CI operations.
|
||||
|
||||
## Durable review provenance
|
||||
|
||||
A successful provider write command—or a wrapper message based only on that command's exit code—is **not** durable review provenance. Review comments count as durable provenance only after the wrapper reads the created provider record back and verifies that it belongs to the intended repository and pull request and contains the exact submitted body (or verifies the provider-returned record ID).
|
||||
|
||||
`pr-review.sh` therefore fails closed when a Gitea comment cannot be written, its created comment ID cannot be identified, or provider read-back does not match. It reports comment success only after that read-back verification passes.
|
||||
@@ -81,32 +81,7 @@ get_repo_slug() {
|
||||
gitea_url_matches_host() {
|
||||
local url="${1:-}" host="${2:-}"
|
||||
[[ -n "$url" && -n "$host" ]] || return 1
|
||||
python3 - "$url" "$host" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
url, remote_host = sys.argv[1:]
|
||||
configured = urlparse(url)
|
||||
remote = urlparse(f"//{remote_host}")
|
||||
if configured.scheme not in {"http", "https"} or configured.hostname != remote.hostname:
|
||||
raise SystemExit(1)
|
||||
|
||||
# Normalize by scheme: an implicit (portless) HTTP(S) URL and its explicit
|
||||
# default-port form (":80" for http, ":443" for https) name the same
|
||||
# provider endpoint. Apply that equivalence symmetrically -- whichever side
|
||||
# omits the port is treated as carrying the scheme's default port -- so
|
||||
# "configured implicit vs. remote explicit" and "configured explicit vs.
|
||||
# remote implicit" both match. (The remote side here is always an HTTP(S)
|
||||
# authority; an SSH remote's transport port is stripped by get_remote_host
|
||||
# before reaching this comparison, since it identifies an unrelated
|
||||
# service on the same host, not the HTTP(S) provider port.)
|
||||
default_port = 80 if configured.scheme == "http" else 443
|
||||
normalized_configured = configured.port if configured.port is not None else default_port
|
||||
normalized_remote = remote.port if remote.port is not None else default_port
|
||||
if normalized_configured != normalized_remote:
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0)
|
||||
PY
|
||||
[[ "${url%/}" == "https://$host" || "${url%/}" == "http://$host" || "${url%/}" == *"//$host" ]]
|
||||
}
|
||||
|
||||
get_gitea_service_for_host() {
|
||||
@@ -372,47 +347,6 @@ get_gitea_api_host_for_repo_override() {
|
||||
get_host_from_url "${GITEA_URL:-}"
|
||||
}
|
||||
|
||||
# Resolve owner/repo relative to a configured Gitea base URL. HTTP(S) clone
|
||||
# URLs can include the deployment prefix (for example /gitea/owner/repo.git),
|
||||
# but Gitea's /repos API expects only owner/repo. Root-mounted and SSH clone
|
||||
# forms retain their existing owner/repo behavior.
|
||||
get_gitea_repo_slug_for_url() {
|
||||
local configured_url="$1" remote_url
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || return 1
|
||||
|
||||
if [[ "$remote_url" =~ ^https?:// ]]; then
|
||||
python3 - "$remote_url" "$configured_url" <<'PY'
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
remote = urlparse(sys.argv[1])
|
||||
base = urlparse(sys.argv[2])
|
||||
remote_path = remote.path.strip("/")
|
||||
if remote_path.endswith(".git"):
|
||||
remote_path = remote_path[:-4]
|
||||
base_path = base.path.strip("/")
|
||||
remote_parts = [part for part in remote_path.split("/") if part]
|
||||
base_parts = [part for part in base_path.split("/") if part]
|
||||
|
||||
if base_parts and remote_parts[:len(base_parts)] == base_parts:
|
||||
repo_parts = remote_parts[len(base_parts):]
|
||||
elif len(remote_parts) == 2:
|
||||
# Preserve a root-shaped clone URL when provider API configuration carries
|
||||
# a reverse-proxy prefix separately.
|
||||
repo_parts = remote_parts
|
||||
else:
|
||||
raise SystemExit(1)
|
||||
|
||||
if len(repo_parts) != 2:
|
||||
raise SystemExit(1)
|
||||
print("/".join(repo_parts))
|
||||
PY
|
||||
return
|
||||
fi
|
||||
|
||||
get_repo_slug
|
||||
}
|
||||
|
||||
get_gitea_repo_args() {
|
||||
local repo host login
|
||||
repo=$(get_repo_slug) || return 1
|
||||
@@ -436,15 +370,6 @@ get_remote_host() {
|
||||
echo "${host##*@}"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^ssh://([^/]+)/ ]]; then
|
||||
local host="${BASH_REMATCH[1]}"
|
||||
host="${host##*@}"
|
||||
# Strip an SSH transport port (e.g. "git.example:2222"): it names the
|
||||
# SSH daemon port, not the HTTP(S) provider API port, and must not
|
||||
# feed gitea_url_matches_host's port comparison (#850).
|
||||
echo "${host%%:*}"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$remote_url" =~ ^git@([^:]+): ]]; then
|
||||
echo "${BASH_REMATCH[1]}"
|
||||
return 0
|
||||
@@ -452,51 +377,6 @@ get_remote_host() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve the configured Gitea base URL for a host from the same credential
|
||||
# source used by get_gitea_token. The scheme and any deployment path prefix are
|
||||
# provider configuration and must not be reconstructed from the git remote.
|
||||
get_gitea_url_for_host() {
|
||||
local host="$1" script_dir cred_loader url
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cred_loader="$script_dir/../_lib/credentials.sh"
|
||||
|
||||
if [[ -f "$cred_loader" ]]; then
|
||||
url=$(
|
||||
# shellcheck source=/dev/null
|
||||
source "$cred_loader"
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
case "$host" in
|
||||
git.mosaicstack.dev) load_credentials gitea-mosaicstack 2>/dev/null ;;
|
||||
git.uscllc.com) load_credentials gitea-usc 2>/dev/null ;;
|
||||
*)
|
||||
for svc in gitea-mosaicstack gitea-usc; do
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
load_credentials "$svc" 2>/dev/null || continue
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
break
|
||||
fi
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
done
|
||||
;;
|
||||
esac
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
printf '%s' "${GITEA_URL%/}"
|
||||
fi
|
||||
)
|
||||
if [[ -n "$url" ]]; then
|
||||
printf '%s\n' "$url"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
printf '%s\n' "${GITEA_URL%/}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Resolve a Gitea API token for the given host.
|
||||
# Priority: Mosaic credential loader → GITEA_TOKEN env → ~/.git-credentials
|
||||
get_gitea_token() {
|
||||
@@ -523,7 +403,7 @@ get_gitea_token() {
|
||||
for svc in gitea-mosaicstack gitea-usc; do
|
||||
unset GITEA_TOKEN GITEA_URL
|
||||
load_credentials "$svc" 2>/dev/null || continue
|
||||
if gitea_url_matches_host "${GITEA_URL:-}" "$host"; then
|
||||
if [[ "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
||||
matched=true
|
||||
break
|
||||
fi
|
||||
@@ -543,7 +423,7 @@ get_gitea_token() {
|
||||
|
||||
# 2. GITEA_TOKEN env var (only when GITEA_URL, if present, matches the remote host)
|
||||
if [[ -n "${GITEA_TOKEN:-}" ]]; then
|
||||
if [[ -z "${GITEA_URL:-}" ]] || gitea_url_matches_host "$GITEA_URL" "$host"; then
|
||||
if [[ -z "${GITEA_URL:-}" || "${GITEA_URL:-}" == "https://$host" || "${GITEA_URL:-}" == "http://$host" || "${GITEA_URL:-}" == *"//$host" ]]; then
|
||||
echo "$GITEA_TOKEN"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=packages/mosaic/framework/tools/git/detect-platform.sh
|
||||
source "$SCRIPT_DIR/detect-platform.sh"
|
||||
|
||||
# Parse arguments
|
||||
@@ -56,125 +55,6 @@ fi
|
||||
|
||||
detect_platform >/dev/null
|
||||
|
||||
# Post a review comment body to a Gitea PR via the supported comments REST API
|
||||
# and verify it durably via provider read-back (see docs on durable review
|
||||
# provenance in README.md). Used by the `comment` action and, since `tea`
|
||||
# v0.11.1 defines no `--comment`/`-comment` flag on `pr approve`/`pr reject`,
|
||||
# also by the `approve` and `request-changes` actions to carry an optional
|
||||
# review body that `tea` itself cannot attach.
|
||||
#
|
||||
# Args: $1 = PR number, $2 = comment body
|
||||
# On success: prints only the created comment ID to stdout, returns 0.
|
||||
# On failure: prints an error to stderr, returns 1.
|
||||
gitea_post_verified_comment() {
|
||||
local pr_number="$1" comment_body="$2"
|
||||
local host token configured_url repo api_base payload
|
||||
local write_response_file readback_response_file comment_id
|
||||
|
||||
host=$(get_remote_host)
|
||||
token=$(get_gitea_token "$host") || {
|
||||
echo "Error: Gitea token not found for comment persistence" >&2
|
||||
return 1
|
||||
}
|
||||
configured_url=$(get_gitea_url_for_host "$host") || {
|
||||
echo "Error: Configured Gitea URL not found for comment persistence" >&2
|
||||
return 1
|
||||
}
|
||||
repo=$(get_gitea_repo_slug_for_url "$configured_url") || {
|
||||
echo "Error: Could not resolve Gitea owner/repository relative to configured URL" >&2
|
||||
return 1
|
||||
}
|
||||
api_base="${configured_url%/}/api/v1/repos/$repo"
|
||||
payload=$(COMMENT_BODY="$comment_body" python3 -c '
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({"body": os.environ["COMMENT_BODY"]}))
|
||||
')
|
||||
write_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-write.XXXXXX")
|
||||
readback_response_file=$(mktemp "${TMPDIR:-/tmp}/mosaic-pr-review-readback.XXXXXX")
|
||||
trap 'rm -f "$write_response_file" "$readback_response_file"' RETURN
|
||||
|
||||
if ! write_status=$(curl -sS -o "$write_response_file" -w '%{http_code}' \
|
||||
-X POST \
|
||||
-H "Authorization: token $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$payload" \
|
||||
"$api_base/issues/$pr_number/comments"); then
|
||||
echo "Error: Gitea comment write transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$write_status" != "201" ]]; then
|
||||
echo "Error: Gitea comment write failed with HTTP $write_status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
comment_id=$(python3 - "$write_response_file" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
comment_id = comment.get("id") if isinstance(comment, dict) else None
|
||||
if not isinstance(comment_id, int) or comment_id <= 0:
|
||||
raise ValueError("missing positive comment id")
|
||||
except (OSError, json.JSONDecodeError, ValueError) as error:
|
||||
print(f"Error: could not identify created Gitea comment: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(comment_id)
|
||||
PY
|
||||
) || return 1
|
||||
|
||||
if ! readback_status=$(curl -sS -o "$readback_response_file" -w '%{http_code}' \
|
||||
-H "Authorization: token $token" \
|
||||
"$api_base/issues/comments/$comment_id"); then
|
||||
echo "Error: Gitea comment read-back transport failed" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ "$readback_status" != "200" ]]; then
|
||||
echo "Error: Gitea comment read-back failed with HTTP $readback_status" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
if EXPECTED_COMMENT_ID="$comment_id" EXPECTED_COMMENT_BODY="$comment_body" EXPECTED_REPO="$repo" EXPECTED_PR_NUMBER="$pr_number" \
|
||||
python3 - "$readback_response_file" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], encoding="utf-8") as response:
|
||||
comment = json.load(response)
|
||||
if not isinstance(comment, dict):
|
||||
raise ValueError("response is not a comment object")
|
||||
expected_id = int(os.environ["EXPECTED_COMMENT_ID"])
|
||||
expected_body = os.environ["EXPECTED_COMMENT_BODY"]
|
||||
expected_repo = os.environ["EXPECTED_REPO"]
|
||||
expected_pr = os.environ["EXPECTED_PR_NUMBER"]
|
||||
issue_path = urlparse(comment.get("issue_url", "")).path.rstrip("/")
|
||||
expected_suffix = f"/repos/{expected_repo}/issues/{expected_pr}"
|
||||
if comment.get("id") != expected_id:
|
||||
raise ValueError("comment id mismatch")
|
||||
if comment.get("body") != expected_body:
|
||||
raise ValueError("comment body mismatch")
|
||||
if not issue_path.endswith(expected_suffix):
|
||||
raise ValueError("repository or PR mismatch")
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as error:
|
||||
print(f"Error: Gitea comment persistence verification failed: {error}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
PY
|
||||
then
|
||||
true
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "$comment_id"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [[ "$PLATFORM" == "github" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
@@ -205,41 +85,24 @@ if [[ "$PLATFORM" == "github" ]]; then
|
||||
elif [[ "$PLATFORM" == "gitea" ]]; then
|
||||
case $ACTION in
|
||||
approve)
|
||||
repo=$(get_repo_slug)
|
||||
host=$(get_remote_host)
|
||||
login=$(get_gitea_login_for_host "$host")
|
||||
# tea v0.11.1 defines no --comment/-comment flag on `pr approve`;
|
||||
# route any review body via the durable comment API instead (#835).
|
||||
tea pr approve "$PR_NUMBER" --repo "$repo" --login "$login"
|
||||
tea pr approve "$PR_NUMBER" $(get_gitea_repo_args) ${COMMENT:+--comment "$COMMENT"}
|
||||
echo "Approved Gitea PR #$PR_NUMBER"
|
||||
if [[ -n "$COMMENT" ]]; then
|
||||
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
fi
|
||||
;;
|
||||
request-changes)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required for request-changes"
|
||||
exit 1
|
||||
fi
|
||||
repo=$(get_repo_slug)
|
||||
host=$(get_remote_host)
|
||||
login=$(get_gitea_login_for_host "$host")
|
||||
# tea v0.11.1 defines no --comment/-comment flag on `pr reject`;
|
||||
# route the review body via the durable comment API instead (#835).
|
||||
tea pr reject "$PR_NUMBER" --repo "$repo" --login "$login"
|
||||
tea pr reject "$PR_NUMBER" $(get_gitea_repo_args) --comment "$COMMENT"
|
||||
echo "Requested changes on Gitea PR #$PR_NUMBER"
|
||||
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||
echo "Added and verified review comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
;;
|
||||
comment)
|
||||
if [[ -z "$COMMENT" ]]; then
|
||||
echo "Error: Comment required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment_id=$(gitea_post_verified_comment "$PR_NUMBER" "$COMMENT") || exit 1
|
||||
echo "Added and verified comment on Gitea PR #$PR_NUMBER (comment ID $comment_id)"
|
||||
tea pr comment "$PR_NUMBER" "$COMMENT" $(get_gitea_repo_args)
|
||||
echo "Added comment to Gitea PR #$PR_NUMBER"
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unknown action: $ACTION"
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for durable Gitea PR review comments (#812) and for the
|
||||
# approve/reject `--comment` flag removal (#835). The `tea` stub below rejects
|
||||
# any `-comment`/`--comment` flag on `pr approve`/`pr reject` exactly like real
|
||||
# `tea` v0.11.1 does ("flag provided but not defined: -comment"), so this
|
||||
# harness fails RED against the pre-#835 wrapper (which passed that flag) and
|
||||
# only passes once the wrapper routes the review body through the durable
|
||||
# comment REST API instead.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
|
||||
REPO_DIR="$WORK_DIR/repo"
|
||||
BIN_DIR="$WORK_DIR/bin"
|
||||
TEA_LOG="$WORK_DIR/tea.log"
|
||||
CURL_LOG="$WORK_DIR/curl.log"
|
||||
OUTPUT_FILE="$WORK_DIR/output.log"
|
||||
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
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
|
||||
|
||||
write_credentials() {
|
||||
local configured_url="$1"
|
||||
CONFIGURED_GITEA_URL="$configured_url" python3 - "$CREDENTIALS_FILE" <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
||||
json.dump({
|
||||
"gitea": {
|
||||
"mosaicstack": {
|
||||
"url": os.environ["CONFIGURED_GITEA_URL"],
|
||||
"token": "test-only-placeholder",
|
||||
}
|
||||
}
|
||||
}, credentials)
|
||||
PY
|
||||
}
|
||||
|
||||
cat > "$BIN_DIR/tea" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"
|
||||
|
||||
if [[ "$*" == "login list --output json" ]]; then
|
||||
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# tea v0.11.1 defines no --comment/-comment flag on `pr approve` or `pr
|
||||
# reject`; it fails closed with this exact message and a nonzero exit. Any
|
||||
# regression that reintroduces the flag on those subcommands must hit this
|
||||
# branch and fail RED (#835).
|
||||
if [[ "$*" == *" -comment "* || "$*" == *" --comment "* || "$*" == *" -comment" || "$*" == *" --comment" ]]; then
|
||||
echo "flag provided but not defined: -comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||
approve)
|
||||
[[ "$*" == "pr approve 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 90
|
||||
;;
|
||||
request-changes)
|
||||
[[ "$*" == "pr reject 123 --repo mosaicstack/stack --login mosaicstack" ]] || exit 91
|
||||
;;
|
||||
legacy-fallback|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|ssh-transport-port-success|explicit-default-port-success|write-transport-failure|write-http-failure|readback-failure)
|
||||
if [[ "$*" == pr\ comment* ]]; then
|
||||
# tea v0.11.1 treats the nonexistent subcommand as `tea pr list` and exits 0.
|
||||
printf '%s\n' 'INDEX TITLE STATE'
|
||||
exit 0
|
||||
fi
|
||||
echo "Unexpected tea command: $*" >&2
|
||||
exit 92
|
||||
;;
|
||||
*)
|
||||
exit 95
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/tea"
|
||||
|
||||
cat > "$BIN_DIR/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output_file=""
|
||||
method="GET"
|
||||
payload=""
|
||||
url=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-o)
|
||||
output_file="$2"
|
||||
shift 2
|
||||
;;
|
||||
-w|-H)
|
||||
shift 2
|
||||
;;
|
||||
-X)
|
||||
method="$2"
|
||||
shift 2
|
||||
;;
|
||||
-d|--data)
|
||||
payload="$2"
|
||||
shift 2
|
||||
;;
|
||||
-s|-S|-sS)
|
||||
shift
|
||||
;;
|
||||
http://*|https://*)
|
||||
url="$1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG"
|
||||
|
||||
write_response() {
|
||||
local status="$1" body="$2"
|
||||
[[ -n "$output_file" ]] || exit 96
|
||||
printf '%s' "$body" > "$output_file"
|
||||
printf '%s' "$status"
|
||||
}
|
||||
|
||||
case "${PR_REVIEW_TEST_MODE:-}" in
|
||||
legacy-fallback|write-transport-failure)
|
||||
echo "simulated transport failure" >&2
|
||||
exit 7
|
||||
;;
|
||||
write-http-failure)
|
||||
write_response 500 '{"message":"simulated rejection"}'
|
||||
;;
|
||||
approve|request-changes|comment-success|http-success|prefix-success|subpath-success|port-success|scp-ssh-success|url-ssh-success|ssh-transport-port-success|explicit-default-port-success|readback-failure)
|
||||
if [[ "$method" == "POST" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
|
||||
PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
assert json.loads(os.environ["PR_REVIEW_PAYLOAD"]) == {"body": os.environ["PR_REVIEW_EXPECTED_BODY"]}
|
||||
PY
|
||||
response=$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({"id": 456, "body": os.environ["PR_REVIEW_EXPECTED_BODY"]}))
|
||||
PY
|
||||
)
|
||||
write_response 201 "$response"
|
||||
elif [[ "$method" == "GET" && "$url" == "$PR_REVIEW_EXPECTED_API_BASE/issues/comments/456" ]]; then
|
||||
if [[ "$PR_REVIEW_TEST_MODE" == "readback-failure" ]]; then
|
||||
body="different-body"
|
||||
else
|
||||
body="$PR_REVIEW_EXPECTED_BODY"
|
||||
fi
|
||||
response=$(PR_REVIEW_BODY="$body" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
print(json.dumps({
|
||||
"id": 456,
|
||||
"body": os.environ["PR_REVIEW_BODY"],
|
||||
"issue_url": os.environ["PR_REVIEW_EXPECTED_API_BASE"] + "/issues/123",
|
||||
}))
|
||||
PY
|
||||
)
|
||||
write_response 200 "$response"
|
||||
else
|
||||
echo "Unexpected curl request: $method $url" >&2
|
||||
exit 97
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
exit 98
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod +x "$BIN_DIR/curl"
|
||||
|
||||
run_review() {
|
||||
local mode="$1" action="$2" comment="${3:-}"
|
||||
local configured_url="${4:-https://git.mosaicstack.dev}"
|
||||
local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
|
||||
local expected_repo="${6:-mosaicstack/stack}"
|
||||
local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
|
||||
git -C "$REPO_DIR" remote set-url origin "$remote_url"
|
||||
write_credentials "$configured_url"
|
||||
: > "$TEA_LOG"
|
||||
: > "$CURL_LOG"
|
||||
: > "$OUTPUT_FILE"
|
||||
(
|
||||
cd "$REPO_DIR"
|
||||
PATH="$BIN_DIR:$PATH" \
|
||||
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||
PR_REVIEW_TEA_LOG="$TEA_LOG" \
|
||||
PR_REVIEW_CURL_LOG="$CURL_LOG" \
|
||||
PR_REVIEW_TEST_MODE="$mode" \
|
||||
PR_REVIEW_EXPECTED_BODY="$comment" \
|
||||
PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
|
||||
"$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"}
|
||||
) > "$OUTPUT_FILE" 2>&1
|
||||
}
|
||||
|
||||
run_review approve approve
|
||||
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
|
||||
if grep -q 'comment' "$TEA_LOG"; then
|
||||
echo "Plain approve (no review body) unexpectedly touched comment persistence" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# #835: tea v0.11.1 defines no --comment/-comment flag on `pr approve`. A
|
||||
# review body supplied alongside approve must be routed through the durable
|
||||
# comment REST API instead of being passed to `tea` directly.
|
||||
run_review approve approve approve-note
|
||||
grep -q '^pr approve 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||
grep -q 'Approved Gitea PR #123' "$OUTPUT_FILE"
|
||||
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$OUTPUT_FILE"
|
||||
|
||||
# #835: same for `pr reject` (request-changes), where a comment is required.
|
||||
run_review request-changes request-changes changes-required
|
||||
grep -q '^pr reject 123 --repo mosaicstack/stack --login mosaicstack$' "$TEA_LOG"
|
||||
grep -q 'Requested changes on Gitea PR #123' "$OUTPUT_FILE"
|
||||
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
grep -q 'Added and verified review comment on Gitea PR #123 (comment ID 456)' "$OUTPUT_FILE"
|
||||
|
||||
if run_review legacy-fallback comment durable-body; then
|
||||
echo "The old nonexistent tea pr comment fallback returned success" >&2
|
||||
cat "$OUTPUT_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q '^pr comment ' "$TEA_LOG"; then
|
||||
echo "Wrapper invoked unsupported tea pr comment" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added comment to Gitea PR' "$OUTPUT_FILE"; then
|
||||
echo "Wrapper reported success without durable persistence" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
complex_body=$'durable "body"\n-- marker'
|
||||
run_review comment-success comment "$complex_body"
|
||||
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
|
||||
if [[ -s "$TEA_LOG" ]]; then
|
||||
echo "REST comment path unexpectedly invoked tea" >&2
|
||||
cat "$TEA_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_review http-success comment durable-body http://git.mosaicstack.dev
|
||||
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
|
||||
run_review prefix-success comment durable-body https://git.mosaicstack.dev/gitea/
|
||||
grep -q '^POST https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
|
||||
|
||||
run_review subpath-success comment durable-body https://git.example/gitea https://git.example/gitea/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/gitea/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET https://git.example/gitea/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||
if grep -q '/repos/gitea/owner/repo/' "$CURL_LOG"; then
|
||||
echo "Configured Gitea path prefix leaked into the repository slug" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_review port-success comment durable-body http://git.example:3000 http://git.example:3000/owner/repo.git owner/repo
|
||||
grep -q '^POST http://git.example:3000/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
grep -q '^GET http://git.example:3000/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
|
||||
|
||||
run_review scp-ssh-success comment durable-body https://git.example [email protected]:owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
run_review url-ssh-success comment durable-body https://git.example ssh://[email protected]/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
# #850 (follow-up to #812): an SSH remote's transport port (e.g. `ssh://
|
||||
# git@host:2222/...`) must NOT be compared against the configured HTTP(S) API
|
||||
# URL's port -- they identify unrelated properties (SSH daemon port vs. HTTP(S)
|
||||
# provider port) of the same Gitea host. Before the fix, host-match required
|
||||
# the configured URL to carry the identical port, so this failed closed even
|
||||
# though both remote and configured URL name the same host.
|
||||
run_review ssh-transport-port-success comment durable-body https://git.example ssh://[email protected]:2222/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
# #850 (follow-up to #812): an explicit default HTTP(S) port on the remote
|
||||
# (`https://host:443/...`) must be treated as equal to an implicit
|
||||
# (portless) configured URL on BOTH sides -- the pre-fix comparison only
|
||||
# normalized the default port when the REMOTE side was portless, so the
|
||||
# inverse (explicit remote, implicit configured) form failed closed.
|
||||
run_review explicit-default-port-success comment durable-body https://git.example https://git.example:443/owner/repo.git owner/repo
|
||||
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
|
||||
|
||||
if run_review write-transport-failure comment durable-body; then
|
||||
echo "Expected provider transport failure to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_review write-http-failure comment durable-body; then
|
||||
echo "Expected non-201 provider write to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if run_review readback-failure comment durable-body; then
|
||||
echo "Expected mismatched provider read-back to return nonzero" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
|
||||
echo "Read-back mismatch reported durable success" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pr-review.sh durable Gitea comment regression passed"
|
||||
@@ -1,92 +0,0 @@
|
||||
# orchestrator/ tools
|
||||
|
||||
Helper scripts for r0 coordinator / orchestrator sessions — mission lifecycle,
|
||||
session health, continuation, and board maintenance. See
|
||||
`framework/guides/ORCHESTRATOR-PROTOCOL.md` for the surrounding process.
|
||||
|
||||
| Script | Purpose |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `mission-init.sh` | Initialize a new orchestration mission (manifest, scratchpad, TASKS.md). |
|
||||
| `mission-status.sh` | Show the mission progress dashboard. |
|
||||
| `session-run.sh` | Generate continuation context and launch the target runtime. |
|
||||
| `session-resume.sh` | Crash recovery for dead orchestrator sessions. |
|
||||
| `session-status.sh` | Check agent session health. |
|
||||
| `continue-prompt.sh` | Generate the continuation prompt for the next session. |
|
||||
| `board-roll.sh` | Keep a LIVE orchestration board under its byte cap by rolling the oldest entries to its LEDGER. |
|
||||
| `smoke-test.sh` | Behavior smoke checks for the coord continue/run workflows. |
|
||||
| `test-board-roll.sh` | Regression harness for `board-roll.sh`. |
|
||||
| `_lib.sh` | Shared functions sourced by the above (state files, TASKS.md parsing, locks). |
|
||||
|
||||
## board-roll.sh
|
||||
|
||||
Coordinator boards (`MOS-ORCHESTRATION-BOARD-LIVE.md`, `MS-LEAD-BOARD-LIVE.md`)
|
||||
follow a **"< 8 KB LIVE"** discipline: the LIVE board is the only file loaded on
|
||||
resume, so it must stay small, and history lives in an append-only LEDGER. When a
|
||||
board write would push LIVE over its cap, coordinators otherwise hand-trim and
|
||||
retry every time — an observed 38 ABORT-OVER-CAP cycles in one 24 h window.
|
||||
`board-roll.sh` automates that trim mechanically and reversibly: the audit trail
|
||||
is moved to the LEDGER instead of being hand-deleted.
|
||||
|
||||
### Contract (conservative — it never guesses what is safe to move)
|
||||
|
||||
The LIVE board opts in by wrapping its aging archival ticks in an explicit roll
|
||||
zone. Everything **outside** the markers (title, protocol blockquote, curated
|
||||
always-current `##` sections) is pinned and never touched:
|
||||
|
||||
```markdown
|
||||
# MOS ORCHESTRATION BOARD — LIVE state
|
||||
|
||||
> protocol blockquote … (pinned)
|
||||
|
||||
## 🟦 Curated always-current section (pinned)
|
||||
|
||||
…
|
||||
|
||||
<!-- BOARD-ROLL:START -->
|
||||
|
||||
### 2026-07-22 (mid²²) — newest tick, stays longest
|
||||
|
||||
…
|
||||
|
||||
### 2026-07-20 (dawn) — oldest tick, rolled first
|
||||
|
||||
…
|
||||
|
||||
<!-- BOARD-ROLL:END -->
|
||||
```
|
||||
|
||||
Inside the zone, entries are delimited by a heading marker (default `### `) and
|
||||
are assumed **newest-first (top) → oldest-last (bottom)**. `board-roll.sh` moves
|
||||
whole oldest (bottom-most) entry blocks out of the zone and appends them verbatim
|
||||
to the LEDGER, one at a time, until LIVE is back under the cap or the zone is
|
||||
empty. If the board has no markers, it exits `3` and changes nothing — adding the
|
||||
markers is a deliberate opt-in by the board owner.
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
|
||||
|
||||
--live <path> LIVE board file (required)
|
||||
--ledger <path> append-only LEDGER file (required; created if absent)
|
||||
--cap <bytes> size ceiling for LIVE (default 8192)
|
||||
--marker <prefix> entry-heading prefix inside the roll zone (default "### ")
|
||||
--dry-run report what would move; change nothing
|
||||
-h, --help show help and exit 0
|
||||
```
|
||||
|
||||
Only **one** roll zone is supported. If a board carries more than one
|
||||
`BOARD-ROLL:START`/`END` pair, `board-roll.sh` refuses (exit `3`, zero changes)
|
||||
rather than span first-START..last-END and relocate the curated content between
|
||||
the zones — consolidate the ticks into a single zone instead.
|
||||
|
||||
Exit codes: `0` LIVE under cap (already, or after rolling) — on `--dry-run`, a
|
||||
plan exists or nothing to do · `2` usage / argument / IO error · `3` cannot meet
|
||||
the cap (no markers, **more than one marker pair**, or the pinned sections alone
|
||||
exceed the cap and need a manual trim).
|
||||
|
||||
Writes are atomic (temp file + `mv`, LEDGER first) so a failure never leaves a
|
||||
board half-written; line endings are normalized to LF on rewrite. `--dry-run`
|
||||
first is recommended when wiring it into a board update protocol.
|
||||
|
||||
Run the regression suite with `bash test-board-roll.sh`.
|
||||
@@ -1,277 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# board-roll.sh — keep a LIVE orchestration board under its byte cap by rolling
|
||||
# the oldest archival entries out to its append-only LEDGER.
|
||||
#
|
||||
# WHY: coordinator boards (MOS-ORCHESTRATION-BOARD-LIVE.md, MS-LEAD-BOARD-LIVE.md)
|
||||
# enforce a "< 8 KB LIVE" discipline via a self-guard that ABORTs the board write
|
||||
# when the file exceeds the cap. In practice the LIVE board keeps bumping the cap,
|
||||
# so coordinators hand-trim + retry every time (observed: 38 ABORT-OVER-CAP cycles
|
||||
# in a 24h window on one coordinator). This automates that trim, mechanically and
|
||||
# reversibly, so the audit trail is preserved in the LEDGER instead of hand-deleted.
|
||||
#
|
||||
# CONTRACT (conservative by design — it NEVER guesses what is safe to move):
|
||||
# The LIVE board must declare an explicit ROLL ZONE with HTML-comment markers:
|
||||
#
|
||||
# <!-- BOARD-ROLL:START -->
|
||||
# ### 2026-07-22 (newest tick — stays longest)
|
||||
# ...
|
||||
# ### 2026-07-19 (oldest tick — rolled first)
|
||||
# ...
|
||||
# <!-- BOARD-ROLL:END -->
|
||||
#
|
||||
# Everything OUTSIDE the markers (title, protocol blockquote, curated always-current
|
||||
# `##` sections) is PINNED and never touched. Inside the zone, entries are delimited
|
||||
# by a heading marker (default `### `) and are assumed newest-first (top) → oldest-last
|
||||
# (bottom), matching board convention. board-roll moves whole oldest (bottom-most)
|
||||
# entry blocks out of the zone and APPENDS them verbatim to the LEDGER, one block at a
|
||||
# time, until the LIVE file is back under the cap or the zone is empty.
|
||||
#
|
||||
# If no markers are present, it exits 3 without changing anything (safe default —
|
||||
# adding the markers is a deliberate opt-in by the board owner).
|
||||
#
|
||||
# USAGE:
|
||||
# board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
|
||||
#
|
||||
# OPTIONS:
|
||||
# --live <path> LIVE board file (required)
|
||||
# --ledger <path> append-only LEDGER file (required; created if absent)
|
||||
# --cap <bytes> size ceiling for LIVE (default 8192)
|
||||
# --marker <prefix> entry-heading prefix inside the roll zone (default "### ")
|
||||
# --dry-run report what would move + resulting size; change nothing
|
||||
# -h, --help print usage and exit 0
|
||||
#
|
||||
# EXIT CODES:
|
||||
# 0 LIVE is under cap (already, or after rolling); on --dry-run, 0 = a plan exists
|
||||
# (or nothing to do)
|
||||
# 2 usage / argument / IO error (bad flag, missing file, unwritable target)
|
||||
# 3 cannot satisfy the cap: no roll markers present, MORE THAN ONE marker pair
|
||||
# (multiple zones are refused, not guessed), OR the zone was emptied and LIVE
|
||||
# is still over cap (curated pinned sections need a manual trim)
|
||||
#
|
||||
# NOTE: line endings are normalized to LF on rewrite (boards are LF markdown); a
|
||||
# trailing newline is always ensured. Writes are atomic (temp file + mv) so a
|
||||
# failure never leaves LIVE or LEDGER half-written.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
START_MARK='<!-- BOARD-ROLL:START -->'
|
||||
END_MARK='<!-- BOARD-ROLL:END -->'
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: board-roll.sh --live <LIVE.md> --ledger <LEDGER.md> [options]
|
||||
|
||||
Roll the oldest entries out of a LIVE orchestration board into its LEDGER
|
||||
until the LIVE file is under a byte cap. Conservative: only content inside
|
||||
explicit <!-- BOARD-ROLL:START -->/<!-- BOARD-ROLL:END --> markers is moved.
|
||||
|
||||
Options:
|
||||
--live <path> LIVE board file (required)
|
||||
--ledger <path> append-only LEDGER file (required; created if absent)
|
||||
--cap <bytes> size ceiling for LIVE (default 8192)
|
||||
--marker <prefix> entry-heading prefix inside the roll zone (default "### ")
|
||||
--dry-run report what would move; change nothing
|
||||
-h, --help show this help and exit 0
|
||||
|
||||
Exit: 0 under cap (or dry-run plan) · 2 usage/IO error · 3 cannot meet cap
|
||||
(no markers, or pinned sections alone exceed the cap).
|
||||
EOF
|
||||
}
|
||||
|
||||
die() { echo "board-roll: $*" >&2; exit 2; }
|
||||
|
||||
LIVE=""; LEDGER=""; CAP=8192; MARKER='### '; DRYRUN=0
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--live) LIVE="${2:-}"; shift 2 || die "--live needs a value" ;;
|
||||
--ledger) LEDGER="${2:-}"; shift 2 || die "--ledger needs a value" ;;
|
||||
--cap) CAP="${2:-}"; shift 2 || die "--cap needs a value" ;;
|
||||
--marker) MARKER="${2:-}"; shift 2 || die "--marker needs a value" ;;
|
||||
--dry-run) DRYRUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) usage >&2; die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n "$LIVE" ]] || { usage >&2; die "--live is required"; }
|
||||
[[ -n "$LEDGER" ]] || { usage >&2; die "--ledger is required"; }
|
||||
[[ -f "$LIVE" ]] || die "LIVE file not found: $LIVE"
|
||||
[[ "$CAP" =~ ^[0-9]+$ ]] || die "--cap must be a non-negative integer, got: $CAP"
|
||||
|
||||
# --- read LIVE into a line array (newlines stripped; re-added on write) ---------
|
||||
mapfile -t LINES < "$LIVE"
|
||||
|
||||
# byte size of an array rendered as LF-terminated text
|
||||
render_size() {
|
||||
if [[ $# -eq 0 ]]; then printf 0; return; fi
|
||||
printf '%s\n' "$@" | wc -c
|
||||
}
|
||||
|
||||
orig_size=$(render_size "${LINES[@]}")
|
||||
|
||||
# --- already under cap → nothing to do -----------------------------------------
|
||||
if (( orig_size < CAP )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (< cap ${CAP}B) — nothing to roll."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- locate the roll-zone markers ----------------------------------------------
|
||||
# Exactly ONE marker pair is supported. If a board carries more than one START or
|
||||
# END marker we REFUSE (exit 3, zero changes) rather than guess: a naive
|
||||
# first-START..last-END span would swallow the curated content and the intermediate
|
||||
# markers sitting between two intended zones and silently relocate that pinned text
|
||||
# to the LEDGER — the exact data-loss this tool exists to prevent. Refusing matches
|
||||
# the "no markers = exit 3" conservative posture.
|
||||
start_idx=-1; end_idx=-1; start_count=0; end_count=0
|
||||
for i in "${!LINES[@]}"; do
|
||||
if [[ "${LINES[$i]}" == "$START_MARK" ]]; then
|
||||
if (( start_count == 0 )); then start_idx=$i; fi
|
||||
start_count=$(( start_count + 1 ))
|
||||
fi
|
||||
if [[ "${LINES[$i]}" == "$END_MARK" ]]; then
|
||||
end_idx=$i
|
||||
end_count=$(( end_count + 1 ))
|
||||
fi
|
||||
done
|
||||
if (( start_count > 1 || end_count > 1 )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but has ${start_count} START / ${end_count} END" >&2
|
||||
echo " markers — only a SINGLE '$START_MARK' … '$END_MARK' roll zone is supported." >&2
|
||||
echo " Multiple zones are refused (not guessed) so content between zones is never relocated." >&2
|
||||
echo " Consolidate the archival ticks into one zone, or trim manually." >&2
|
||||
exit 3
|
||||
fi
|
||||
if (( start_idx < 0 || end_idx < 0 || end_idx <= start_idx )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but no usable roll zone" >&2
|
||||
echo " (need '$START_MARK' then '$END_MARK'). Add the markers around the" >&2
|
||||
echo " archival tick section to opt this board into automatic rolling." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# preamble = lines [0 .. start_idx] (inclusive of START marker)
|
||||
# zone = lines (start_idx .. end_idx) (exclusive of both markers)
|
||||
# footer = lines [end_idx .. end] (inclusive of END marker)
|
||||
preamble=(); zone=(); footer=()
|
||||
for i in "${!LINES[@]}"; do
|
||||
if (( i <= start_idx )); then preamble+=("${LINES[$i]}")
|
||||
elif (( i < end_idx )); then zone+=("${LINES[$i]}")
|
||||
else footer+=("${LINES[$i]}")
|
||||
fi
|
||||
done
|
||||
|
||||
# --- split the zone into a fixed head + entry blocks ----------------------------
|
||||
# zone_head = any zone lines before the first entry marker (kept, never rolled).
|
||||
# blocks[k] = newline-joined text of entry k (marker line .. line before next marker).
|
||||
zone_head=(); declare -a block_start=()
|
||||
first_block=-1
|
||||
for i in "${!zone[@]}"; do
|
||||
if [[ "${zone[$i]}" == "$MARKER"* ]]; then
|
||||
[[ $first_block -eq -1 ]] && first_block=$i
|
||||
block_start+=("$i")
|
||||
fi
|
||||
done
|
||||
if (( first_block == -1 )); then
|
||||
echo "board-roll: LIVE is ${orig_size}B (>= cap ${CAP}B) but the roll zone has no" >&2
|
||||
echo " '${MARKER}' entries to move. Trim the pinned sections manually." >&2
|
||||
exit 3
|
||||
fi
|
||||
for (( i=0; i<first_block; i++ )); do zone_head+=("${zone[$i]}"); done
|
||||
|
||||
nblocks=${#block_start[@]}
|
||||
# block k spans zone[ block_start[k] .. (block_start[k+1]-1 or end-of-zone) ]
|
||||
block_text() { # $1 = block index → prints the block's lines, LF-joined (no trailing)
|
||||
local k=$1 s e
|
||||
s=${block_start[$k]}
|
||||
if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
|
||||
local out=()
|
||||
for (( j=s; j<=e; j++ )); do out+=("${zone[$j]}"); done
|
||||
printf '%s\n' "${out[@]}"
|
||||
}
|
||||
|
||||
# --- greedily roll oldest (bottom-most) blocks until under cap ------------------
|
||||
# keep = number of newest blocks retained; start with all, drop from the bottom.
|
||||
keep=$nblocks # blocks [keep .. nblocks-1] are the oldest set that gets moved
|
||||
current_size=$orig_size
|
||||
build_live_size() { # size of LIVE if we keep blocks [0 .. keep-1]
|
||||
local acc=("${preamble[@]}" "${zone_head[@]}")
|
||||
local k s e j
|
||||
for (( k=0; k<keep; k++ )); do
|
||||
s=${block_start[$k]}
|
||||
if (( k+1 < nblocks )); then e=$(( block_start[$((k+1))] - 1 )); else e=$(( ${#zone[@]} - 1 )); fi
|
||||
for (( j=s; j<=e; j++ )); do acc+=("${zone[$j]}"); done
|
||||
done
|
||||
acc+=("${footer[@]}")
|
||||
render_size "${acc[@]}"
|
||||
}
|
||||
while (( current_size >= CAP && keep > 0 )); do
|
||||
keep=$(( keep - 1 ))
|
||||
current_size=$(build_live_size)
|
||||
done
|
||||
|
||||
moved_count=$(( nblocks - keep ))
|
||||
if (( moved_count == 0 )); then
|
||||
# zone had entries but none movable brought us under (shouldn't happen: keep hits 0)
|
||||
echo "board-roll: could not reduce LIVE below cap (${current_size}B >= ${CAP}B)." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- dry-run report -------------------------------------------------------------
|
||||
plan_headers() {
|
||||
local k
|
||||
for (( k=keep; k<nblocks; k++ )); do
|
||||
# first line of each moved block
|
||||
printf ' %s\n' "${zone[${block_start[$k]}]}"
|
||||
done
|
||||
}
|
||||
if (( DRYRUN )); then
|
||||
echo "board-roll: DRY RUN"
|
||||
echo " LIVE now: ${orig_size}B (cap ${CAP}B) — over by $(( orig_size - CAP ))B"
|
||||
echo " would roll: ${moved_count} of ${nblocks} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) (oldest first):"
|
||||
plan_headers
|
||||
echo " LIVE after: ${current_size}B"
|
||||
if (( current_size >= CAP )); then
|
||||
echo " WARNING: still >= cap after emptying the zone; pinned sections need a manual trim." >&2
|
||||
exit 3
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- commit the roll atomically -------------------------------------------------
|
||||
live_tmp="$(mktemp "${LIVE}.roll.XXXXXX")" || die "cannot create temp next to LIVE"
|
||||
ledger_tmp=""
|
||||
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
|
||||
cleanup() { rm -f "$live_tmp" "$ledger_tmp" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# new LIVE = preamble + zone_head + kept blocks + footer
|
||||
{
|
||||
printf '%s\n' "${preamble[@]}" "${zone_head[@]}"
|
||||
for (( k=0; k<keep; k++ )); do block_text "$k"; done
|
||||
printf '%s\n' "${footer[@]}"
|
||||
} > "$live_tmp"
|
||||
|
||||
# LEDGER gets the moved blocks appended verbatim, in original top→bottom order,
|
||||
# under a provenance separator. LEDGER is append-only, so we only ever add at EOF.
|
||||
ledger_tmp="$(mktemp "${LEDGER}.roll.XXXXXX")" || die "cannot create temp next to LEDGER"
|
||||
if [[ -f "$LEDGER" ]]; then cat "$LEDGER" > "$ledger_tmp"; fi
|
||||
# ensure a trailing newline on existing content before appending
|
||||
if [[ -s "$ledger_tmp" && -n "$(tail -c1 "$ledger_tmp")" ]]; then printf '\n' >> "$ledger_tmp"; fi
|
||||
{
|
||||
printf '\n<!-- board-roll: %d entr%s rolled from %s -->\n' \
|
||||
"$moved_count" "$([[ $moved_count -eq 1 ]] && echo y || echo ies)" "$(basename "$LIVE")"
|
||||
for (( k=keep; k<nblocks; k++ )); do block_text "$k"; done
|
||||
} >> "$ledger_tmp"
|
||||
|
||||
# atomic swap (both, LEDGER first so a crash never drops content that left LIVE)
|
||||
mv "$ledger_tmp" "$LEDGER"; ledger_tmp=""
|
||||
mv "$live_tmp" "$LIVE"; live_tmp=""
|
||||
trap - EXIT
|
||||
|
||||
# read the real on-disk size back (truthful, not the predicted value)
|
||||
final_size=$(wc -c < "$LIVE")
|
||||
echo "board-roll: rolled ${moved_count} entr$([[ $moved_count -eq 1 ]] && echo y || echo ies) to $(basename "$LEDGER"); LIVE ${orig_size}B → ${final_size}B (cap ${CAP}B)."
|
||||
if (( final_size >= CAP )); then
|
||||
echo "board-roll: still >= cap after rolling all zone entries; pinned sections need a manual trim." >&2
|
||||
exit 3
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for board-roll.sh — rolling oldest LIVE-board entries to LEDGER.
|
||||
#
|
||||
# Asserts:
|
||||
# 1. Under cap → no-op, exit 0, files unchanged.
|
||||
# 2. Over cap, no roll markers → exit 3, LIVE unchanged (never guesses).
|
||||
# 3. Over cap, markers present → rolls the fewest oldest entries to get under cap,
|
||||
# LIVE ends under cap, pinned preamble/footer + newest entries preserved.
|
||||
# 4. Rolled blocks land in the LEDGER verbatim, oldest set in original order.
|
||||
# 5. --dry-run changes nothing and reports a plan.
|
||||
# 6. Zone emptied but pinned sections alone exceed cap → exit 3.
|
||||
# 7. --help exits 0 and prints usage; an unknown flag exits nonzero (#701 discipline).
|
||||
# 8. More than one marker pair → exit 3, unchanged; curated content between the two
|
||||
# zones is never relocated to the LEDGER (rev0 #868 regression).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SUT="$SCRIPT_DIR/board-roll.sh"
|
||||
|
||||
fail=0
|
||||
note() { echo "FAIL: $*" >&2; fail=1; }
|
||||
|
||||
WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
# builds a LIVE board: pinned preamble + roll zone with N dated entries (newest first),
|
||||
# each entry padded to be individually large so the cap math is predictable.
|
||||
make_board() { # $1 file $2 n_entries $3 with_markers(1/0) $4 pad_bytes
|
||||
local f=$1 n=$2 markers=$3 pad=$4 i padtxt
|
||||
padtxt=$(head -c "$pad" < /dev/zero | tr '\0' 'x')
|
||||
{
|
||||
echo "# BOARD — LIVE"
|
||||
echo "> pinned protocol blockquote, never rolled."
|
||||
echo
|
||||
echo "## Curated always-current section (pinned)"
|
||||
echo "- this stays no matter what"
|
||||
echo
|
||||
[[ "$markers" == 1 ]] && echo '<!-- BOARD-ROLL:START -->'
|
||||
# newest first (i=n .. 1); oldest (i=1) ends at the bottom
|
||||
for (( i=n; i>=1; i-- )); do
|
||||
echo "### 2026-07-$(printf '%02d' $i) tick number $i"
|
||||
echo "- detail $i $padtxt"
|
||||
echo
|
||||
done
|
||||
[[ "$markers" == 1 ]] && echo '<!-- BOARD-ROLL:END -->'
|
||||
} > "$f"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ── 1. under cap → no-op ───────────────────────────────────────────────────────
|
||||
L="$WORK/live1.md"; G="$WORK/ledger1.md"; : > "$G"
|
||||
make_board "$L" 2 1 10
|
||||
before=$(cat "$L")
|
||||
if ! out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 100000 2>&1); then
|
||||
note "under-cap should exit 0 (got nonzero): $out"
|
||||
fi
|
||||
[[ "$(cat "$L")" == "$before" ]] || note "under-cap modified LIVE"
|
||||
[[ -s "$G" ]] && note "under-cap wrote to LEDGER"
|
||||
|
||||
# ── 2. over cap, no markers → exit 3, unchanged ────────────────────────────────
|
||||
L="$WORK/live2.md"; G="$WORK/ledger2.md"; : > "$G"
|
||||
make_board "$L" 6 0 400
|
||||
before=$(cat "$L")
|
||||
set +e; bash "$SUT" --live "$L" --ledger "$G" --cap 800 >/dev/null 2>&1; rc=$?; set -e
|
||||
[[ "$rc" -eq 3 ]] || note "no-markers over-cap should exit 3 (got $rc)"
|
||||
[[ "$(cat "$L")" == "$before" ]] || note "no-markers run modified LIVE (must never guess)"
|
||||
|
||||
# ── 3+4. over cap with markers → rolls oldest, LIVE under cap, LEDGER gets them ─
|
||||
L="$WORK/live3.md"; G="$WORK/ledger3.md"; echo "# LEDGER" > "$G"
|
||||
make_board "$L" 6 1 400 # 6 entries, each ~>400B
|
||||
big=$(wc -c < "$L")
|
||||
[[ "$big" -ge 2000 ]] || note "fixture too small to test rolling ($big B)"
|
||||
if ! out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 2>&1); then
|
||||
note "marker roll should exit 0 when it can get under cap: $out"
|
||||
fi
|
||||
after=$(wc -c < "$L")
|
||||
[[ "$after" -lt 2000 ]] || note "LIVE still >= cap after roll ($after B)"
|
||||
# pinned content survives
|
||||
grep -q "Curated always-current section" "$L" || note "roll dropped pinned section"
|
||||
grep -q 'BOARD-ROLL:START' "$L" || note "roll dropped START marker"
|
||||
grep -q 'BOARD-ROLL:END' "$L" || note "roll dropped END marker"
|
||||
# newest entry (07-06) stays; oldest (07-01) is the first to leave
|
||||
grep -q "### 2026-07-06 tick number 6" "$L" || note "roll dropped the newest entry"
|
||||
grep -q "### 2026-07-01 tick number 1" "$L" && note "oldest entry not rolled out of LIVE"
|
||||
# oldest went to LEDGER
|
||||
grep -q "### 2026-07-01 tick number 1" "$G" || note "oldest entry not appended to LEDGER"
|
||||
grep -q "board-roll:.*rolled from live3.md" "$G" || note "LEDGER missing provenance separator"
|
||||
# a rolled entry must not be duplicated (present in exactly one of LIVE/LEDGER)
|
||||
if grep -q "### 2026-07-01 tick number 1" "$L"; then note "rolled entry duplicated in LIVE"; fi
|
||||
# LEDGER original content preserved
|
||||
grep -q "^# LEDGER" "$G" || note "roll clobbered existing LEDGER content"
|
||||
|
||||
# ── 5. --dry-run changes nothing ───────────────────────────────────────────────
|
||||
L="$WORK/live5.md"; G="$WORK/ledger5.md"; echo "# LEDGER" > "$G"
|
||||
make_board "$L" 6 1 400
|
||||
before_l=$(cat "$L"); before_g=$(cat "$G")
|
||||
out=$(bash "$SUT" --live "$L" --ledger "$G" --cap 2000 --dry-run 2>&1) || note "dry-run exited nonzero: $out"
|
||||
echo "$out" | grep -qi "dry run" || note "dry-run did not announce itself"
|
||||
echo "$out" | grep -q "would roll" || note "dry-run did not report a plan"
|
||||
[[ "$(cat "$L")" == "$before_l" ]] || note "dry-run modified LIVE"
|
||||
[[ "$(cat "$G")" == "$before_g" ]] || note "dry-run modified LEDGER"
|
||||
|
||||
# ── 6. zone emptied, pinned alone over cap → exit 3 ────────────────────────────
|
||||
# cap 120 is below the pinned preamble+footer size (~180B), so even after rolling
|
||||
# every zone entry the LIVE file stays over cap → must report the unsatisfiable case.
|
||||
L="$WORK/live6.md"; G="$WORK/ledger6.md"; echo "# LEDGER" > "$G"
|
||||
make_board "$L" 3 1 50
|
||||
set +e; bash "$SUT" --live "$L" --ledger "$G" --cap 120 >/dev/null 2>&1; rc=$?; set -e
|
||||
[[ "$rc" -eq 3 ]] || note "unsatisfiable cap should exit 3 (got $rc)"
|
||||
|
||||
# ── 7. help exits 0, unknown flag exits nonzero (#701) ─────────────────────────
|
||||
if ! out=$(bash "$SUT" --help 2>&1); then note "--help exited nonzero"; fi
|
||||
[[ "$out" == Usage:* ]] || note "--help did not print usage"
|
||||
bash "$SUT" -h >/dev/null 2>&1 || note "-h exited nonzero"
|
||||
if bash "$SUT" --not-a-real-flag >/dev/null 2>&1; then note "unknown flag was accepted"; fi
|
||||
if bash "$SUT" --live "$WORK/live3.md" >/dev/null 2>&1; then note "missing --ledger was accepted"; fi
|
||||
|
||||
# ── 8. multiple marker pairs → exit 3, unchanged (no cross-zone relocation) ─────
|
||||
# Two separately-marked zones with a curated pinned section BETWEEN them. A naive
|
||||
# first-START..last-END span would sweep that curated section (and the intermediate
|
||||
# markers) into the LEDGER. board-roll must refuse (exit 3) and touch nothing.
|
||||
L="$WORK/live8.md"; G="$WORK/ledger8.md"; echo "# LEDGER" > "$G"
|
||||
pad8=$(head -c 300 < /dev/zero | tr '\0' 'x')
|
||||
{
|
||||
echo "# BOARD — LIVE"
|
||||
echo "> pinned protocol blockquote"
|
||||
echo
|
||||
echo '<!-- BOARD-ROLL:START -->'
|
||||
echo "### 2026-07-10 zone-A newest"
|
||||
echo "- detail A2 $pad8"
|
||||
echo "### 2026-07-09 zone-A oldest"
|
||||
echo "- detail A1 $pad8"
|
||||
echo '<!-- BOARD-ROLL:END -->'
|
||||
echo
|
||||
echo "## Curated-between-zones (pinned — must never move)"
|
||||
echo "- CANARY-BETWEEN keep me"
|
||||
echo
|
||||
echo '<!-- BOARD-ROLL:START -->'
|
||||
echo "### 2026-07-08 zone-B newest"
|
||||
echo "- detail B2 $pad8"
|
||||
echo "### 2026-07-07 zone-B oldest"
|
||||
echo "- detail B1 $pad8"
|
||||
echo '<!-- BOARD-ROLL:END -->'
|
||||
} > "$L"
|
||||
before8=$(cat "$L")
|
||||
set +e; bash "$SUT" --live "$L" --ledger "$G" --cap 80 >/dev/null 2>&1; rc=$?; set -e
|
||||
[[ "$rc" -eq 3 ]] || note "multi-pair board should exit 3 (got $rc)"
|
||||
[[ "$(cat "$L")" == "$before8" ]] || note "multi-pair run modified LIVE (must never guess across zones)"
|
||||
grep -q "CANARY-BETWEEN keep me" "$L" || note "multi-pair run relocated curated between-zones content"
|
||||
grep -q "CANARY-BETWEEN" "$G" && note "curated between-zones content leaked into LEDGER"
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "board-roll regression passed (8 groups)"
|
||||
fi
|
||||
exit "$fail"
|
||||
@@ -50,21 +50,6 @@ if ! [[ "$FILE_PATH" =~ \.(ts|tsx|js|jsx|mjs|cjs)$ ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Deps preflight (#856): this hook is the common gate-entry seam the delivery
|
||||
# cycle invokes on every Edit/Write/MultiEdit — it fires before any pnpm-based
|
||||
# gate (test/lint/typecheck/format:check) runs against the edited file. In a
|
||||
# freshly created git worktree (pnpm workspaces do NOT share node_modules
|
||||
# across worktrees), node_modules/.bin is empty until `pnpm install` has run,
|
||||
# so gate binaries (tsc/eslint/prettier/vitest) fail with a raw, illegible
|
||||
# `sh: 1: <tool>: not found` that is indistinguishable from a real failure.
|
||||
# Fail legibly here instead, before that raw error has a chance to surface.
|
||||
BIN_DIR="$PROJECT_ROOT/node_modules/.bin"
|
||||
if [ ! -d "$BIN_DIR" ] || [ -z "$(ls -A "$BIN_DIR" 2>/dev/null)" ]; then
|
||||
echo "deps not installed — run pnpm install" >&2
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] deps not installed — run pnpm install ($BIN_DIR is missing or empty)" >> "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Call the main QA handler with extracted parameters
|
||||
if [ -f ~/.config/mosaic/tools/qa/qa-hook-handler.sh ]; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Calling QA handler for $FILE_PATH" >> "$LOG_FILE"
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression harness for #856: worker git-worktrees under a fresh `git worktree
|
||||
# add` have no node_modules until `pnpm install` runs (pnpm workspaces do NOT
|
||||
# share node_modules across worktrees). Before the fix, the gate-entry seam
|
||||
# (qa-hook-stdin.sh, registered as the PostToolUse hook for every Edit/Write/
|
||||
# MultiEdit in runtime/claude/settings.json) silently let a raw
|
||||
# `sh: 1: <tool>: not found` surface from any downstream gate invocation —
|
||||
# indistinguishable from a real test/lint failure (false-red).
|
||||
#
|
||||
# Asserts:
|
||||
# 1. RED (documented): a completely fresh worktree with no node_modules/.bin
|
||||
# at all produces the raw "not found" for a gate binary — this is the
|
||||
# defect the fix prevents from reaching the operator un-annotated.
|
||||
# 2. With node_modules/.bin missing entirely, the seam exits nonzero with
|
||||
# the legible sentinel "deps not installed — run pnpm install" instead
|
||||
# of silently proceeding (exit 0) into a would-be raw not-found.
|
||||
# 3. With node_modules/.bin present but empty, same legible-sentinel
|
||||
# behavior (covers `git worktree add` immediately followed by an
|
||||
# as-yet-incomplete/interrupted install).
|
||||
# 4. Once node_modules/.bin is populated (post `pnpm install`), the seam
|
||||
# proceeds normally (exit 0) — the preflight does not false-positive.
|
||||
# 5. Non-JS/TS files are unaffected (existing skip behavior preserved).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HOOK="$SCRIPT_DIR/qa-hook-stdin.sh"
|
||||
|
||||
TMP_DIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
fail=0
|
||||
|
||||
fail_msg() {
|
||||
echo "FAIL: $*" >&2
|
||||
fail=1
|
||||
}
|
||||
|
||||
run_hook() {
|
||||
local file_path="$1"
|
||||
printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}' "$file_path" | "$HOOK"
|
||||
}
|
||||
|
||||
make_fixture_repo() {
|
||||
local dir="$1"
|
||||
mkdir -p "$dir"
|
||||
git -C "$dir" init -q .
|
||||
git -C "$dir" -c user.email=fixture@test -c user.name=fixture commit -q --allow-empty -m init
|
||||
}
|
||||
|
||||
# --- Scenario 1: RED — document the pre-fix raw not-found a gate hits when
|
||||
# node_modules/.bin is entirely absent (this is what the preflight now
|
||||
# intercepts before any gate command runs).
|
||||
RED_DIR="$TMP_DIR/red-fixture"
|
||||
make_fixture_repo "$RED_DIR"
|
||||
RED_OUTPUT=$(PATH="/usr/bin:/bin" sh -c 'tsc --noEmit' 2>&1) && RED_STATUS=0 || RED_STATUS=$?
|
||||
case "$RED_OUTPUT" in
|
||||
*"not found"*) ;;
|
||||
*) fail_msg "expected the raw un-preflighted invocation to demonstrate 'not found'; got: $RED_OUTPUT" ;;
|
||||
esac
|
||||
[[ "$RED_STATUS" -ne 0 ]] || fail_msg "expected raw invocation without deps installed to fail"
|
||||
|
||||
# --- Scenario 2: node_modules/.bin missing entirely -> legible sentinel, nonzero.
|
||||
MISSING_DIR="$TMP_DIR/missing-bin"
|
||||
make_fixture_repo "$MISSING_DIR"
|
||||
echo "console.log(1)" > "$MISSING_DIR/x.ts"
|
||||
OUTPUT=$(cd "$MISSING_DIR" && run_hook "$MISSING_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "missing node_modules/.bin: expected nonzero exit, got 0"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*"pnpm install"*) ;;
|
||||
*) fail_msg "missing node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 3: node_modules/.bin present but empty -> legible sentinel, nonzero.
|
||||
EMPTY_DIR="$TMP_DIR/empty-bin"
|
||||
make_fixture_repo "$EMPTY_DIR"
|
||||
mkdir -p "$EMPTY_DIR/node_modules/.bin"
|
||||
echo "console.log(1)" > "$EMPTY_DIR/x.ts"
|
||||
OUTPUT=$(cd "$EMPTY_DIR" && run_hook "$EMPTY_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -ne 0 ]] || fail_msg "empty node_modules/.bin: expected nonzero exit, got 0"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*"pnpm install"*) ;;
|
||||
*) fail_msg "empty node_modules/.bin: expected legible sentinel, got: $OUTPUT" ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 4: node_modules/.bin populated (post `pnpm install`) -> proceeds normally.
|
||||
OK_DIR="$TMP_DIR/installed-bin"
|
||||
make_fixture_repo "$OK_DIR"
|
||||
mkdir -p "$OK_DIR/node_modules/.bin"
|
||||
printf '#!/bin/sh\necho ok\n' > "$OK_DIR/node_modules/.bin/tsc"
|
||||
chmod +x "$OK_DIR/node_modules/.bin/tsc"
|
||||
echo "console.log(1)" > "$OK_DIR/x.ts"
|
||||
OUTPUT=$(cd "$OK_DIR" && run_hook "$OK_DIR/x.ts" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "populated node_modules/.bin: expected exit 0, got $STATUS ($OUTPUT)"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*) fail_msg "populated node_modules/.bin: unexpected sentinel fired: $OUTPUT" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
# --- Scenario 5: non-JS/TS files are unaffected by the preflight (still
|
||||
# skipped before the deps check, regardless of node_modules state).
|
||||
NONJS_DIR="$TMP_DIR/nonjs"
|
||||
make_fixture_repo "$NONJS_DIR"
|
||||
echo "# doc" > "$NONJS_DIR/README.md"
|
||||
OUTPUT=$(cd "$NONJS_DIR" && run_hook "$NONJS_DIR/README.md" 2>&1) && STATUS=0 || STATUS=$?
|
||||
[[ "$STATUS" -eq 0 ]] || fail_msg "non-JS/TS file: expected exit 0 (skip), got $STATUS ($OUTPUT)"
|
||||
case "$OUTPUT" in
|
||||
*"deps not installed"*) fail_msg "non-JS/TS file: preflight incorrectly fired: $OUTPUT" ;;
|
||||
*) ;;
|
||||
esac
|
||||
|
||||
if [[ "$fail" -eq 0 ]]; then
|
||||
echo "deps-preflight regression passed (5/5 scenarios)"
|
||||
fi
|
||||
|
||||
exit "$fail"
|
||||
@@ -25,7 +25,7 @@
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell",
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh"
|
||||
"test:framework-shell": "python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/brain": "workspace:*",
|
||||
|
||||
@@ -31,26 +31,17 @@ PI_EXTENSION = FRAMEWORK / "runtime/pi/mosaic-extension.ts"
|
||||
|
||||
|
||||
def request(socket_path: Path, value: dict[str, object]) -> dict[str, object]:
|
||||
deadline = time.monotonic() + 5.0
|
||||
while True:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
try:
|
||||
connection.connect(str(socket_path))
|
||||
except ConnectionRefusedError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
break
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(3.0)
|
||||
connection.connect(str(socket_path))
|
||||
connection.sendall((json.dumps(value, separators=(",", ":")) + "\n").encode())
|
||||
connection.shutdown(socket.SHUT_WR)
|
||||
response = bytearray()
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
response.extend(chunk)
|
||||
if not response.endswith(b"\n") or response.count(b"\n") != 1:
|
||||
raise AssertionError(f"unframed broker response: {bytes(response)!r}")
|
||||
reply = json.loads(response[:-1])
|
||||
|
||||
@@ -661,27 +661,13 @@ describe('whole mutator-class lease gate', () => {
|
||||
test('observer revocation and monotonic TTL expiry deny the next mutator', async () => {
|
||||
const { socket } = await startBroker();
|
||||
const sessionId = await register(socket);
|
||||
|
||||
// Establish the lease with a normal (non-racing) TTL first and prove it
|
||||
// authorizes. This "still valid" check is setup, not a TTL-expiry
|
||||
// assertion, so it must not share a lease with a 1-second TTL: on a
|
||||
// contended push-CI host, scheduling delay alone between promote() and
|
||||
// this authorize() call can consume that entire 1-second margin and
|
||||
// spuriously deny it (CI#1945). Using a generous TTL here removes that
|
||||
// real-time race without touching lease-gate security semantics.
|
||||
const pending = await beginVerification(socket, sessionId, 'claude');
|
||||
const pending = await beginVerification(socket, sessionId, 'claude', 1, 1);
|
||||
await promote(socket, sessionId, pending.receipt_challenge!);
|
||||
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: true,
|
||||
decision: 'allow',
|
||||
});
|
||||
|
||||
// A dedicated, isolated short-TTL lease drives the deliberate monotonic
|
||||
// expiry demonstration below. It is never used for anything but the
|
||||
// wait-then-expire assertion, so there is no setup work racing its
|
||||
// 1-second window.
|
||||
const shortLived = await beginVerification(socket, sessionId, 'claude', 1, 1, 2);
|
||||
await promote(socket, sessionId, shortLived.receipt_challenge!);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
expect(await authorize(socket, sessionId, 'claude', 'Bash')).toMatchObject({
|
||||
ok: false,
|
||||
@@ -689,7 +675,7 @@ describe('whole mutator-class lease gate', () => {
|
||||
decision: 'deny',
|
||||
});
|
||||
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 3);
|
||||
const refreshed = await beginVerification(socket, sessionId, 'claude', 1, 300, 2);
|
||||
await promote(socket, sessionId, refreshed.receipt_challenge!);
|
||||
expect(
|
||||
await request(socket, {
|
||||
|
||||
@@ -217,22 +217,12 @@ git fetch origin
|
||||
mkdir -p ~/src/${projectName}-worktrees
|
||||
git worktree add ~/src/${projectName}-worktrees/<task-slug> -b <branch-name> origin/main
|
||||
cd ~/src/${projectName}-worktrees/<task-slug>
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
# ... all work happens here ...
|
||||
git push origin <branch-name>
|
||||
cd ~/src/${projectName} && git worktree remove ~/src/${projectName}-worktrees/<task-slug>
|
||||
\`\`\`
|
||||
|
||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.
|
||||
|
||||
\`pnpm install --frozen-lockfile --prefer-offline\` MUST run immediately after
|
||||
\`git worktree add\`/\`cd\`, BEFORE any gate (\`pnpm test\`/\`lint\`/\`typecheck\`/\`format:check\`)
|
||||
is invoked. pnpm workspaces do NOT share \`node_modules\` across separate git
|
||||
worktrees — a fresh worktree has an empty \`node_modules/.bin\`, so every gate
|
||||
binary (\`tsc\`/\`eslint\`/\`prettier\`/\`vitest\`) fails \`sh: 1: <tool>: not found\`
|
||||
until deps are installed. That failure is indistinguishable from a real
|
||||
test/lint failure — a false-red gate. Never skip this step and never reorder
|
||||
it after the first gate invocation.`);
|
||||
Worktrees path: \`~/src/<repo>-worktrees/<task-slug>\` — NEVER use /tmp.`);
|
||||
|
||||
// 6. Completion gates
|
||||
sections.push(`# Completion Gates — ENFORCED
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# Skill: glpi-create — Open a New GLPI Ticket
|
||||
|
||||
> Create a new GLPI helpdesk ticket. Mutates GLPI — confirm the details before running.
|
||||
|
||||
## When to use
|
||||
|
||||
- Logging a new incident or request that should live in the helpdesk queue.
|
||||
|
||||
## Required information
|
||||
|
||||
- **title** — short subject line.
|
||||
- **content** — description of the issue / request.
|
||||
|
||||
## Optional
|
||||
|
||||
- **priority** — `1`=VeryLow, `2`=Low, `3`=Medium (default), `4`=High, `5`=VeryHigh, `6`=Major.
|
||||
- **type** — `1`=Incident (default), `2`=Request.
|
||||
|
||||
## Command
|
||||
|
||||
Wraps the existing tooling:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/glpi/ticket-create.sh \
|
||||
-t "<title>" \
|
||||
-c "<content>" \
|
||||
[-p <priority>] \
|
||||
[-y <type>] \
|
||||
[-f json]
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/glpi/ticket-create.sh \
|
||||
-t "Paint-area camera install" \
|
||||
-c "Ordered 2 cameras for Paint and stock; schedule mounting + NVR config." \
|
||||
-p 3 -y 2
|
||||
```
|
||||
|
||||
## After creating
|
||||
|
||||
- Note the returned **ticket ID** — you'll need it for **[[glpi-followup]]** and
|
||||
**[[glpi-solve]]**.
|
||||
- If it should also be tracked as brain work, add a matching task (see the `add-task` skill).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Confirm title/content/priority with the user before creating — a ticket is outward-facing.
|
||||
- Never echo GLPI tokens.
|
||||
@@ -1,56 +0,0 @@
|
||||
# Skill: glpi-followup — Add a Followup to a GLPI Ticket
|
||||
|
||||
> Post a followup (comment / progress note / resolution writeup) to a GLPI ticket.
|
||||
> This documents work but does **not** change the ticket status — to close a ticket
|
||||
> out, follow with **[[glpi-solve]]** to set status to Solved.
|
||||
|
||||
## When to use
|
||||
|
||||
- Recording progress, a decision, or a root-cause/resolution note on a ticket.
|
||||
- The documentation step that usually precedes closing a ticket out (`glpi-solve`).
|
||||
|
||||
## Critical quirk
|
||||
|
||||
Use the **top-level `/ITILFollowup` endpoint**, NOT `/Ticket/<id>/ITILFollowup`. The
|
||||
sub-resource path returns permission errors even with a Super-Admin profile.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Session + creds
|
||||
|
||||
```bash
|
||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
||||
```
|
||||
|
||||
### 2. Post the followup
|
||||
|
||||
```bash
|
||||
TICKET_ID=<id>
|
||||
CONTENT="<the followup text>"
|
||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
||||
-H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --argjson id "$TICKET_ID" --arg c "$CONTENT" \
|
||||
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
|
||||
```
|
||||
|
||||
Expect HTTP 201. Building the payload with `jq` keeps quotes/newlines in the content safe.
|
||||
|
||||
### 3. Long or multi-paragraph content
|
||||
|
||||
Write the note to a file first, then read it into the payload:
|
||||
|
||||
```bash
|
||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --argjson id "$TICKET_ID" --rawfile c /path/to/note.md \
|
||||
'{input:{itemtype:"Ticket", items_id:$id, content:$c}}')"
|
||||
```
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Never echo the GLPI app/user/session tokens.
|
||||
- A followup alone leaves the ticket open. If the work is done, run **[[glpi-solve]]** next.
|
||||
@@ -1,57 +0,0 @@
|
||||
# Skill: glpi-list — Query GLPI Tickets
|
||||
|
||||
> Quick lookups of GLPI helpdesk tickets by status or recency. Read-only.
|
||||
|
||||
## When to use
|
||||
|
||||
- "What tickets are open / pending?" · "Show recent tickets" · finding a ticket ID
|
||||
before running **[[glpi-followup]]** or **[[glpi-solve]]**.
|
||||
|
||||
## Command
|
||||
|
||||
Wraps the existing tooling:
|
||||
|
||||
```bash
|
||||
GLPI=~/.config/mosaic/tools/glpi
|
||||
|
||||
# Most recent tickets (default 50, newest first)
|
||||
"$GLPI/ticket-list.sh"
|
||||
|
||||
# Filter by status: new | processing | pending | solved | closed
|
||||
"$GLPI/ticket-list.sh" -s pending
|
||||
|
||||
# JSON output (for parsing / piping to jq) and a custom limit
|
||||
"$GLPI/ticket-list.sh" -s processing -f json -l 20
|
||||
```
|
||||
|
||||
Status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.
|
||||
|
||||
## Details lookup for one ticket
|
||||
|
||||
When you have an ID and want the full record:
|
||||
|
||||
```bash
|
||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
||||
curl -sk "${GLPI_URL}/Ticket/<id>?expand_dropdowns=true" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
| jq '{id, name, status, date, date_mod}'
|
||||
|
||||
# Followups on a ticket
|
||||
curl -sk "${GLPI_URL}/Ticket/<id>/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
| jq '.[] | {date, content}'
|
||||
```
|
||||
|
||||
(Reading followups via the sub-resource is fine — only _creating_ them requires the
|
||||
top-level `/ITILFollowup` endpoint. See **[[glpi-followup]]**.)
|
||||
|
||||
## Present to user
|
||||
|
||||
Group by status, one line per ticket: `#<id> · <title> · <status> · <last-modified>`.
|
||||
Use neutral phrasing — no "OVERDUE"/"URGENT".
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Read-only. Never echo GLPI tokens.
|
||||
- To sync tickets into brain data instead, use `python tools/sync_glpi.py` (not this skill).
|
||||
@@ -1,96 +0,0 @@
|
||||
# Skill: glpi-solve — Close Out a GLPI Ticket
|
||||
|
||||
> Properly close out a completed GLPI helpdesk ticket. Completing the work is not
|
||||
> enough — the ticket **status must be set to "Solved"**, which is what triggers
|
||||
> GLPI's config-driven auto-close. Posting a resolution followup documents the work
|
||||
> but does **not** change status, so a ticket left at Solved-less status stays open.
|
||||
|
||||
## When to use
|
||||
|
||||
- Any time work on a GLPI ticket is finished and it should be closed out.
|
||||
- After posting a root-cause / resolution writeup as an `/ITILFollowup`.
|
||||
- During a cleanup sweep of tickets that are done in reality but still open in GLPI.
|
||||
|
||||
## The rule (from an operator, 2026-07-20)
|
||||
|
||||
**"Solved" is the correct terminal state to set — not "Closed."** GLPI is configured
|
||||
to auto-close Solved tickets after its delay. If you only post a followup and never set
|
||||
status, the ticket sits open (this bit us on a real incident where resolution followups
|
||||
were posted but status was never advanced, leaving tickets open, which the operator had
|
||||
to mark Solved by hand).
|
||||
|
||||
Close-out = **followup (optional but preferred) + set status to Solved.**
|
||||
|
||||
## GLPI status IDs
|
||||
|
||||
| ID | Status | |
|
||||
| ----- | --------------------- | -------------------------------------------- |
|
||||
| 1 | New | |
|
||||
| 2 | Processing (assigned) | |
|
||||
| 3 | Processing (planned) | |
|
||||
| 4 | Pending / Waiting | |
|
||||
| **5** | **Solved** | ← set this on close-out |
|
||||
| 6 | Closed | ← happens automatically; do not set manually |
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Get a session token
|
||||
|
||||
```bash
|
||||
SESSION=$(~/.config/mosaic/tools/glpi/session-init.sh -q)
|
||||
source ~/.config/mosaic/tools/_lib/credentials.sh && load_credentials glpi
|
||||
```
|
||||
|
||||
### 2. (Preferred) Post the resolution followup
|
||||
|
||||
Use the **top-level `/ITILFollowup` endpoint** — the `/Ticket/<id>/ITILFollowup`
|
||||
sub-resource returns permission errors even as Super-Admin (known GLPI quirk).
|
||||
|
||||
```bash
|
||||
TICKET_ID=<id>
|
||||
curl -sk -X POST "${GLPI_URL}/ITILFollowup" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
||||
-H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"input\":{\"itemtype\":\"Ticket\",\"items_id\":${TICKET_ID},\"content\":\"<resolution summary>\"}}"
|
||||
```
|
||||
|
||||
### 3. Set status to Solved (the step that actually closes it out)
|
||||
|
||||
```bash
|
||||
curl -sk -X PUT "${GLPI_URL}/Ticket/${TICKET_ID}" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" \
|
||||
-H "Session-Token: $SESSION" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"input\":{\"id\":${TICKET_ID},\"status\":5}}"
|
||||
```
|
||||
|
||||
Expect HTTP 200/201. GLPI will auto-close it later per its config — leave status at 5.
|
||||
|
||||
### 4. Verify
|
||||
|
||||
```bash
|
||||
curl -sk "${GLPI_URL}/Ticket/${TICKET_ID}?expand_dropdowns=true" \
|
||||
-H "App-Token: $GLPI_APP_TOKEN" -H "Session-Token: $SESSION" \
|
||||
| jq '{id, name, status}'
|
||||
```
|
||||
|
||||
`status` should read `Solved` (or `5`).
|
||||
|
||||
## Optional: sweep for done-but-open tickets
|
||||
|
||||
List tickets still open (New/Processing/Pending) to spot ones whose work is actually
|
||||
finished but were never marked Solved:
|
||||
|
||||
```bash
|
||||
~/.config/mosaic/tools/glpi/ticket-list.sh -s processing -f table
|
||||
~/.config/mosaic/tools/glpi/ticket-list.sh -s pending -f table
|
||||
```
|
||||
|
||||
Review each; for any that are genuinely resolved, run steps 2–3.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Read-only until you intend to close — confirm the ticket is actually done first.
|
||||
- Never echo the GLPI app/user/session tokens.
|
||||
- Set **Solved (5)**, never Closed (6) — auto-close owns that transition.
|
||||
@@ -1,62 +0,0 @@
|
||||
# Skill: glpi-sweep — Find Done-But-Open Tickets
|
||||
|
||||
> Read-only sweep for tickets that are finished in reality but still sitting open in
|
||||
> GLPI (never moved to Solved). Surfaces the exact miss an operator caught on 2026-07-20
|
||||
> (a real incident where an affected ticket had resolution followups posted but was left
|
||||
> open). For each one that's genuinely done, close it out with **[[glpi-solve]]**.
|
||||
|
||||
## When to use
|
||||
|
||||
- Periodic hygiene pass (e.g. before a weekly update or month-end).
|
||||
- After a burst of ticket work, to catch any you resolved-in-followup but never Solved.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Posting an `/ITILFollowup` documents work but does **not** change status. Tickets only
|
||||
auto-close once set to **Solved (status 5)**. Anything left at New/Processing/Pending
|
||||
stays open indefinitely. This sweep finds those.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. List still-open tickets by status
|
||||
|
||||
```bash
|
||||
GLPI=~/.config/mosaic/tools/glpi
|
||||
"$GLPI/ticket-list.sh" -s new -f table
|
||||
"$GLPI/ticket-list.sh" -s processing -f table
|
||||
"$GLPI/ticket-list.sh" -s pending -f table
|
||||
```
|
||||
|
||||
(GLPI status IDs: 1 New · 2/3 Processing · 4 Pending · 5 Solved · 6 Closed.)
|
||||
|
||||
### 2. Triage
|
||||
|
||||
For each open ticket, judge whether the underlying work is actually finished — check
|
||||
its latest followups and cross-reference brain tasks / recent work. Read-only here;
|
||||
change nothing yet.
|
||||
|
||||
Reasonable "probably done" signals:
|
||||
|
||||
- A resolution/root-cause followup already posted, but status never advanced.
|
||||
- The related brain task is `done`, or the fix shipped and was confirmed.
|
||||
- Requester confirmed resolution but the ticket was never Solved.
|
||||
|
||||
### 3. Present the candidates
|
||||
|
||||
List them for review before touching anything — never bulk-solve blindly:
|
||||
|
||||
```
|
||||
Open tickets that look resolved:
|
||||
- #<id> "<title>" — <why it looks done> → glpi-solve?
|
||||
```
|
||||
|
||||
### 4. Close out the confirmed ones
|
||||
|
||||
For each ticket the user (or clear evidence) confirms is done, run **[[glpi-solve]]**
|
||||
(optionally **[[glpi-followup]]** first if a closing note is warranted).
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Read-only until a ticket is confirmed done — do not auto-solve on a guess.
|
||||
- Never echo GLPI tokens.
|
||||
- Set **Solved (5)**, never Closed (6) — GLPI auto-close owns that transition.
|
||||
Reference in New Issue
Block a user