@miadi/connector 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,12 +20,13 @@ Call `get_capabilities` first. It names the signed-in person, their Miadi API ti
20
20
 
21
21
  | kind | tools |
22
22
  |---|---|
23
- | reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `search` `fetch` |
23
+ | reads | `get_capabilities` `get_context` `list_circles` `get_circle` `list_sessions` `get_session` `get_evidence` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `search` `fetch` |
24
24
  | contributions | `append_observation` `open_inquiry` `link_records` `propose_narrative_beat` `record_evaluation` `register_source_intent` `record_handoff` `update_handoff` `prepare_calendar_event` `save_research` |
25
- | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
25
+ | requests (approved by a person) | `propose_work` `propose_session_completion` `request_authorized_run` `request_session_steer` `request_review_operation` `request_deep_diver` `link_review_to_episode` `request_inquiry_episode` |
26
26
  | decisions in the conversation | `show_decisions` (cards), `decide_request` (called by the card only) |
27
27
 
28
28
  - **Reads** need Miadi's read tier and stay within it. A person Miadi gives no API tier reaches only `get_capabilities`. Sessions need the write tier, because a session's name and pane are operator data. For the same reason, below the write tier a record someone else made shows no session, working folder or seat, and no long bodies.
29
+ - **Episode files.** `get_evidence` lists an episode's files; each text file Miadi can preview carries a `read_ref` (`miadi-chronicle://<n>/<path>`). `read_episode_file` (or `fetch` with that ref) returns the file's whole UTF-8 text, read as the person through Miadi's file door, so Miadi's audience and room rules apply. The answer names the episode, the path, the content type, the byte count and the sha256, and `verified` says whether the text received hashes to what Miadi read. Only md, txt, yaml, yml and json files up to 512 KiB are read. A traversal, absolute, hidden or encoded path is refused before any URL is built. A longer text comes in pages cut on character boundaries: pass `page.next` back, and the joined pages hash to `sha256`. Other kinds answer `unsupported`, a file over the limit `too_large`, a missing one `not_found`, a ceremony note below the write gate `forbidden`.
29
30
  - **Contributions** need Miadi's read tier and the `miadi:contribute` scope, chosen at sign-in. A record is moved on (`update_handoff`) only by its author or the write tier, and a request's receipt comes only from its decision. They append a record to the ledger and change nothing else, except `save_research`: it saves a report on the inquiry shelf as a new folder (the Markdown exactly as given, owner and visibility recorded, private by default), opens the folder's inquiry issue, and commits and pushes the folder in the shelf's repository. A long report is sent in parts (`part`, `parts`, `upload_id`) and saved when the last part arrives; the answer returns its bytes, sha256 and first and last lines.
30
31
  - **Requests** append a record that waits for a decision. The answer gives an `approval_url`; Miadi's `/research` page offers the same decisions to a signed-in person with the write tier, and `show_decisions` shows them as cards inside the conversation (below). The person approves, approves with changes (their instructions are carried into what runs), sends it back for revision with instructions (`revision_requested`, nothing runs, `get_run_result` returns the instructions), or declines. Only the approval door runs a request, once, as that person; a second decision on the same request, at the same time or later, is refused:
31
32
  - a run becomes a brief in the factory queue, which the intake delivers to a session;
@@ -40,6 +41,14 @@ The model that asked cannot approve its own request.
40
41
 
41
42
  `get_run_result` returns one `projection` of the decision, dispatch/completion state, receipt link and outstanding instructions. The in-chat card, approval page and Miadi's research page use that same read model. Asking `show_decisions` for an already-decided `record_id` shows its receipt without issuing another decision token. An in-flight card can refresh status without repeating the operation. A completed basic review with unapplied approval instructions remains open as **changes outstanding**, not falsely finished.
42
43
 
44
+ ### Following and steering a mission
45
+
46
+ `mission_status` joins one mission read-only: its source request and decision, the sessions bound to it (each with how it is bound — a signed `session:` ref, the delivered brief's target, or the requested name), its issues and PRs, its steering grant, and where it stands stage by stage — approved, queued, delivered, running, merged, released, closed — each by its evidence, so an idle pane or a merge is never read as completion. Live panes and Miadi's session view are joined only for the write tier.
47
+
48
+ `request_session_steer` sends one authorized follow-up to one existing live agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). It peeks at the bound pane — the grant's pane or the session's first pane, never whichever pane is active — refuses a busy agent or a pane that is not an agent, types the exact instruction literally, and re-peeks to verify the agent accepted it (`accepted`) or only holds it at the prompt (`submitted`). Every attempt leaves a durable artifact with the instruction's sha256, its grant, its target and both captures. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
49
+
50
+ `request_authorized_run` may name a `provider` (`codex`, `pi`, `copilot`, `claude`). The connector verifies it is installed on the host before recording anything; an unknown or unavailable provider answers `provider_unavailable` and the mission stays unstarted. With no provider named, the first installed provider in the preference order is resolved and named in the brief; Claude Code is never chosen silently — it runs only when named. `provider_capabilities` (write tier) reports what the host has installed, the sign-in evidence, the preference order, and the read-only Jev routing preflight (`unconfigured` without `MIADI_JEV_URL`). A provider name inside a task's text selects nothing.
51
+
43
52
  ### Deciding inside the conversation
44
53
 
45
54
  In a client that renders MCP Apps (ChatGPT among them), `show_decisions` draws one card per request awaiting a decision: what is asked, by whom, and what approving does. The person decides on the card with their own click: Approve, Approve with my changes, Send back for revision, or Decline. The card then shows the receipt.
package/dist/approve.d.ts CHANGED
@@ -16,6 +16,7 @@ import type { MiadiClient } from "./miadi.js";
16
16
  import { type MiadiPerson } from "./people.js";
17
17
  import { type ConnectorRecord } from "./records.js";
18
18
  import { type RequestProjection } from "./request-state.js";
19
+ import { type TmuxLike } from "./steer.js";
19
20
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
20
21
  export interface ApprovalDeps {
21
22
  config: ConnectorConfig;
@@ -26,6 +27,10 @@ export interface ApprovalDeps {
26
27
  /** The factory directory holding queue/ (default ~/.miadi/factory). */
27
28
  factoryDir?: string;
28
29
  tmuxSessions?: () => string[];
30
+ /** The steering surface (jgwill/Miadi#893); tests inject a fake, production uses tmux. */
31
+ tmux?: TmuxLike;
32
+ /** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
33
+ steerSettleMs?: number;
29
34
  }
30
35
  /**
31
36
  * What a person may decide on a request. Approving with changes runs it with the
package/dist/approve.js CHANGED
@@ -19,6 +19,7 @@ import { MiadiUnavailable } from "./people.js";
19
19
  import { current, newRecordId } from "./records.js";
20
20
  import { APPROVABLE, projectRequest } from "./request-state.js";
21
21
  import { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
22
+ import { steerAttempts, steerSession, tmuxAdapter } from "./steer.js";
22
23
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
23
24
  /**
24
25
  * What a person may decide on a request. Approving with changes runs it with the
@@ -67,7 +68,15 @@ export function runBrief(record, approver, sessions, instructions) {
67
68
  const short = record.id.split(":")[1]?.slice(0, 8) ?? "run";
68
69
  const to = body.session && sessions.includes(body.session) ? body.session : `new:${body.session ?? `connector-${yymmdd()}-${short}`}`;
69
70
  const cwd = body.cwd || "/a/src/Miadi-18";
70
- const open = [`to="${attr(to)}"`, 'status="send"', 'type="connector-run"', `record="${attr(record.id)}"`, ...(to.startsWith("new:") ? [`cwd="${attr(cwd)}"`] : [])];
71
+ const open = [
72
+ `to="${attr(to)}"`,
73
+ 'status="send"',
74
+ 'type="connector-run"',
75
+ `record="${attr(record.id)}"`,
76
+ // The provider the connector verified at request time (jgwill/Miadi#893); the intake may honour it or record why not.
77
+ ...(body.resolved_provider ? [`provider="${attr(body.resolved_provider)}"`] : []),
78
+ ...(to.startsWith("new:") ? [`cwd="${attr(cwd)}"`] : []),
79
+ ];
71
80
  const lines = [
72
81
  `<input ${open.join(" ")}>`,
73
82
  `A bounded task requested through the Miadi connector (jgwill/Miadi#815, #816).`,
@@ -202,6 +211,23 @@ export async function executeApproved(record, approver, deps, instructions) {
202
211
  ending: b.ending,
203
212
  note: "Recorded only. The witness seat writes it into the inventory record and closes the session by its own lifecycle.",
204
213
  };
214
+ case "session_steer_request": {
215
+ // The person's approval of this exact steer is its authorization; the
216
+ // grant record it cites still binds the session (jgwill/Miadi#893).
217
+ const grantId = record.refs.find((r) => r.startsWith("run_request:") || r.startsWith("work_proposal:"));
218
+ const grantRecord = grantId ? current(deps.ledger.all()).find((x) => x.id === grantId) : undefined;
219
+ if (!grantRecord)
220
+ return { outcome: "failed", message: "The run whose grant this steer cites is gone." };
221
+ const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, { record, grantRecord, priorAttempts: steerAttempts(current(deps.ledger.all()), grantRecord.id), authorized: true });
222
+ const delivered = result.status === "accepted" || result.status === "submitted";
223
+ return {
224
+ outcome: delivered ? result.status : "failed",
225
+ steer_status: result.status,
226
+ message: result.message,
227
+ ...(result.target ? { target: result.target } : {}),
228
+ ...(result.artifact ? { artifact: result.artifact } : {}),
229
+ };
230
+ }
205
231
  default:
206
232
  return { outcome: "nothing_to_run" };
207
233
  }
@@ -398,6 +424,8 @@ export function consequenceOf(r) {
398
424
  return b.new_episode ? "Approving mints a new episode with the inquiry woven in." : `Approving weaves the inquiry into episode ${b.episode ?? "?"}.`;
399
425
  case "session_completion_proposal":
400
426
  return `Approving records the ending (${b.ending ?? "?"}) for the session. No pane is closed.`;
427
+ case "session_steer_request":
428
+ return `Approving types exactly this instruction into the bound agent session (${b.session ?? "the run's session"}), once, and records whether the agent accepted it.`;
401
429
  default:
402
430
  return "Approving runs the request once, as you.";
403
431
  }
@@ -594,4 +622,5 @@ export const KIND_LABELS = {
594
622
  review_episode_link_request: "Review into an episode",
595
623
  inquiry_episode_request: "Inquiry into an episode",
596
624
  session_completion_proposal: "Session ending",
625
+ session_steer_request: "Session steer",
597
626
  };
@@ -0,0 +1,62 @@
1
+ /** The extensions Miadi's vessel reads as text (@medicine-wheel/episode-vessel TEXT_EXTENSIONS). */
2
+ export declare const TEXT_TYPES: Record<string, string>;
3
+ /** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
4
+ export declare const MAX_TEXT_BYTES: number;
5
+ export declare const DEFAULT_PAGE_BYTES = 100000;
6
+ /** A page must hold the longest UTF-8 character. */
7
+ export declare const MIN_PAGE_BYTES = 4;
8
+ export declare class FileRefusal extends Error {
9
+ readonly code: "invalid_path" | "invalid_offset" | "unsupported" | "changed";
10
+ constructor(code: "invalid_path" | "invalid_offset" | "unsupported" | "changed", message: string);
11
+ }
12
+ /** The same rule as the vessel's assertRelativeFile, applied before any URL exists. */
13
+ export declare function checkRelativePath(value: string): string[];
14
+ export declare function extensionOf(path: string): string;
15
+ /** `miadi-chronicle://548/foundations/x/README.md` → its episode and path; null for any other id. */
16
+ export declare function parseFileRef(id: string): {
17
+ episode: string;
18
+ path: string;
19
+ } | null;
20
+ export declare function fileRef(episodeNumber: number | string, path: string): string;
21
+ /** The episode number a folder carries (`2026-09-27-episode-548-…` → 548). */
22
+ export declare function episodeNumberOf(folder: string): number | null;
23
+ export interface MiadiText {
24
+ episode: string;
25
+ file: string;
26
+ content: string;
27
+ size: number;
28
+ revision?: {
29
+ sha256?: string;
30
+ bytes?: number;
31
+ modifiedAt?: string;
32
+ };
33
+ }
34
+ /**
35
+ * One page of a file Miadi returned whole. `offset` must be 0 or a
36
+ * `next.offset` this function gave, so a page never starts inside a character;
37
+ * `revision`, when given, must still be the file's sha256, so pages of two
38
+ * versions are never joined.
39
+ */
40
+ export declare function pageOf(text: MiadiText, opts?: {
41
+ offset?: number;
42
+ maxBytes?: number;
43
+ revision?: string;
44
+ }): {
45
+ page: {
46
+ offset: number;
47
+ end: number;
48
+ bytes: number;
49
+ sha256: string;
50
+ complete: boolean;
51
+ next: {
52
+ offset: number;
53
+ revision: string;
54
+ } | null;
55
+ };
56
+ text: string;
57
+ modified_at?: string | undefined;
58
+ verification?: string | undefined;
59
+ bytes: number;
60
+ sha256: string;
61
+ verified: boolean;
62
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Reading one text file an episode holds, through Miadi's file door
3
+ * (`GET /api/chronicle/episodes/<ref>/files/<path>`, jgwill/Miadi#653), as the
4
+ * signed-in person. Miadi decides what that person may read: the episode's
5
+ * audience, the room rule (ceremony notes and captures), the vessel's path
6
+ * guard and its 512 KiB text limit. The connector adds two things Miadi cannot
7
+ * see from its side of the URL:
8
+ *
9
+ * - the path is checked before a URL is built, because a URL parser folds
10
+ * `a/../../people` into another route before Miadi ever reads it;
11
+ * - the text is paged by UTF-8 bytes on character boundaries, each page
12
+ * carrying the whole file's sha256, so a reader can join the pages and prove
13
+ * they hold the file exactly (jgwill/Miadi#815).
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ /** The extensions Miadi's vessel reads as text (@medicine-wheel/episode-vessel TEXT_EXTENSIONS). */
17
+ export const TEXT_TYPES = {
18
+ ".md": "text/markdown; charset=utf-8",
19
+ ".txt": "text/plain; charset=utf-8",
20
+ ".yaml": "application/yaml; charset=utf-8",
21
+ ".yml": "application/yaml; charset=utf-8",
22
+ ".json": "application/json; charset=utf-8",
23
+ };
24
+ /** The vessel's own limit for reading text whole (MAX_TEXT_BYTES). */
25
+ export const MAX_TEXT_BYTES = 512 * 1024;
26
+ export const DEFAULT_PAGE_BYTES = 100_000;
27
+ /** A page must hold the longest UTF-8 character. */
28
+ export const MIN_PAGE_BYTES = 4;
29
+ export class FileRefusal extends Error {
30
+ code;
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.code = code;
34
+ }
35
+ }
36
+ /** The same rule as the vessel's assertRelativeFile, applied before any URL exists. */
37
+ export function checkRelativePath(value) {
38
+ if (!value || value.includes("\\") || value.includes("\0") || value.startsWith("/") || /^[a-z]+:/i.test(value)) {
39
+ throw new FileRefusal("invalid_path", "Name a file by its path inside the episode, as get_evidence lists it (e.g. foundations/<topic>/README.md).");
40
+ }
41
+ const segments = value.split("/");
42
+ if (segments.some((s) => !s || s === "." || s === ".." || s.startsWith(".") || /%2e|%2f|%5c|%00/i.test(s))) {
43
+ throw new FileRefusal("invalid_path", "The path has an empty, '.', '..', hidden or encoded segment; episode files are read by their listed path only.");
44
+ }
45
+ return segments;
46
+ }
47
+ export function extensionOf(path) {
48
+ const name = path.slice(path.lastIndexOf("/") + 1);
49
+ const dot = name.lastIndexOf(".");
50
+ return dot > 0 ? name.slice(dot).toLowerCase() : "";
51
+ }
52
+ /** `miadi-chronicle://548/foundations/x/README.md` → its episode and path; null for any other id. */
53
+ export function parseFileRef(id) {
54
+ const m = /^miadi-chronicle:(?:\/\/)?([^/\s]+)\/(.+)$/.exec(id.trim());
55
+ return m ? { episode: m[1], path: m[2] } : null;
56
+ }
57
+ export function fileRef(episodeNumber, path) {
58
+ return `miadi-chronicle://${episodeNumber}/${path}`;
59
+ }
60
+ /** The episode number a folder carries (`2026-09-27-episode-548-…` → 548). */
61
+ export function episodeNumberOf(folder) {
62
+ const m = /(?:^|-)episode-0*(\d+)(?:-|$)/.exec(folder);
63
+ return m ? Number.parseInt(m[1], 10) : null;
64
+ }
65
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
66
+ /**
67
+ * One page of a file Miadi returned whole. `offset` must be 0 or a
68
+ * `next.offset` this function gave, so a page never starts inside a character;
69
+ * `revision`, when given, must still be the file's sha256, so pages of two
70
+ * versions are never joined.
71
+ */
72
+ export function pageOf(text, opts = {}) {
73
+ const bytes = Buffer.from(text.content, "utf8");
74
+ const total = bytes.byteLength;
75
+ const whole = sha256(bytes);
76
+ if (opts.revision && opts.revision !== whole) {
77
+ throw new FileRefusal("changed", `The file changed since that page was read (it was ${opts.revision}, it is ${whole} now). Read it again from offset 0.`);
78
+ }
79
+ const offset = opts.offset ?? 0;
80
+ const maxBytes = Math.max(MIN_PAGE_BYTES, opts.maxBytes ?? DEFAULT_PAGE_BYTES);
81
+ if (!Number.isInteger(offset) || offset < 0 || offset > total || (offset < total && (bytes[offset] & 0xc0) === 0x80)) {
82
+ throw new FileRefusal("invalid_offset", `offset must be 0 or a next.offset this tool returned (the file has ${total} bytes).`);
83
+ }
84
+ let end = Math.min(total, offset + maxBytes);
85
+ while (end < total && (bytes[end] & 0xc0) === 0x80)
86
+ end--;
87
+ const page = bytes.subarray(offset, end);
88
+ const complete = end === total;
89
+ // Miadi computed its sha256 over the text it decoded; the bytes on disk are
90
+ // `size`. Both agree only when the file is valid UTF-8 and was read whole.
91
+ const miadiSha = text.revision?.sha256;
92
+ const verified = miadiSha === whole && total === text.size;
93
+ return {
94
+ bytes: total,
95
+ sha256: whole,
96
+ verified,
97
+ ...(verified
98
+ ? {}
99
+ : {
100
+ verification: miadiSha !== whole
101
+ ? `Miadi named sha256 ${miadiSha ?? "none"}; the text received hashes to ${whole}.`
102
+ : `The file is ${text.size} bytes on disk and ${total} bytes as UTF-8 text: it is not valid UTF-8, and the text holds replacement characters.`,
103
+ }),
104
+ ...(text.revision?.modifiedAt ? { modified_at: text.revision.modifiedAt } : {}),
105
+ page: {
106
+ offset,
107
+ end,
108
+ bytes: page.byteLength,
109
+ sha256: sha256(page),
110
+ complete,
111
+ next: complete ? null : { offset: end, revision: whole },
112
+ },
113
+ text: page.toString("utf8"),
114
+ };
115
+ }
package/dist/index.d.ts CHANGED
@@ -5,4 +5,7 @@ export { MiadiOAuthProvider, SCOPES, SCOPE_READ, SCOPE_CONTRIBUTE, type Person }
5
5
  export { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, RECORD_KINDS, current, type ConnectorRecord, type CircleId, type ClaimCategory, type RecordKind, } from "./records.js";
6
6
  export { TOOL_STATUS } from "./tools.js";
7
7
  export { runBrief, readQueueState } from "./approve.js";
8
+ export { PROVIDERS, discoverProviders, resolveProvider, routerPreflight, type ProviderAvailability, type ProviderId, type ProviderResolution, type RouterPreflight, } from "./providers.js";
9
+ export { missionStatus, resolveMissionSubject, type MissionStatus, type MissionStatusDeps } from "./mission-status.js";
10
+ export { AGENT_COMMANDS, BUSY_PATTERNS, STEER_STATUSES, checkGrant, isBusy, readSteerArtifact, steerAttempts, steerSession, steeringGrantOf, tmuxAdapter, type SteerDeps, type SteerResult, type SteerStatus, type SteeringGrant, type TmuxLike, } from "./steer.js";
8
11
  export { VERSION } from "./version.js";
package/dist/index.js CHANGED
@@ -5,4 +5,7 @@ export { MiadiOAuthProvider, SCOPES, SCOPE_READ, SCOPE_CONTRIBUTE } from "./oaut
5
5
  export { CIRCLES, CIRCLE_IDS, CLAIM_CATEGORIES, DOMAINS, HANDOFF_STATES, RECORD_KINDS, current, } from "./records.js";
6
6
  export { TOOL_STATUS } from "./tools.js";
7
7
  export { runBrief, readQueueState } from "./approve.js";
8
+ export { PROVIDERS, discoverProviders, resolveProvider, routerPreflight, } from "./providers.js";
9
+ export { missionStatus, resolveMissionSubject } from "./mission-status.js";
10
+ export { AGENT_COMMANDS, BUSY_PATTERNS, STEER_STATUSES, checkGrant, isBusy, readSteerArtifact, steerAttempts, steerSession, steeringGrantOf, tmuxAdapter, } from "./steer.js";
8
11
  export { VERSION } from "./version.js";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Read-only mission status (jgwill/Miadi#893, Phase 1A): where one mission
3
+ * stands, joined from the connector ledger, the factory queue, tmux, Miadi's
4
+ * session view and GitHub — with every stage named by its evidence, so an idle
5
+ * pane or a merge is never read as completion.
6
+ *
7
+ * The caller (tools.ts) decides the viewer's tier first: without the write
8
+ * tier there are no operator details (live panes, session internals); the
9
+ * record's own standing still answers.
10
+ */
11
+ import type { ConnectorRecord } from "./records.js";
12
+ import type { RequestProjection } from "./request-state.js";
13
+ export interface MissionSession {
14
+ /** How this session is bound to the mission: signed_ref (a session: ref on the record), brief_target (the delivered brief's `to`), named (the requested name). */
15
+ association: "signed_ref" | "brief_target" | "named";
16
+ session: string;
17
+ live: "running" | "absent" | "unknown";
18
+ activity?: "busy" | "idle" | "unknown";
19
+ /** Present when Miadi's session view was joined (write tier). */
20
+ miadi?: unknown;
21
+ }
22
+ export interface MissionStageTruth {
23
+ approved: boolean;
24
+ queued: boolean;
25
+ delivered: boolean;
26
+ /** "evidence" only while a bound session is live; an idle pane proves nothing. */
27
+ running: "evidence" | "no_evidence";
28
+ pr_merged: "merged" | "open_or_absent" | "no_pr";
29
+ released: "evidence" | "no_evidence";
30
+ closed: boolean;
31
+ }
32
+ export interface MissionStatus {
33
+ ref: string;
34
+ found: boolean;
35
+ via?: string;
36
+ subject?: {
37
+ id: string;
38
+ kind: string;
39
+ created_at: string;
40
+ by: string;
41
+ circle?: string;
42
+ intention?: string;
43
+ };
44
+ standing?: RequestProjection;
45
+ queue?: {
46
+ state: string;
47
+ };
48
+ sessions?: MissionSession[];
49
+ issues?: {
50
+ ref: string;
51
+ state: string;
52
+ title?: string;
53
+ merged?: boolean;
54
+ }[];
55
+ steering?: {
56
+ grant: "none" | "active" | "expired_grant" | "exhausted";
57
+ attempts_used: number;
58
+ max_attempts?: number;
59
+ };
60
+ stage_truth?: MissionStageTruth;
61
+ next_authorized_action?: string;
62
+ notes: string[];
63
+ }
64
+ export interface MissionStatusDeps {
65
+ queueOf?: (record: ConnectorRecord) => {
66
+ state: string;
67
+ } | undefined;
68
+ projectOf: (record: ConnectorRecord) => RequestProjection | null;
69
+ /** Live tmux session names, when the host can list them (write tier only). */
70
+ tmuxSessions?: () => string[];
71
+ /** Read-only peek: is the named session's first pane mid-turn? (write tier only.) */
72
+ paneBusy?: (session: string) => "busy" | "idle" | "unknown";
73
+ /** Miadi's session view by session:<uuid> (write tier only). */
74
+ sessionOf?: (id: string) => Promise<unknown>;
75
+ issueOf?: (owner: string, repo: string, n: string) => Promise<{
76
+ state: string;
77
+ title?: string;
78
+ merged?: boolean;
79
+ }>;
80
+ now?: () => Date;
81
+ }
82
+ /** The record a mission reference resolves to, and how it was found. */
83
+ export declare function resolveMissionSubject(ref: string, records: ConnectorRecord[]): {
84
+ record: ConnectorRecord;
85
+ via: string;
86
+ } | null;
87
+ export declare function missionStatus(ref: string, records: ConnectorRecord[], deps: MissionStatusDeps): Promise<MissionStatus>;
@@ -0,0 +1,144 @@
1
+ import { steerAttempts, steeringGrantOf, checkGrant } from "./steer.js";
2
+ const ISSUE_REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
3
+ const SESSION_REF = /^session:([0-9a-f-]{36})$/;
4
+ /** The record a mission reference resolves to, and how it was found. */
5
+ export function resolveMissionSubject(ref, records) {
6
+ const exact = records.find((r) => r.id === ref);
7
+ if (exact)
8
+ return { record: exact, via: "record_id" };
9
+ const session = SESSION_REF.exec(ref);
10
+ if (session) {
11
+ const citing = records
12
+ .filter((r) => (r.kind === "run_request" || r.kind === "work_proposal") && r.refs.includes(ref))
13
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
14
+ if (citing.length)
15
+ return { record: citing[0], via: "session_ref" };
16
+ return null;
17
+ }
18
+ if (ISSUE_REF.test(ref)) {
19
+ const citing = records
20
+ .filter((r) => (r.kind === "run_request" || r.kind === "work_proposal") && (r.refs.includes(ref) || r.evidence_refs.includes(ref)))
21
+ .sort((a, b) => b.created_at.localeCompare(a.created_at));
22
+ if (citing.length)
23
+ return { record: citing[0], via: "issue_ref" };
24
+ return null;
25
+ }
26
+ return null;
27
+ }
28
+ function nextAction(record, standing, queue) {
29
+ if (!standing)
30
+ return "Read the record itself; it is not an approvable request.";
31
+ switch (standing.stage) {
32
+ case "awaiting_decision":
33
+ return "A person with Miadi's write tier decides on its approval page or a decisions card.";
34
+ case "recording":
35
+ return "A decision is being recorded; read it again shortly.";
36
+ case "declined":
37
+ case "sent_back":
38
+ return standing.stage === "declined" ? "Nothing runs; a new request is needed." : "Submit a revised request citing the old id.";
39
+ case "dispatching":
40
+ return queue?.state === "delivered"
41
+ ? "The brief is with its session. Follow it, or steer it with request_session_steer inside the approved grant."
42
+ : "Wait for the factory intake to deliver the brief.";
43
+ case "failed":
44
+ return standing.rerun.available ? "A write-tier person may run it again with the same approval." : "Read the failure and decide.";
45
+ case "completed":
46
+ return standing.outstanding.length
47
+ ? "The approved changes are still outstanding; they need their own authorized step."
48
+ : "The request is done. A mission closes when its session's ending is decided, not when this request completes.";
49
+ }
50
+ }
51
+ export async function missionStatus(ref, records, deps) {
52
+ const notes = [];
53
+ const found = resolveMissionSubject(ref, records);
54
+ if (!found)
55
+ return { ref, found: false, notes: ["No mission record resolves from this reference."] };
56
+ const { record, via } = found;
57
+ const standing = deps.projectOf(record) ?? undefined;
58
+ const queue = deps.queueOf?.(record);
59
+ const now = deps.now?.() ?? new Date();
60
+ // The sessions this mission names, each with how it is bound.
61
+ const sessions = [];
62
+ const live = deps.tmuxSessions ? new Set(deps.tmuxSessions()) : undefined;
63
+ const briefTo = (record.completion_receipt ?? {}).to;
64
+ const named = String(record.body.session ?? "") || undefined;
65
+ const seen = new Set();
66
+ const addSession = (session, association) => {
67
+ const key = `${association}:${session}`;
68
+ if (seen.has(session))
69
+ return;
70
+ seen.add(session);
71
+ sessions.push({
72
+ association,
73
+ session,
74
+ live: live ? (live.has(session) ? "running" : "absent") : "unknown",
75
+ ...(deps.paneBusy && live?.has(session) ? { activity: deps.paneBusy(session) } : {}),
76
+ });
77
+ void key;
78
+ };
79
+ if (typeof briefTo === "string" && briefTo)
80
+ addSession(briefTo.replace(/^new:/, ""), "brief_target");
81
+ if (named)
82
+ addSession(named, "named");
83
+ for (const r of record.refs) {
84
+ const m = SESSION_REF.exec(r);
85
+ if (m) {
86
+ const miadi = deps.sessionOf ? await deps.sessionOf(m[1]).catch(() => ({ state: "unavailable" })) : undefined;
87
+ sessions.push({ association: "signed_ref", session: r, live: "unknown", ...(miadi ? { miadi } : {}) });
88
+ }
89
+ }
90
+ if (!live)
91
+ notes.push("Live session state was not joined (the viewer's tier or the host does not allow it).");
92
+ const issues = [];
93
+ if (deps.issueOf) {
94
+ for (const r of [...record.refs, ...record.evidence_refs]) {
95
+ const m = ISSUE_REF.exec(r);
96
+ if (!m)
97
+ continue;
98
+ const issue = await deps
99
+ .issueOf(m[1], m[2], m[3])
100
+ .catch(() => ({ state: "unavailable" }));
101
+ issues.push({ ref: r, state: issue.state, ...(issue.title ? { title: issue.title } : {}), ...(issue.merged !== undefined ? { merged: issue.merged } : {}) });
102
+ }
103
+ }
104
+ const grant = steeringGrantOf(record);
105
+ const attempts = steerAttempts(records, record.id);
106
+ const grantCheck = grant ? checkGrant(grant, attempts, now) : null;
107
+ const grantState = grantCheck === null ? "none" : grantCheck === "ok" ? "active" : grantCheck === "expired_grant" ? "expired_grant" : "exhausted";
108
+ const receipt = (record.completion_receipt ?? {});
109
+ const anyLive = sessions.some((s) => s.live === "running");
110
+ const prs = issues.filter((i) => i.merged !== undefined);
111
+ const stageTruth = {
112
+ approved: record.approval_state === "approved",
113
+ queued: queue?.state === "queued" || queue?.state === "delivered",
114
+ delivered: queue?.state === "delivered",
115
+ running: anyLive ? "evidence" : "no_evidence",
116
+ pr_merged: prs.length ? (prs.every((p) => p.merged) ? "merged" : "open_or_absent") : "no_pr",
117
+ released: receipt.published || receipt.version ? "evidence" : "no_evidence",
118
+ closed: standing?.stage === "completed" && standing?.standing === "done",
119
+ };
120
+ if (stageTruth.running === "no_evidence" && record.approval_state === "approved") {
121
+ notes.push("No bound session shows live; an idle or absent pane is not proof of completion or failure.");
122
+ }
123
+ return {
124
+ ref,
125
+ found: true,
126
+ via,
127
+ subject: {
128
+ id: record.id,
129
+ kind: record.kind,
130
+ created_at: record.created_at,
131
+ by: record.actor.name,
132
+ ...(record.circle ? { circle: record.circle } : {}),
133
+ ...(record.intention ? { intention: record.intention } : {}),
134
+ },
135
+ ...(standing ? { standing } : {}),
136
+ ...(queue ? { queue } : {}),
137
+ sessions,
138
+ issues,
139
+ steering: { grant: grantState, attempts_used: attempts, ...(grant?.max_attempts !== undefined ? { max_attempts: grant.max_attempts } : {}) },
140
+ stage_truth: stageTruth,
141
+ next_authorized_action: nextAction(record, standing ?? null, queue),
142
+ notes,
143
+ };
144
+ }
@@ -0,0 +1,61 @@
1
+ export type ProviderId = "codex" | "pi" | "copilot" | "claude";
2
+ export interface ProviderSpec {
3
+ id: ProviderId;
4
+ cli: string;
5
+ name: string;
6
+ /** False when a provider may run only on an explicit, authorized request. */
7
+ selectable_by_default: boolean;
8
+ /** Config paths whose existence suggests a sign-in; contents are never read. */
9
+ auth_evidence: string[];
10
+ }
11
+ /** Preference order when a run request names no provider (jgwill/Miadi#893). */
12
+ export declare const PROVIDERS: ProviderSpec[];
13
+ export interface ProviderAvailability {
14
+ id: ProviderId;
15
+ name: string;
16
+ cli: string;
17
+ installed: boolean;
18
+ version?: string;
19
+ auth_evidence: "found" | "none";
20
+ selectable_by_default: boolean;
21
+ }
22
+ export interface DiscoveryProbe {
23
+ which?: (cli: string) => string | null;
24
+ versionOf?: (cli: string) => string | undefined;
25
+ exists?: (path: string) => boolean;
26
+ home?: string;
27
+ }
28
+ export declare function discoverProviders(probe?: DiscoveryProbe, specs?: ProviderSpec[]): ProviderAvailability[];
29
+ export type ProviderResolution = {
30
+ state: "ok";
31
+ provider: ProviderAvailability;
32
+ explicit: boolean;
33
+ } | {
34
+ state: "provider_unknown" | "provider_unavailable" | "provider_needs_explicit_selection";
35
+ message: string;
36
+ available: ProviderAvailability[];
37
+ };
38
+ /**
39
+ * Resolve the provider a run will ask the intake for. A requested provider must
40
+ * be known and installed; with no request the first installed provider in the
41
+ * preference order that may be selected by default wins — Claude Code only ever
42
+ * by an explicit request.
43
+ */
44
+ export declare function resolveProvider(requested: string | undefined, found: ProviderAvailability[]): ProviderResolution;
45
+ export type RouterPreflight = {
46
+ state: "unconfigured";
47
+ note: string;
48
+ } | {
49
+ state: "reachable";
50
+ url: string;
51
+ } | {
52
+ state: "router_unavailable";
53
+ url: string;
54
+ message: string;
55
+ };
56
+ /**
57
+ * The read-only Jev routing preflight (jgwill/Miadi#893, section E): is a Jev
58
+ * decision-aide endpoint configured and reachable from this host? Nothing is
59
+ * sent but a health probe; no key is read here and no source text ever leaves.
60
+ */
61
+ export declare function routerPreflight(env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): Promise<RouterPreflight>;