@miadi/connector 0.1.20 → 0.1.22

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,9 +20,9 @@ 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` `read_episode_file` `get_evaluations` `get_narrative_beats` `review_lookup` `review_versions` `handoff_status` `list_records` `get_run_result` `mission_status` `provider_capabilities` `mission_model_preflight` `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` `mission_model_preflight` `search` `fetch` `list_research_artifacts` `read_research_artifact` |
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_session_steer` `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` `revoke_session_steering` `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.
@@ -60,7 +60,40 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
60
60
 
61
61
  **The agent's result (jgwill/Miadi#921).** An agent returns its work with `propose_session_completion` naming the mission in `mission`, from its bound session, with its evidence refs and, optionally, a structured `result`: `summary`, `outputs` (`ref`, `label`, `git_sha`), `tests` (`command`, `passed`, `failed`, `skipped`) and `blockers`. `mission_status` and `get_run_result` read it back by the mission id as `result`: the standing submission (the newest `verified` one: it names the mission, ends a bound session and was submitted with Miadi's write tier or the factory's writer token, which the connector records on the ending as `submitted_with_tier`; anything else naming the mission is `unverified_association` with an `association_code`, a mere citation `cited_only` and never a result), its summary, tests and blockers as the agent's claim, and each output typed and checked when read. A commit or file on GitHub is read through GitHub (`GITHUB_TOKEN` when the host has one): `verified` with its sha, `changed_since_submission` when its declared `git_sha` no longer matches or the file changed on its branch after the submission, `unverified` with the reason when GitHub cannot be read, never `not_found`. A connector record id the ledger does not hold is `not_found`. Bound sessions are taken from the factory's receipts for every reader, so the verdict is the same at every tier; below the write tier the session ids are left out. The completion decision stays the person's, and only a verified submission sets it: `stage_truth.completion` is `pending_approval`, `accepted`, `declined` or `revision_requested` (`none` for an unverified one, whose stage is `result_submitted_unverified`; an approved exit submitted below the write tier closes nothing), `pending_human_gates` lists what waits on a person with its approval page, and `mission_stage` names the furthest stage with evidence (`result_submitted_awaiting_completion_approval`, `completion_accepted`, `merged`, `released`, `closed`…). A duplicate submission (same session, ending and mission, still pending) is handed back with `replayed: true`, and `not_recorded` names what the second call carried that was not kept.
62
62
 
63
- `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. When the factory recorded the mission's pane, the steer goes to that pane id and nowhere else: another session is `stale_binding`, another pane `wrong_pane`, a replaced pane `stale_binding`, and text already at the prompt `composing` (nothing typed, Enter not pressed). A pane whose prompt box cannot be read (any TUI but Claude Code's today) is `blocked`. Every tmux address is exact (`=name:w.p`), because tmux otherwise matches a session by the start of its name. Only a steer that typed its instruction uses one of the grant's attempts (`typed`, and `attempts` in the answer). The connector's service steers through the tmux server of the user it runs as; `get_capabilities.steering` says whether that transport answers now, and while it does not, `request_session_steer` records nothing and uses no attempt. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
63
+ `request_session_steer` sends one person's correction to a running agent, within the steering grant an approved run carries (`steering: { max_attempts, expires_at, pane }` on `request_authorized_run`). Busy is the normal case (jgwill/Miadi#930).
64
+
65
+ | who | owns | never does |
66
+ |---|---|---|
67
+ | The person, through ChatGPT or any client | one correction, in scope | watch tools, wait for idle, send it again |
68
+ | The connector | who may send it, the grant, mission → session → runtime, idempotency, handing it to that session's inbox, reporting what happened | decide when the model reads it, type into a working agent's pane, call a forward a delivery |
69
+ | The runtime (Claude Code hooks: `miadi-session-observability` 0.1.34, `hooks/steering_inbox.mjs`) | the inbox, its order, the checks again at consumption, when its model reads it | deliver anything to a subagent or to another session |
70
+ | The model | reading it and changing what it does next | |
71
+
72
+ The route comes from what each part signed: the factory's receipt names the session, Claude Code's own registry says it runs and in which pane, and the adapter's `adapter.json` says this session loaded its hooks. A wrong session is `stale_binding`, a wrong pane `wrong_pane`. A provider with no adapter, or a Claude session started before the adapter (hooks load at session start), answers `unsupported` with the reason and uses no attempt. No Claude Code behaviour is assumed of Codex, Copilot or Pi, and `get_capabilities.steering.contract` names the adapters that exist.
73
+
74
+ The inbox is `$MIADI_STEERING_DIR` (default `<factory>/steering`), `<session_id>/pending/` and its siblings, one Unix account's, `0700`. The connector writes one envelope with fsync and rename. The adapter takes it at the next `PostToolUse` or `PostToolUseFailure` as `additionalContext` in the same turn, or at `Stop` with `decision: block` when no further tool follows. A correction landing while the turn ends, after `Stop` has looked, is caught by a background watcher that runs for 30 seconds after each turn (`asyncRewake`): exit 2 wakes the model with it, so a working agent never waits for idle or a timer. A seat already idle is typed at once (below). The factory runner's next pass is the last net for anything left (`wakeIdleSteers`, once per envelope, only if the prompt box is clear), for example a session that resumed with a correction waiting. `delivery.continuation` says which of these is coming: `next_tool_or_turn_end`, `runner_wakes_idle_seat` or `session_not_running`. One rename into `claimed/` decides who delivers, so it is delivered once and never replayed. The adapter checks the session, the hash, the expiry (the grant's, or one day) and revocation again when it consumes. The hook does not poll: it runs in the agent's own lifecycle, and nothing outside watches it. A hook on an interrupted tool does not consume, because Claude Code drops hook messages there, and a subagent's tool does not consume either.
75
+
76
+ Why hooks: Claude Code 2.1.318 has a native mid-turn queue, and `claude --help` offers no outside door to it. Typing reaches it through the keyboard, which is the terminal heuristic this replaces. The cross-session socket (`messagingSocketPath`) belongs to Claude sessions speaking to each other: its protocol is not published, and a connector speaking it would pose as a session. `PostToolUse.additionalContext` and `Stop` are its documented interface, so that is the adapter. Pi, Codex and Copilot have their own in-turn mechanisms, and none has an adapter here yet.
77
+
78
+ `delivery` in the answer, in `get_run_result` on the steer's id, and in `mission_status.steering.steers` (write tier) says where it stands, each state with its source and time:
79
+
80
+ | state | means | source |
81
+ |---|---|---|
82
+ | `authorized` | the grant or a person's decision covered it | connector |
83
+ | `forwarded_to_runtime`, `queued_at_runtime` | it is in the session's inbox; the model has not necessarily read it | connector, runtime inbox |
84
+ | `delivered_to_agent_context` | Claude Code recorded it entering the context | the transcript's attachment record |
85
+ | `acknowledged` | the agent's own next text names `steer <id> received` | the transcript |
86
+ | `behavior_adjusted` | a later tool input holds what the steer named in `expect_next_action`; without that, `behavior: not_judged` and the next actions are shown for a person to judge | the transcript |
87
+ | `unsupported`, `expired`, `blocked` | it never reached the model, with the reason | connector or runtime hook |
88
+ | `unknown` | claimed with no transcript record: ambiguous, and it is not sent again | |
89
+
90
+ The record itself moves with its evidence: `completed` once the model has it, `blocked` once it never will, on the next read by anyone. So the ledger, `mission_status` and the decision surfaces say the same thing.
91
+
92
+ `revoke_session_steering` stops a run's steering or one steer. What is still pending moves out at once, a marker stops anything later, and the answer says exactly what was revoked and what the agent had already consumed. The same idempotency key gets the first answer back, and the same words under a new key answer `duplicate`. Only a typed or forwarded steer uses one of the grant's attempts.
93
+
94
+ An idle seat has no continuation coming, so it is the one case still typed: into the mission's bound pane, never whichever pane is active, with an exact tmux address (`=name:w.p`). It peeks first and re-peeks after. Text already at the prompt, or a prompt box that cannot be read, goes to the inbox when the seat has one and otherwise is `composing` or `blocked`, with nothing typed. A pane echo is not delivery: only a user record in the transcript moves it to `delivered_to_agent_context`. Every attempt leaves a durable artifact with the instruction's sha256, its grant and its target. The connector's service reaches the tmux server of the user it runs as; `get_capabilities.steering` says whether that answers now, and while it does not, `request_session_steer` records nothing and uses no attempt. A steer the grant does not cover waits for one explicit scoped human decision on its `approval_url`, which runs exactly that instruction once.
95
+
96
+ Rollback: run the connector version before this one (`git checkout` of its `dist`, then `systemctl --user restart miadi-connector`). A working agent's steer then answers `busy` again and nothing else changes. To stop the runtime half, remove the plugin's `steering_inbox.sh` entries from `hooks/hooks.json`; a new session then has no `adapter.json` and answers `unsupported`. Envelopes already queued stay on disk, unread.
64
97
 
65
98
  `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 that may be picked by default (Pi, then Copilot) is resolved and named in the brief; Codex and Claude Code are never chosen silently, each 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 check: `unconfigured` without `JEV_AI_API_KEY` or `TYPESAFE_API_KEY` in the connector's environment, else the models Jev lists for the key (no charge). A provider name inside a task's text selects nothing.
66
99
 
@@ -68,6 +101,27 @@ A mission is found by the issue it implements (jgwill/Miadi#913). `request_autho
68
101
 
69
102
  `mission_model_preflight` (write tier) answers, for one mission, which provider and model fit its task type and criticality, and how that is known: contract `miadi.mission_model_preflight` v1, candidates with their configured default model, sign-in evidence (config directory only), quota `unknown`, policy eligibility, a review floor, and a recommendation or an abstention with reasons. Its `decider.source` is `static_fallback`, `jev` or `synthetic_fixture`. Jev is asked only with `consult_jev: true` and a key on the host; it is sent task type, criticality and candidates, never task text, mission id or repository, and its answer is advisory: uncalibrated, below 0.8 or outside the contract, the preflight abstains. A reported `launch` on another provider than the approved one answers `provider_mismatch` before anything is asked. It authorizes nothing. `bench/model-preflight-bench.mjs` compares the 0.1.12 baseline, the static preflight and Jev on synthetic scenarios; `bench/results/` keeps the runs.
70
103
 
104
+ ### The mission standard
105
+
106
+ A new or revised run or work proposal answers with `draft_review`, the report on that version against the mission standard (jgwill/Miadi#939). It lists aspects to revise, each with `aspect`, `name`, `checked_by`, `why`, `ask`, `settled_by` and sometimes `span`, in `needs_revision`, and what it did not judge in `not_checked`. `running_missions` lists what a person approved in the last 48 hours that has not finished, `preferences` holds the requester's standing preferences, and `next_action` says what to do. It carries no overall verdict, it advises and blocks nothing, and a failure in it never refuses a request. The report is also in `get_run_result`, and the open aspects are on the decision card and the approval page. The worker reads `delivery`, `person_words` and any aspect still open at approval in its brief.
107
+
108
+ | aspect | requires | checked by | settled by |
109
+ |---|---|---|---|
110
+ | S1 | one exact place the result lands, in `delivery` | code for an address, an issue, a uuid or a path; Jev below 0.3 otherwise | drafter |
111
+ | S2 | who may release, deploy or start a session | not checked yet: the local reviewer (jgwill/Miadi#939 A3) | person |
112
+ | S3 | running work on the same ground | code, on a shared issue only; the local reviewer judges the rest | drafter |
113
+ | S4 | a mechanism the mission dictates | Jev at 0.8 or above | drafter |
114
+ | S5 | the person's words for what is credited to them, in `person_words` | Jev at 0.8 or above notices the credit; only the drafter can answer | drafter |
115
+ | S6 | an episode or an issue that holds the work, and `primary_issue` when several are named | code | drafter |
116
+
117
+ `delivery` is optional in the schema, and a missing one is a finding. A client holding a cached tool list could not send a required field, and would lose the mission instead of revising it.
118
+
119
+ The drafter gets two rounds: a report and a revision, twice. The third report's `next_action` sends what is open to the card. One report is kept per record and version in `<state dir>/draft-reviews.jsonl`, so a person's decision on a version can be read against the aspects open on it. That pair is the ruling the bars will be set from.
120
+
121
+ **The standard is a document.** A text prompt named `mission-standard`, label `production`, in the prompt store's Engineering domain, read at most once per ten minutes, holding `{ jev_model, aspects, preferences }`. Revising it needs no release, and a report names the version that judged it. An aspect with a Jev check and a new code runs as written, so an aspect can be added there. `S1`, `S3` and `S6` have code behind them. A failed read keeps the last good version and says so, and with none the built-in copy answers and says why. A person's preferences live only in the store, because this package is published.
122
+
123
+ **What leaves the host.** The mission's text and its `delivery`, with ids, paths, hashes, URLs and repository names removed, to Jev, when the connector's environment holds a key. `mission_model_preflight` still sends no task text. Keys go in an environment file only the connector's user can read (`EnvironmentFile=-%h/.config/miadi/connector.env`): `JEV_AI_API_KEY` or `TYPESAFE_API_KEY`, and `ENG_LANGFUSE_PUBLIC_KEY`, `ENG_LANGFUSE_SECRET_KEY`, `ENG_LANGFUSE_BASE_URL`. Without them the report says what went unchecked, and code's findings stand. The bars are uncalibrated. Episode 564 (miadi-chronicle://564, `mission-draft-review-261105/`) holds the tests they were read from.
124
+
71
125
  ### Deciding inside the conversation
72
126
 
73
127
  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.
@@ -101,7 +155,7 @@ MIADI_CONNECTOR_PUBLIC_URL=https://mcp.sanctuaireagentique.com node packages/con
101
155
  | `MIADI_REVIEW_BASE_URL` | `https://miadi-review-service.vercel.app` | the review service |
102
156
  | `MIADI_REVIEW_TOKEN` | read from `~/.env` | review search and approved review operations |
103
157
  | `MIADI_FACTORY_DIR` | `~/.miadi/factory` | the queue approved runs are written to |
104
- | `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes |
158
+ | `MIADI_INQUIRY_DIR` | `/a/src/IAIP/prototypes/artefacts` | the inquiry shelf `save_research` writes to, inside a git checkout it pushes, and `list_research_artifacts` and `read_research_artifact` read back |
105
159
  | `MIADI_INQUIRY_GITHUB_REPO` | `miadisabelle/Etuaptmumk-RSM` | where a saved report's inquiry issue is opened, with the host's `gh` login |
106
160
  | `MIADI_CONNECTOR_DECISION_TOKEN_TTL` | `604800` (7 days) | seconds a card's decision token stays usable, held between 300 and 2592000 |
107
161
  | `MIADI_CONNECTOR_DECISION_WAIT_MS` | `20000` | how long a decision answer waits for its run before answering it as recorded and running, at most 50000 |
package/dist/approve.d.ts CHANGED
@@ -16,11 +16,15 @@ 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 { DraftReviewService } from "./draft-review.js";
20
+ import type { RuntimeDeps } from "./runtime-steer.js";
19
21
  import { type TmuxLike } from "./steer.js";
20
22
  import { type ProviderAuthorization } from "./provider-policy.js";
21
23
  export { factoryDir, runBriefFile, readQueueState } from "./queue-state.js";
22
24
  export interface ApprovalDeps {
23
25
  config: ConnectorConfig;
26
+ /** The report a drafted mission gets against the mission standard, shown on the page (jgwill/Miadi#939). */
27
+ draftReview?: DraftReviewService;
24
28
  ledger: Ledger<ConnectorRecord>;
25
29
  miadi: MiadiClient;
26
30
  identify: (token: string) => Promise<MiadiPerson | null>;
@@ -32,6 +36,8 @@ export interface ApprovalDeps {
32
36
  tmux?: TmuxLike;
33
37
  /** Re-peek delay after a steer is typed (default 4000ms; tests pass 0). */
34
38
  steerSettleMs?: number;
39
+ /** Where sessions and their steering inboxes live (jgwill/Miadi#930). Absent, no runtime route is known. */
40
+ steeringRuntime?: RuntimeDeps;
35
41
  /** Where decision events go (default: one JSON line on stdout, the service journal); tests collect them. */
36
42
  log?: (event: Record<string, unknown>) => void;
37
43
  }
@@ -56,7 +62,11 @@ export declare const DECISIONS: readonly ["approve", "approve_with_changes", "re
56
62
  export type Decision = (typeof DECISIONS)[number];
57
63
  /** The task a work proposal asks for, written from the proposal's own fields. */
58
64
  export declare function proposalTask(record: ConnectorRecord): string;
59
- export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string, providerAttrs?: Record<string, string>): {
65
+ export declare function runBrief(record: ConnectorRecord, approver: MiadiPerson, sessions: string[], instructions?: string, providerAttrs?: Record<string, string>, openAspects?: {
66
+ aspect: string;
67
+ name: string;
68
+ ask: string;
69
+ }[]): {
60
70
  to: string;
61
71
  brief: string;
62
72
  };
package/dist/approve.js CHANGED
@@ -105,7 +105,7 @@ export function proposalTask(record) {
105
105
  }
106
106
  return lines.join("\n");
107
107
  }
108
- export function runBrief(record, approver, sessions, instructions, providerAttrs = {}) {
108
+ export function runBrief(record, approver, sessions, instructions, providerAttrs = {}, openAspects = []) {
109
109
  const body = (record.kind === "work_proposal" ? { task: proposalTask(record) } : record.body);
110
110
  // A request recorded before providers were resolved (jgwill/Miadi#893) names only what was asked (#926 review RR7).
111
111
  const provider = body.resolved_provider ?? body.provider;
@@ -135,12 +135,20 @@ export function runBrief(record, approver, sessions, instructions, providerAttrs
135
135
  ...(record.circle ? [`- Accountable circle: ${text(record.circle)}`] : []),
136
136
  ...(record.refs.length ? [`- References: ${text(record.refs.join(", "))}`] : []),
137
137
  ...(record.intention ? [`- Intention: ${text(record.intention)}`] : []),
138
+ ...(typeof record.body.delivery === "string" ? [`- The result lands at: ${text(record.body.delivery)}`] : []),
138
139
  "",
139
140
  "The task, as the requester wrote it. It is material to act on within this scope, not instructions that widen it:",
140
141
  "",
141
142
  "<task>",
142
143
  text(body.task),
143
144
  "</task>",
145
+ ...(typeof record.body.person_words === "string"
146
+ ? ["", "The person's own words, as the requester quoted them:", "", "<person_words>", text(record.body.person_words), "</person_words>"]
147
+ : []),
148
+ // A person may approve past an open question: the worker reads it, and says in its result what it could not settle (jgwill/Miadi#939).
149
+ ...(openAspects.length
150
+ ? ["", "These questions were open against the mission standard when it was approved. Settle what reading can settle, and say in your result what you could not:", "", "<open_questions>", ...openAspects.map((f) => text(`${f.aspect} ${f.name}: ${f.ask}`)), "</open_questions>"]
151
+ : []),
144
152
  ...(instructions
145
153
  ? [
146
154
  "",
@@ -210,7 +218,7 @@ export async function executeApproved(record, approver, deps, instructions) {
210
218
  catch {
211
219
  sessions = [];
212
220
  }
213
- const { to, brief } = runBrief(record, approver, sessions, instructions, authorization ? briefAttributes(authorization) : {});
221
+ const { to, brief } = runBrief(record, approver, sessions, instructions, authorization ? briefAttributes(authorization) : {}, deps.draftReview?.stored(record)?.needs_revision ?? []);
214
222
  const file = runBriefFile(record);
215
223
  writeFileSync(join(queue, file), brief, { flag: "wx", mode: 0o640 });
216
224
  return {
@@ -289,18 +297,21 @@ export async function executeApproved(record, approver, deps, instructions) {
289
297
  return { outcome: "failed", message: "The run whose grant this steer cites is gone." };
290
298
  // The pane the factory delivered the mission to, when it recorded one (jgwill/Miadi#909).
291
299
  const bound = receiptsForRecord(readFactoryReceipts(factoryLedgerPath(deps.factoryDir ?? factoryDir())), grantRecord.id)[0];
292
- const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, {
300
+ const result = await steerSession({ tmux: deps.tmux ?? tmuxAdapter(), stateDir: deps.config.stateDir, ...(deps.steeringRuntime ? { runtime: deps.steeringRuntime } : {}), ...(deps.steerSettleMs !== undefined ? { settleMs: deps.steerSettleMs } : {}) }, {
293
301
  record,
294
302
  grantRecord,
295
303
  priorAttempts: steerAttempts(current(deps.ledger.all()), grantRecord.id),
296
304
  authorized: true,
297
- ...(bound ? { binding: { session: bound.tmux_session, ...(bound.pane_id ? { pane_id: bound.pane_id } : {}) } } : {}),
305
+ ...(bound ? { binding: { session: bound.tmux_session, ...(bound.pane_id ? { pane_id: bound.pane_id } : {}), ...(bound.session_id ? { session_id: bound.session_id } : {}), ...(bound.provider ? { provider: bound.provider } : {}) } } : {}),
298
306
  });
299
- // A typed steer is never "failed": failed is re-runnable, and that would type it twice (#922 R8).
307
+ // A typed or forwarded steer is never "failed": failed is re-runnable, and that would send it twice (#922 R8).
300
308
  return {
301
- outcome: result.typed ? result.status : "failed",
309
+ outcome: result.typed || result.forwarded ? result.status : "failed",
302
310
  steer_status: result.status,
303
311
  typed: result.typed,
312
+ ...(result.forwarded ? { forwarded: true } : {}),
313
+ ...(result.route ? { route: result.route } : {}),
314
+ ...(result.transcript_offset !== undefined ? { transcript_offset: result.transcript_offset } : {}),
304
315
  message: result.message,
305
316
  ...(result.target ? { target: result.target } : {}),
306
317
  ...(result.artifact ? { artifact: result.artifact } : {}),
@@ -328,6 +339,33 @@ input[type=password],textarea{width:100%;box-sizing:border-box;padding:.6rem;bor
328
339
  button{padding:.6rem 1.1rem;border-radius:.4rem;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer}
329
340
  button.primary{border-color:var(--accent);outline:1px solid var(--accent)}
330
341
  .err{color:#b00020}
342
+ .muted{color:var(--muted)}
343
+ .standard{border:1px solid var(--line);border-radius:.4rem;padding:.8rem 1rem;margin:1rem 0}
344
+ .standard h2{font-size:1rem;margin:0 0 .5rem}
345
+ .standard p{margin:.4rem 0}
346
+ .standard .muted{font-size:.9rem}
347
+ .aspects{list-style:none;margin:.8rem 0;padding:0;display:grid;gap:1rem}
348
+ .aspects li{display:grid;grid-template-columns:1rem 1fr;gap:.5rem;overflow-wrap:anywhere}
349
+ .aspects p{margin:.1rem 0}
350
+ .aspects .ask{font-weight:600}
351
+ .aspects .settler{color:var(--muted);font-size:.9rem;flex:1}
352
+ .aspects .yours .settler{color:var(--fg);font-weight:600}
353
+ .aspects .yours .glyph{color:var(--accent)}
354
+ .aspects .yours .iconbtn{border-color:var(--accent);color:var(--accent)}
355
+ .openL{display:none}
356
+ details[open]>summary .openL{display:inline}
357
+ details[open]>summary .closedL{display:none}
358
+ .aspects details{margin-top:.1rem}
359
+ .aspects details[open]{border-left:2px solid var(--accent);padding-left:.6rem;margin-top:.2rem}
360
+ .aspects summary{cursor:pointer;color:var(--muted);font-size:.9rem;padding:.45rem 0}
361
+ summary:focus-visible,.iconbtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
362
+ .who{display:flex;gap:.5rem;align-items:center}
363
+ .iconbtn{width:34px;height:34px;flex:none;display:inline-grid;place-items:center;border:1px solid var(--line);border-radius:.4rem;color:var(--fg)}
364
+ .state{display:grid;grid-template-columns:1rem 1fr;gap:.5rem;color:var(--muted);font-size:.9rem}
365
+ .code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
366
+ .glyph{margin-top:.25rem;color:var(--muted)}
367
+ .wordsLabel{margin-bottom:.1rem}
368
+ blockquote{margin:.2rem 0 .6rem;padding-left:.8rem;border-left:2px solid var(--line);overflow-wrap:anywhere}
331
369
  @media (prefers-color-scheme:dark){.err{color:#ff8a80}}
332
370
  </style></head><body><main>${body}</main></body></html>`;
333
371
  }
@@ -533,7 +571,7 @@ export async function rerun(r, person, deps, opts) {
533
571
  receipt = { ...receipt, ...outcome };
534
572
  if (outcome.outcome === "failed")
535
573
  state = "blocked";
536
- if (outcome.outcome === "queued")
574
+ if (outcome.outcome === "queued" || outcome.outcome === "queued_at_runtime")
537
575
  state = "in_progress";
538
576
  }
539
577
  catch (error) {
@@ -600,7 +638,7 @@ async function decideOnce(r, person, choice, deps, opts) {
600
638
  receipt = { ...receipt, ...outcome };
601
639
  if (outcome.outcome === "failed")
602
640
  state = "blocked";
603
- if (outcome.outcome === "queued")
641
+ if (outcome.outcome === "queued" || outcome.outcome === "queued_at_runtime")
604
642
  state = "in_progress";
605
643
  }
606
644
  catch (error) {
@@ -880,6 +918,55 @@ function projectionBlock(p, receipt) {
880
918
  const audit = receipt ? `<details><summary>Receipt</summary><pre>${escapeHtml(JSON.stringify(receipt, null, 2))}</pre></details>` : "";
881
919
  return `<section aria-label="Where it stands"><p><strong>${escapeHtml(p.label)}</strong></p>${lines ? `<ul>${lines}</ul>` : ""}${review}${audit}</section>`;
882
920
  }
921
+ const SETTLER_WORDS = {
922
+ drafter: "The drafting agent can answer this.",
923
+ person: "Only you can answer this. Write it under Instructions.",
924
+ bystander: "Only the person deciding can answer this, in their instructions.",
925
+ local_agent: "A local agent can settle this from the running system.",
926
+ };
927
+ const DASHED_PATHS = `<path d="M10.1 2.182a10 10 0 0 1 3.8 0"/><path d="M13.9 21.818a10 10 0 0 1-3.8 0"/><path d="M17.609 3.721a10 10 0 0 1 2.69 2.7"/><path d="M2.182 13.9a10 10 0 0 1 0-3.8"/><path d="M20.279 17.609a10 10 0 0 1-2.7 2.69"/><path d="M21.818 10.1a10 10 0 0 1 0 3.8"/><path d="M3.721 6.391a10 10 0 0 1 2.7-2.69"/><path d="M6.391 20.279a10 10 0 0 1-2.69-2.7"/>`;
928
+ const EYE_PATHS = `<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>`;
929
+ const glyph = (label, paths) => `<svg class="glyph" role="img" aria-label="${label}" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
930
+ const ARROW_DOWN_TO_LINE = `<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></svg>`;
931
+ const stateLine = (label, paths, html) => `<p class="state">${glyph(label, paths)}<span>${html}</span></p>`;
932
+ /** The mission standard's open aspects, for the person deciding: what only they can answer first, and no verdict (jgwill/Miadi#939). */
933
+ function draftReviewSection(r, review, canAnswer = false) {
934
+ if (r.kind !== "run_request" && r.kind !== "work_proposal")
935
+ return "";
936
+ const body = r.body;
937
+ const open = review ? [...review.needs_revision].sort((a, b) => Number(b.settled_by === "person") - Number(a.settled_by === "person") || a.aspect.localeCompare(b.aspect)) : [];
938
+ const place = body.delivery
939
+ ? open.some((f) => f.aspect === "S1")
940
+ ? `<p class="muted">The result lands at: ${escapeHtml(body.delivery)} (S1 below asks about this)</p>`
941
+ : `<p><span class="muted">The result lands at:</span> ${escapeHtml(body.delivery)}</p>`
942
+ : "";
943
+ const words = body.person_words ? `<p class="wordsLabel muted">The person's words, as the drafting agent quoted them:</p><blockquote>${escapeHtml(body.person_words)}</blockquote>` : "";
944
+ if (!review)
945
+ return `<section class="standard" aria-label="The mission standard"><h2>The mission standard</h2>${place}${words}${stateLine("Read it yourself", EYE_PATHS, "No check ran on this version of the mission. Read it yourself.")}</section>`;
946
+ const items = open
947
+ .map((f) => {
948
+ const span = f.span && f.span !== body.delivery ? `<p class="muted">The mission says: ${escapeHtml(f.span)}</p>` : "";
949
+ const note = f.note ? `<p class="muted">${escapeHtml(f.note)}</p>` : "";
950
+ const by = f.checked_by.split(" ")[0] === "jev" ? "Jev" : f.checked_by === "code" ? "a check in code" : `the ${f.checked_by}`;
951
+ const mine = f.settled_by === "person" && canAnswer;
952
+ const settler = f.settled_by === "person" && !canAnswer ? SETTLER_WORDS.bystander : (SETTLER_WORDS[f.settled_by] ?? f.settled_by);
953
+ const go = mine ? `<a class="iconbtn" href="#i" aria-label="Go to Instructions" title="Go to Instructions">${ARROW_DOWN_TO_LINE}</a>` : "";
954
+ return `<li class="${mine ? "yours" : ""}">${glyph("Open aspect", DASHED_PATHS)}<div><p class="muted"><span class="code">${escapeHtml(f.aspect)}</span> ${escapeHtml(f.name)}</p><p class="ask">${escapeHtml(f.ask)}</p>${span}${note}<div class="who"><p class="settler">${escapeHtml(settler)}</p>${go}</div><details><summary><span class="closedL">Why</span><span class="openL">Hide why</span></summary><p class="muted">${escapeHtml(f.why)}</p><p class="muted">Flagged by ${escapeHtml(by)}.</p></details></div></li>`;
955
+ })
956
+ .join("");
957
+ const openCodes = new Set(open.map((f) => f.aspect));
958
+ const unchecked = review.not_checked.filter((n) => !openCodes.has(n.aspect));
959
+ const uncheckedLine = unchecked.length ? stateLine("Read it yourself", EYE_PATHS, `No check covers these, so read the mission for them: ${unchecked.map((n) => `<span class="code">${escapeHtml(n.aspect)}</span> ${escapeHtml(n.name)}`).join(" · ")}`) : "";
960
+ const heading = open.length ? `${open.length} open on the mission standard v${review.standard.version}` : `The mission standard v${review.standard.version}`;
961
+ return `<section class="standard" aria-label="The mission standard">
962
+ <h2>${escapeHtml(heading)}</h2>
963
+ ${place}${words}
964
+ ${items ? `<ul class="aspects">${items}</ul>` : stateLine("Nothing flagged", DASHED_PATHS, "Nothing flagged on the aspects checked.")}
965
+ ${uncheckedLine}
966
+ ${canAnswer && open.some((f) => f.settled_by === "drafter") ? `<p class="muted">To put the drafting agent's questions to it, say what to revise under Instructions and send it back.</p>` : ""}
967
+ ${canAnswer && open.length ? `<p class="muted">Approving as it stands starts the session, and the worker reads these questions with the task.</p>` : ""}
968
+ </section>`;
969
+ }
883
970
  /** The request, and the decision form for a write-tier person (their token carried in the form they just signed into). */
884
971
  function approvePage(deps, r, o = {}) {
885
972
  const open = r.approval_state === "awaiting_approval";
@@ -892,6 +979,7 @@ function approvePage(deps, r, o = {}) {
892
979
  ${actionable ? `<p>${escapeHtml(consequenceOf(r, authorization))} Declining records your refusal. Either is kept in the ledger with your name.</p>` : ""}
893
980
  ${authorization ? providerSection(r, authorization, actionable ? o.chooserToken : undefined) : ""}
894
981
  ${summary(r)}
982
+ ${open ? draftReviewSection(r, deps.draftReview?.stored(r), Boolean(actionable && o.token && !blocked)) : ""}
895
983
  ${projectionBlock(projectionOf(r, deps), open ? undefined : r.completion_receipt)}
896
984
  ${o.error ? `<p class="err" role="alert">${escapeHtml(o.error)}</p>` : ""}
897
985
  ${actionable && o.token
@@ -903,7 +991,7 @@ ${actionable && o.token
903
991
  <div class="row">
904
992
  ${blocked
905
993
  ? ""
906
- : `<button class="primary" type="submit" name="decision" value="approve">Approve and run</button>
994
+ : `<button${r.kind === "run_request" || r.kind === "work_proposal" ? "" : ' class="primary"'} type="submit" name="decision" value="approve">Approve and run</button>
907
995
  <button type="submit" name="decision" value="approve_with_changes">Approve with my changes</button>`}
908
996
  <button type="submit" name="decision" value="request_revision">Send back for revision</button>
909
997
  <button type="submit" name="decision" value="decline">Decline</button>
package/dist/bin.js CHANGED
@@ -4,6 +4,7 @@ import { createConnectorApp } from "./server.js";
4
4
  import { identifyThroughMiadi } from "./people.js";
5
5
  import { steeringTransport } from "./tools.js";
6
6
  import { tmuxAdapter } from "./steer.js";
7
+ import { defaultRuntime } from "./runtime-steer.js";
7
8
  import { VERSION } from "./version.js";
8
9
  const config = configFromEnv();
9
10
  // The host's steering surface (jgwill/Miadi#909): the tmux server of the user the service runs as,
@@ -13,6 +14,8 @@ const { app } = createConnectorApp(config, {
13
14
  identify: identifyThroughMiadi(config.miadiUrl),
14
15
  tmux,
15
16
  tmuxSessions: () => tmux.listSessions(),
17
+ // Each session's own steering inbox and Claude Code's registry of running sessions (jgwill/Miadi#930).
18
+ steeringRuntime: defaultRuntime(),
16
19
  });
17
20
  app.listen(config.port, config.host, () => {
18
21
  console.log(`@miadi/connector ${VERSION} on http://${config.host}:${config.port}`);
@@ -1,4 +1,4 @@
1
- export declare const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
1
+ export declare const DECISIONS_CARD_URI = "ui://miadi/decisions-v2.html";
2
2
  /** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
3
3
  export declare const DECISION_TOKEN_TTL: number;
4
4
  /** Where the tokens travel in a result's `_meta`, keyed by record id. */
@@ -19,4 +19,4 @@ export declare class DecisionTokens {
19
19
  * and calls `decide_request` through the host. In a host without that bridge it
20
20
  * shows each request's approval page instead.
21
21
  */
22
- export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.fail{color:var(--err);font-weight:600}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n // A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.\n var POLL_MS=3000,POLL_MAX=100,polls={};\n // An error that leaves the request open offers its approval page under it.\n var relink={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":[\"recording\",\"dispatching\"].indexOf(p.stage)!==-1?\"note\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n // Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.\n function follow(r,lost){\n if(!host()||!host().callTool)return;\n polls[r.id]=(polls[r.id]||0)+1;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}\n if(p.stage===\"awaiting_decision\"){delete decided[r.id];if(lost)errors[r.id]=lost+\" Nothing was decided; decide again.\";render();return;}\n var note=decided[r.id]&&decided[r.id].note;\n decided[r.id]=projectionView(p,note);delete errors[r.id];render();\n if(p.stage===\"recording\"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);\n },function(err){\n if(lost){decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Your decision may be recorded. Checking again\u2026\",lines:[],url:null,note:null};render();}\n if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);\n });\n }\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if(shown.stage===\"recording\"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n function approvalLink(){var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));p.appendChild(el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\"));return p;}\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n var buttons=[[\"approve\",\"Approve\",\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&(out.projection||(out.detail&&out.detail.projection));\n var code=out&&out.error;\n delete pending[r.id];\n // The token expired or the request was revised, and it still awaits a decision: say so and keep it open.\n if(code&&(!proj||proj.stage===\"awaiting_decision\")){errors[r.id]=(out&&out.message)||\"Not recorded.\";relink[r.id]=true;render();return;}\n var note=out&&out.replayed?\"This decision was already recorded. Nothing ran again.\":code===\"decision_conflict\"?\"A different decision was recorded first. Here is where it stands.\":code===\"already_decided\"?\"This request was already decided. Here is where it stands.\":null;\n if(proj){decided[r.id]=projectionView(proj,note);}\n else{decided[r.id]={cls:code?\"fail\":\"done\",label:code?((out&&out.message)||\"Not recorded.\"):((out&&out.summary)||\"Recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){\n // The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).\n delete pending[r.id];\n decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Checking whether your decision was recorded\u2026\",lines:[],url:null,note:null};\n polls[r.id]=0;render();\n follow(r,\"Not recorded: \"+(err&&err.message?err.message:String(err))+\".\");\n });\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
22
+ export declare const DECISIONS_CARD_HTML = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<style>\n:root{color-scheme:light dark;--fg:#1d1b18;--muted:#5b5650;--line:#d9d4cc;--accent:#7a4a00;--ok:#1d6b3a;--err:#b00020;--chip:#f1ede6}\n@media (prefers-color-scheme:dark){:root{--fg:#ece8e1;--muted:#b3ada4;--line:#3a3631;--accent:#ffd700;--ok:#7fd49b;--err:#ff8a80;--chip:#24211d}}\n*{box-sizing:border-box}\nbody{margin:0;color:var(--fg);font:15px/1.45 system-ui,sans-serif;background:transparent}\nmain{padding:12px;display:grid;gap:12px}\n.empty,.note{color:var(--muted)}\narticle{border:1px solid var(--line);border-radius:10px;padding:12px}\nh2{font-size:15px;margin:0 0 4px}\n.kind{display:inline-block;font-size:12px;padding:1px 8px;border-radius:999px;background:var(--chip);color:var(--muted);margin-bottom:6px}\n.meta{font-size:13px;color:var(--muted);margin:0 0 6px}\n.does{margin:6px 0;font-weight:600}\ndetails{margin:6px 0}\nsummary{cursor:pointer;color:var(--muted);font-size:13px}\npre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;margin:6px 0 0;max-height:240px;overflow:auto}\ntextarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);font:inherit;margin-top:8px}\n.row{display:flex;flex-wrap:wrap;gap:8px;margin-top:8px}\nbutton{padding:8px 12px;border-radius:8px;border:1px solid var(--line);background:transparent;color:var(--fg);font:inherit;cursor:pointer;min-height:40px}\nbutton.primary{border-color:var(--accent);outline:1px solid var(--accent)}\nbutton:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\nbutton[disabled]{opacity:.55;cursor:default}\na{color:inherit}\n.done{color:var(--ok);font-weight:600}\n.std{border-top:1px solid var(--line);margin:8px 0;padding-top:8px;display:grid;gap:8px}\nh3{font-size:14px;margin:0;font-weight:600}\n.aspects{list-style:none;margin:4px 0;padding:0;display:grid;gap:14px}\n.asp{display:grid;grid-template-columns:16px 1fr;gap:6px;font-size:14px;overflow-wrap:anywhere}\n.asp .gl{margin-top:3px;color:var(--muted);display:block}\n.asp p{margin:1px 0}\n.asp .nm{color:var(--muted)}\n.asp .ask{font-weight:600}\n.asp details{margin:0}\n.asp details[open]{border-left:2px solid var(--accent);padding-left:8px;margin-top:2px}\n.asp summary{padding:8px 0}\n.who{display:flex;gap:8px;align-items:center}\n.who .settler{flex:1}\n.yours .settler{color:var(--fg);font-weight:600}\n.yours .gl{color:var(--accent)}\n.iconbtn{min-height:34px;width:34px;padding:0;display:inline-grid;place-items:center;flex:none;border-color:var(--accent);color:var(--accent)}\n.openL{display:none}\ndetails[open]>summary .openL{display:inline}\ndetails[open]>summary .closedL{display:none}\n.fact{margin:0;font-size:14px;overflow-wrap:anywhere}\n.code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}\n.small{font-size:13px;color:var(--muted);margin:0;overflow-wrap:anywhere}\nblockquote{margin:4px 0 0;padding-left:8px;border-left:2px solid var(--line);font-size:14px;overflow-wrap:anywhere}\nsummary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}\n</style></head><body><main id=\"root\" aria-live=\"polite\"><p class=\"note\">Loading requests\u2026</p></main>\n<script>\n(function(){\n var root=document.getElementById(\"root\");\n var decided={};\n // A redraw while a decision is being recorded must not offer the buttons again.\n var pending={};\n var errors={};\n // A decision still running is followed to its outcome (jgwill/Miadi#924): one read every POLL_MS, at most POLL_MAX.\n var POLL_MS=3000,POLL_MAX=100,polls={};\n // An error that leaves the request open offers its approval page under it.\n var relink={};\n function el(tag,attrs,text){var e=document.createElement(tag);if(attrs)for(var k in attrs){if(k===\"class\")e.className=attrs[k];else e.setAttribute(k,attrs[k]);}if(text!=null)e.textContent=text;return e;}\n function host(){return window.openai||null;}\n function data(){var h=host();return h&&h.toolOutput?h.toolOutput:null;}\n function tokens(){var h=host();var m=h&&h.toolResponseMetadata;return (m&&m[\"miadi/decisionTokens\"])||{};}\n function render(){\n var d=data();root.textContent=\"\";\n if(!d){root.appendChild(el(\"p\",{class:\"note\"},\"Waiting for the request list.\"));return;}\n var list=d.decisions||[];\n if(!list.length){root.appendChild(el(\"p\",{class:\"empty\"},d.message||\"No request is waiting for a decision.\"));return;}\n if(!d.can_decide_here&&d.reason){root.appendChild(el(\"p\",{class:\"note\"},d.reason));}\n var t=tokens();\n list.forEach(function(r){root.appendChild(card(r,t[r.id]));});\n }\n // Where a request stands, as the connector projected it: the label, its lines, and the review it produced.\n function decidedBlock(d){\n var box=el(\"div\",{role:\"status\",class:d.cls});\n if(d.note)box.appendChild(el(\"p\",{class:\"line\"},d.note));\n box.appendChild(el(\"p\",{class:\"label\"},d.label));\n (d.lines||[]).forEach(function(l){box.appendChild(el(\"p\",{class:\"line\"},l));});\n if(d.url){var p=el(\"p\",{class:\"line\"});p.appendChild(el(\"a\",{href:d.url,target:\"_blank\",rel:\"noopener\"},d.url));box.appendChild(p);}\n return box;\n }\n function toolResult(res){\n var out=(res&&(res.structuredContent||res.structured_content))||null;\n if(!out&&res&&res.content&&res.content[0]&&res.content[0].text){try{out=JSON.parse(res.content[0].text);}catch(e){out={message:res.content[0].text};}}\n return out;\n }\n function projectionView(p,note){return {cls:p.stage===\"failed\"?\"fail\":[\"recording\",\"dispatching\"].indexOf(p.stage)!==-1?\"note\":\"done\",stage:p.stage,label:p.label,lines:p.lines||[],url:p.receipt_url||null,note:note||null};}\n // Read where a request stands. Still running: read again later. Never decided (the call was lost on the way): offer the decision again.\n function follow(r,lost){\n if(!host()||!host().callTool)return;\n polls[r.id]=(polls[r.id]||0)+1;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(!p){if(lost){delete decided[r.id];errors[r.id]=lost;}render();return;}\n if(p.stage===\"awaiting_decision\"){delete decided[r.id];if(lost)errors[r.id]=lost+\" Nothing was decided; decide again.\";render();return;}\n var note=decided[r.id]&&decided[r.id].note;\n decided[r.id]=projectionView(p,note);delete errors[r.id];render();\n if(p.stage===\"recording\"&&polls[r.id]<POLL_MAX)setTimeout(function(){follow(r);},POLL_MS);\n },function(err){\n if(lost){decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Your decision may be recorded. Checking again\u2026\",lines:[],url:null,note:null};render();}\n if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);\n });\n }\n var SETTLED={drafter:\"The drafting agent can answer this.\",person:\"Only you can answer this. Write it under Instructions.\",bystander:\"Only the person deciding can answer this, in their instructions.\",local_agent:\"A local agent can settle this from the running system.\"};\n var ARROW='<svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M12 17V3\"/><path d=\"m6 11 6 6 6-6\"/><path d=\"M19 21H5\"/></svg>';\n var DASHED='<path d=\"M10.1 2.182a10 10 0 0 1 3.8 0\"/><path d=\"M13.9 21.818a10 10 0 0 1-3.8 0\"/><path d=\"M17.609 3.721a10 10 0 0 1 2.69 2.7\"/><path d=\"M2.182 13.9a10 10 0 0 1 0-3.8\"/><path d=\"M20.279 17.609a10 10 0 0 1-2.7 2.69\"/><path d=\"M21.818 10.1a10 10 0 0 1 0 3.8\"/><path d=\"M3.721 6.391a10 10 0 0 1 2.7-2.69\"/><path d=\"M6.391 20.279a10 10 0 0 1-2.69-2.7\"/>';var EYE='<path d=\"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0\"/><circle cx=\"12\" cy=\"12\" r=\"3\"/>';\n function glyph(label,paths){return '<svg role=\"img\" aria-label=\"'+label+'\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.9\" stroke-linecap=\"round\" stroke-linejoin=\"round\">'+paths+'</svg>';}\n // A redraw rebuilds the card: what the person typed and which reasons they opened stay.\n var typed={};var whyOpen={};var boxes={};\n function stateLine(label,paths,build){var row=el(\"div\",{class:\"asp state\"});var g=el(\"span\",{class:\"gl\"});g.innerHTML=glyph(label,paths);row.appendChild(g);var t=el(\"p\",{class:\"small\"});build(t);row.appendChild(t);return row;}\n // The mission standard's open aspects, for the person deciding: what only they can answer first, and no verdict (jgwill/Miadi#939).\n function standardBlock(r,token){\n var rv=r.draft_review;\n var canAnswer=Boolean(token&&host()&&host().callTool);\n var s=el(\"section\",{class:\"std\",\"aria-label\":\"The mission standard\"});\n var list=rv?(rv.needs_revision||[]).slice().sort(function(a,b){var p=(b.settled_by===\"person\"?1:0)-(a.settled_by===\"person\"?1:0);return p||String(a.aspect).localeCompare(String(b.aspect));}):[];\n var version=rv&&rv.standard?\" v\"+rv.standard.version:\"\";\n s.appendChild(el(\"h3\",null,list.length?list.length+\" open on the mission standard\"+version:\"The mission standard\"+version));\n if(r.delivery){\n var place=el(\"p\",{class:\"fact\"});place.appendChild(el(\"span\",{class:\"small\"},\"The result lands at: \"));var disputed=list.some(function(f){return f.aspect===\"S1\";});\n place.appendChild(el(\"span\",disputed?{class:\"small\"}:null,r.delivery));\n if(disputed)place.appendChild(el(\"span\",{class:\"small\"},\" (S1 below asks about this)\"));\n s.appendChild(place);\n }\n if(r.person_words){var w=el(\"div\");w.appendChild(el(\"p\",{class:\"small\"},\"The person's words, as the drafting agent quoted them:\"));w.appendChild(el(\"blockquote\",null,r.person_words));s.appendChild(w);}\n if(!rv){s.appendChild(stateLine(\"Read it yourself\",EYE,function(t){t.textContent=\"No check ran on this version of the mission. Read it yourself.\";}));return s;}\n if(!list.length)s.appendChild(stateLine(\"Nothing flagged\",DASHED,function(t){t.textContent=\"Nothing flagged on the aspects checked.\";}));\n else{\n var ul=el(\"ul\",{class:\"aspects\"});\n list.forEach(function(f){\n var mine=f.settled_by===\"person\"&&canAnswer;\n var row=el(\"li\",{class:\"asp\"+(mine?\" yours\":\"\")});\n var g=el(\"span\",{class:\"gl\"});g.innerHTML=glyph(\"Open aspect\",DASHED);row.appendChild(g);\n var t=el(\"div\");\n var name=el(\"p\",{class:\"nm\"});name.appendChild(el(\"span\",{class:\"code\"},f.aspect));name.appendChild(el(\"span\",null,\" \"+f.name));t.appendChild(name);\n t.appendChild(el(\"p\",{class:\"ask\"},f.ask));\n if(f.span&&f.span!==r.delivery)t.appendChild(el(\"p\",{class:\"small\"},\"The mission says: \"+f.span));\n if(f.note)t.appendChild(el(\"p\",{class:\"small\"},f.note));\n var who=el(\"div\",{class:\"who\"});\n who.appendChild(el(\"p\",{class:\"small settler\"},(f.settled_by===\"person\"&&!canAnswer?SETTLED.bystander:SETTLED[f.settled_by])||f.settled_by));\n if(mine){\n var go=el(\"button\",{type:\"button\",class:\"iconbtn\",\"aria-label\":\"Go to Instructions\",title:\"Go to Instructions\"});go.innerHTML=ARROW;\n go.addEventListener(\"click\",function(){var box=boxes[r.id];if(box){if(box.scrollIntoView)box.scrollIntoView({block:\"center\"});box.focus();}});\n who.appendChild(go);\n }\n t.appendChild(who);\n var key=r.id+\"/\"+f.aspect;\n var why=el(\"details\");if(whyOpen[key])why.setAttribute(\"open\",\"\");\n why.addEventListener(\"toggle\",function(){whyOpen[key]=why.open;});\n var sum=el(\"summary\");sum.appendChild(el(\"span\",{class:\"closedL\"},\"Why\"));sum.appendChild(el(\"span\",{class:\"openL\"},\"Hide why\"));why.appendChild(sum);\n why.appendChild(el(\"p\",{class:\"small\"},f.why));\n var by=String(f.checked_by||\"\").split(\" \")[0]===\"jev\"?\"Jev\":f.checked_by===\"code\"?\"a check in code\":\"the \"+f.checked_by;\n why.appendChild(el(\"p\",{class:\"small\"},\"Flagged by \"+by+\".\"));\n t.appendChild(why);\n row.appendChild(t);ul.appendChild(row);\n });\n s.appendChild(ul);\n }\n var open={};list.forEach(function(f){open[f.aspect]=true;});\n var rest=(rv.not_checked||[]).filter(function(n){return !open[n.aspect];});\n if(rest.length)s.appendChild(stateLine(\"Read it yourself\",EYE,function(t){\n t.appendChild(el(\"span\",null,\"No check covers these, so read the mission for them: \"));\n rest.forEach(function(n,i){if(i)t.appendChild(el(\"span\",null,\" \u00B7 \"));t.appendChild(el(\"span\",{class:\"code\"},n.aspect));t.appendChild(el(\"span\",null,\" \"+n.name));});\n }));\n if(canAnswer&&list.some(function(f){return f.settled_by===\"drafter\";}))s.appendChild(el(\"p\",{class:\"small\"},\"To put the drafting agent's questions to it, say what to revise under Instructions and send it back.\"));\n if(canAnswer&&list.length)s.appendChild(el(\"p\",{class:\"small\"},\"Approving as it stands starts the session, and the worker reads these questions with the task.\"));\n return s;\n }\n function card(r,token){\n var a=el(\"article\",{\"aria-label\":r.title});\n a.appendChild(el(\"span\",{class:\"kind\"},r.kind_label||r.kind));\n a.appendChild(el(\"h2\",null,r.title));\n a.appendChild(el(\"p\",{class:\"meta\"},\"Asked by \"+r.by+(r.via?\" through \"+r.via:\"\")+\" \u00B7 \"+new Date(r.created_at).toLocaleString()+(r.circle?\" \u00B7 \"+r.circle+\" circle\":\"\")));\n a.appendChild(el(\"p\",{class:\"does\"},r.consequence));\n if(r.intention)a.appendChild(el(\"p\",null,r.intention));\n if(!decided[r.id]&&!pending[r.id]&&r.projection&&r.projection.stage===\"awaiting_decision\"&&(r.kind===\"run_request\"||r.kind===\"work_proposal\"||r.draft_review||r.delivery||r.person_words))a.appendChild(standardBlock(r,token));\n var det=el(\"details\");det.appendChild(el(\"summary\",null,\"What exactly is asked\"));det.appendChild(el(\"pre\",null,r.detail||\"\"));a.appendChild(det);\n var status=el(\"p\",{role:\"status\"});\n if(r.projection&&r.projection.stage!==\"awaiting_decision\"&&(!decided[r.id]||[\"recording\",\"dispatching\"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}\n if(decided[r.id]){\n var shown=decided[r.id];a.appendChild(decidedBlock(shown));\n if(shown.stage===\"recording\"&&polls[r.id]===undefined&&host()&&host().callTool){polls[r.id]=0;setTimeout(function(){follow(r);},POLL_MS);}\n if([\"recording\",\"dispatching\"].indexOf(shown.stage)!==-1&&host()&&host().callTool){\n var refresh=el(\"button\",{type:\"button\"},\"Refresh status\");\n refresh.addEventListener(\"click\",function(){\n refresh.disabled=true;\n host().callTool(\"get_run_result\",{record_id:r.id}).then(function(res){\n var out=toolResult(res);var p=out&&(out.projection||(out.data&&out.data.projection));\n if(p){decided[r.id]=projectionView(p);delete errors[r.id];}\n else{errors[r.id]=(out&&out.message)||\"Status could not be refreshed.\";}\n render();\n },function(err){errors[r.id]=\"Status could not be refreshed: \"+String(err&&err.message||err);render();});\n });\n a.appendChild(refresh);\n }\n if(errors[r.id])a.appendChild(el(\"p\",{class:\"fail\",role:\"status\"},errors[r.id]));\n return a;\n }\n function approvalLink(){var p=el(\"p\",{class:\"note\"});p.appendChild(document.createTextNode(\"Decide on its approval page: \"));p.appendChild(el(\"a\",{href:r.approval_url,target:\"_blank\",rel:\"noopener\"},\"open the approval page\"));return p;}\n if(pending[r.id]){status.className=\"note\";status.textContent=\"Recording your decision\u2026\";a.appendChild(status);return a;}\n if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}\n var ta=el(\"textarea\",{\"aria-label\":\"Instructions or changes (needed to approve with changes or send back)\",placeholder:\"Instructions or changes (needed to approve with changes or send back)\"});\n ta.value=typed[r.id]||\"\";boxes[r.id]=ta;\n ta.addEventListener(\"input\",function(){typed[r.id]=ta.value;});\n a.appendChild(ta);\n var row=el(\"div\",{class:\"row\"});\n // The ring on a mission would move with its report and read as a verdict, so a mission's buttons carry none (jgwill/Miadi#939).\n var mission=r.kind===\"run_request\"||r.kind===\"work_proposal\";\n var buttons=[[\"approve\",\"Approve\",mission?\"\":\"primary\"],[\"approve_with_changes\",\"Approve with my changes\",\"\"],[\"request_revision\",\"Send back for revision\",\"\"],[\"decline\",\"Decline\",\"\"]];\n var btns=[];\n buttons.forEach(function(b){var x=el(\"button\",{type:\"button\",class:b[2]},b[1]);x.addEventListener(\"click\",function(){go(b[0]);});btns.push(x);row.appendChild(x);});\n a.appendChild(row);a.appendChild(status);\n if(errors[r.id]){status.className=\"fail\";status.textContent=errors[r.id];if(relink[r.id])a.appendChild(approvalLink());}\n function go(choice){\n var text=ta.value.trim();\n if((choice===\"approve_with_changes\"||choice===\"request_revision\")&&!text){status.className=\"fail\";status.textContent=choice===\"request_revision\"?\"Say what to revise.\":\"Write the changes to apply.\";ta.focus();return;}\n btns.forEach(function(x){x.disabled=true;});status.className=\"note\";status.textContent=\"Recording your decision\u2026\";pending[r.id]=true;delete errors[r.id];\n host().callTool(\"decide_request\",{record_id:r.id,decision:choice,instructions:text||undefined,decision_token:token}).then(function(res){\n var out=toolResult(res);\n // The connector answers with the request's projection, also when a click finds it decided already.\n var proj=out&&(out.projection||(out.detail&&out.detail.projection));\n var code=out&&out.error;\n delete pending[r.id];\n // The token expired or the request was revised, and it still awaits a decision: say so and keep it open.\n if(code&&(!proj||proj.stage===\"awaiting_decision\")){errors[r.id]=(out&&out.message)||\"Not recorded.\";relink[r.id]=true;render();return;}\n var note=out&&out.replayed?\"This decision was already recorded. Nothing ran again.\":code===\"decision_conflict\"?\"A different decision was recorded first. Here is where it stands.\":code===\"already_decided\"?\"This request was already decided. Here is where it stands.\":null;\n if(proj){decided[r.id]=projectionView(proj,note);}\n else{decided[r.id]={cls:code?\"fail\":\"done\",label:code?((out&&out.message)||\"Not recorded.\"):((out&&out.summary)||\"Recorded.\"),lines:[],url:null,note:null};}\n render();\n },function(err){\n // The host gave up waiting; the connector may still have recorded it. Read before offering the buttons again (#924).\n delete pending[r.id];\n decided[r.id]={cls:\"note\",stage:\"recording\",label:\"Checking whether your decision was recorded\u2026\",lines:[],url:null,note:null};\n polls[r.id]=0;render();\n follow(r,\"Not recorded: \"+(err&&err.message?err.message:String(err))+\".\");\n });\n }\n return a;\n }\n window.addEventListener(\"openai:set_globals\",render);\n render();\n})();\n</script></body></html>";
package/dist/decisions.js CHANGED
@@ -24,7 +24,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
24
24
  import { join } from "node:path";
25
25
  import { newSecret } from "./store.js";
26
26
  import { DEFAULT_DECISION_TOKEN_TTL } from "./config.js";
27
- export const DECISIONS_CARD_URI = "ui://miadi/decisions-v1.html";
27
+ export const DECISIONS_CARD_URI = "ui://miadi/decisions-v2.html";
28
28
  /** The default lifetime of a card's decision token, in seconds; MIADI_CONNECTOR_DECISION_TOKEN_TTL changes it. */
29
29
  export const DECISION_TOKEN_TTL = DEFAULT_DECISION_TOKEN_TTL;
30
30
  /** Where the tokens travel in a result's `_meta`, keyed by record id. */
@@ -98,7 +98,30 @@ button:focus-visible,a:focus-visible,textarea:focus-visible{outline:2px solid va
98
98
  button[disabled]{opacity:.55;cursor:default}
99
99
  a{color:inherit}
100
100
  .done{color:var(--ok);font-weight:600}
101
- .fail{color:var(--err);font-weight:600}
101
+ .std{border-top:1px solid var(--line);margin:8px 0;padding-top:8px;display:grid;gap:8px}
102
+ h3{font-size:14px;margin:0;font-weight:600}
103
+ .aspects{list-style:none;margin:4px 0;padding:0;display:grid;gap:14px}
104
+ .asp{display:grid;grid-template-columns:16px 1fr;gap:6px;font-size:14px;overflow-wrap:anywhere}
105
+ .asp .gl{margin-top:3px;color:var(--muted);display:block}
106
+ .asp p{margin:1px 0}
107
+ .asp .nm{color:var(--muted)}
108
+ .asp .ask{font-weight:600}
109
+ .asp details{margin:0}
110
+ .asp details[open]{border-left:2px solid var(--accent);padding-left:8px;margin-top:2px}
111
+ .asp summary{padding:8px 0}
112
+ .who{display:flex;gap:8px;align-items:center}
113
+ .who .settler{flex:1}
114
+ .yours .settler{color:var(--fg);font-weight:600}
115
+ .yours .gl{color:var(--accent)}
116
+ .iconbtn{min-height:34px;width:34px;padding:0;display:inline-grid;place-items:center;flex:none;border-color:var(--accent);color:var(--accent)}
117
+ .openL{display:none}
118
+ details[open]>summary .openL{display:inline}
119
+ details[open]>summary .closedL{display:none}
120
+ .fact{margin:0;font-size:14px;overflow-wrap:anywhere}
121
+ .code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
122
+ .small{font-size:13px;color:var(--muted);margin:0;overflow-wrap:anywhere}
123
+ blockquote{margin:4px 0 0;padding-left:8px;border-left:2px solid var(--line);font-size:14px;overflow-wrap:anywhere}
124
+ summary:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
102
125
  </style></head><body><main id="root" aria-live="polite"><p class="note">Loading requests…</p></main>
103
126
  <script>
104
127
  (function(){
@@ -155,6 +178,71 @@ a{color:inherit}
155
178
  if(polls[r.id]<POLL_MAX)setTimeout(function(){follow(r,lost);},POLL_MS);
156
179
  });
157
180
  }
181
+ var SETTLED={drafter:"The drafting agent can answer this.",person:"Only you can answer this. Write it under Instructions.",bystander:"Only the person deciding can answer this, in their instructions.",local_agent:"A local agent can settle this from the running system."};
182
+ var ARROW='<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17V3"/><path d="m6 11 6 6 6-6"/><path d="M19 21H5"/></svg>';
183
+ var DASHED='<path d="M10.1 2.182a10 10 0 0 1 3.8 0"/><path d="M13.9 21.818a10 10 0 0 1-3.8 0"/><path d="M17.609 3.721a10 10 0 0 1 2.69 2.7"/><path d="M2.182 13.9a10 10 0 0 1 0-3.8"/><path d="M20.279 17.609a10 10 0 0 1-2.7 2.69"/><path d="M21.818 10.1a10 10 0 0 1 0 3.8"/><path d="M3.721 6.391a10 10 0 0 1 2.7-2.69"/><path d="M6.391 20.279a10 10 0 0 1-2.69-2.7"/>';var EYE='<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>';
184
+ function glyph(label,paths){return '<svg role="img" aria-label="'+label+'" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">'+paths+'</svg>';}
185
+ // A redraw rebuilds the card: what the person typed and which reasons they opened stay.
186
+ var typed={};var whyOpen={};var boxes={};
187
+ function stateLine(label,paths,build){var row=el("div",{class:"asp state"});var g=el("span",{class:"gl"});g.innerHTML=glyph(label,paths);row.appendChild(g);var t=el("p",{class:"small"});build(t);row.appendChild(t);return row;}
188
+ // The mission standard's open aspects, for the person deciding: what only they can answer first, and no verdict (jgwill/Miadi#939).
189
+ function standardBlock(r,token){
190
+ var rv=r.draft_review;
191
+ var canAnswer=Boolean(token&&host()&&host().callTool);
192
+ var s=el("section",{class:"std","aria-label":"The mission standard"});
193
+ var list=rv?(rv.needs_revision||[]).slice().sort(function(a,b){var p=(b.settled_by==="person"?1:0)-(a.settled_by==="person"?1:0);return p||String(a.aspect).localeCompare(String(b.aspect));}):[];
194
+ var version=rv&&rv.standard?" v"+rv.standard.version:"";
195
+ s.appendChild(el("h3",null,list.length?list.length+" open on the mission standard"+version:"The mission standard"+version));
196
+ if(r.delivery){
197
+ var place=el("p",{class:"fact"});place.appendChild(el("span",{class:"small"},"The result lands at: "));var disputed=list.some(function(f){return f.aspect==="S1";});
198
+ place.appendChild(el("span",disputed?{class:"small"}:null,r.delivery));
199
+ if(disputed)place.appendChild(el("span",{class:"small"}," (S1 below asks about this)"));
200
+ s.appendChild(place);
201
+ }
202
+ if(r.person_words){var w=el("div");w.appendChild(el("p",{class:"small"},"The person's words, as the drafting agent quoted them:"));w.appendChild(el("blockquote",null,r.person_words));s.appendChild(w);}
203
+ if(!rv){s.appendChild(stateLine("Read it yourself",EYE,function(t){t.textContent="No check ran on this version of the mission. Read it yourself.";}));return s;}
204
+ if(!list.length)s.appendChild(stateLine("Nothing flagged",DASHED,function(t){t.textContent="Nothing flagged on the aspects checked.";}));
205
+ else{
206
+ var ul=el("ul",{class:"aspects"});
207
+ list.forEach(function(f){
208
+ var mine=f.settled_by==="person"&&canAnswer;
209
+ var row=el("li",{class:"asp"+(mine?" yours":"")});
210
+ var g=el("span",{class:"gl"});g.innerHTML=glyph("Open aspect",DASHED);row.appendChild(g);
211
+ var t=el("div");
212
+ var name=el("p",{class:"nm"});name.appendChild(el("span",{class:"code"},f.aspect));name.appendChild(el("span",null," "+f.name));t.appendChild(name);
213
+ t.appendChild(el("p",{class:"ask"},f.ask));
214
+ if(f.span&&f.span!==r.delivery)t.appendChild(el("p",{class:"small"},"The mission says: "+f.span));
215
+ if(f.note)t.appendChild(el("p",{class:"small"},f.note));
216
+ var who=el("div",{class:"who"});
217
+ who.appendChild(el("p",{class:"small settler"},(f.settled_by==="person"&&!canAnswer?SETTLED.bystander:SETTLED[f.settled_by])||f.settled_by));
218
+ if(mine){
219
+ var go=el("button",{type:"button",class:"iconbtn","aria-label":"Go to Instructions",title:"Go to Instructions"});go.innerHTML=ARROW;
220
+ go.addEventListener("click",function(){var box=boxes[r.id];if(box){if(box.scrollIntoView)box.scrollIntoView({block:"center"});box.focus();}});
221
+ who.appendChild(go);
222
+ }
223
+ t.appendChild(who);
224
+ var key=r.id+"/"+f.aspect;
225
+ var why=el("details");if(whyOpen[key])why.setAttribute("open","");
226
+ why.addEventListener("toggle",function(){whyOpen[key]=why.open;});
227
+ var sum=el("summary");sum.appendChild(el("span",{class:"closedL"},"Why"));sum.appendChild(el("span",{class:"openL"},"Hide why"));why.appendChild(sum);
228
+ why.appendChild(el("p",{class:"small"},f.why));
229
+ var by=String(f.checked_by||"").split(" ")[0]==="jev"?"Jev":f.checked_by==="code"?"a check in code":"the "+f.checked_by;
230
+ why.appendChild(el("p",{class:"small"},"Flagged by "+by+"."));
231
+ t.appendChild(why);
232
+ row.appendChild(t);ul.appendChild(row);
233
+ });
234
+ s.appendChild(ul);
235
+ }
236
+ var open={};list.forEach(function(f){open[f.aspect]=true;});
237
+ var rest=(rv.not_checked||[]).filter(function(n){return !open[n.aspect];});
238
+ if(rest.length)s.appendChild(stateLine("Read it yourself",EYE,function(t){
239
+ t.appendChild(el("span",null,"No check covers these, so read the mission for them: "));
240
+ rest.forEach(function(n,i){if(i)t.appendChild(el("span",null," · "));t.appendChild(el("span",{class:"code"},n.aspect));t.appendChild(el("span",null," "+n.name));});
241
+ }));
242
+ if(canAnswer&&list.some(function(f){return f.settled_by==="drafter";}))s.appendChild(el("p",{class:"small"},"To put the drafting agent's questions to it, say what to revise under Instructions and send it back."));
243
+ if(canAnswer&&list.length)s.appendChild(el("p",{class:"small"},"Approving as it stands starts the session, and the worker reads these questions with the task."));
244
+ return s;
245
+ }
158
246
  function card(r,token){
159
247
  var a=el("article",{"aria-label":r.title});
160
248
  a.appendChild(el("span",{class:"kind"},r.kind_label||r.kind));
@@ -162,6 +250,7 @@ a{color:inherit}
162
250
  a.appendChild(el("p",{class:"meta"},"Asked by "+r.by+(r.via?" through "+r.via:"")+" · "+new Date(r.created_at).toLocaleString()+(r.circle?" · "+r.circle+" circle":"")));
163
251
  a.appendChild(el("p",{class:"does"},r.consequence));
164
252
  if(r.intention)a.appendChild(el("p",null,r.intention));
253
+ if(!decided[r.id]&&!pending[r.id]&&r.projection&&r.projection.stage==="awaiting_decision"&&(r.kind==="run_request"||r.kind==="work_proposal"||r.draft_review||r.delivery||r.person_words))a.appendChild(standardBlock(r,token));
165
254
  var det=el("details");det.appendChild(el("summary",null,"What exactly is asked"));det.appendChild(el("pre",null,r.detail||""));a.appendChild(det);
166
255
  var status=el("p",{role:"status"});
167
256
  if(r.projection&&r.projection.stage!=="awaiting_decision"&&(!decided[r.id]||["recording","dispatching"].indexOf(r.projection.stage)===-1)){decided[r.id]=projectionView(r.projection);}
@@ -188,9 +277,13 @@ a{color:inherit}
188
277
  if(pending[r.id]){status.className="note";status.textContent="Recording your decision…";a.appendChild(status);return a;}
189
278
  if(!token||!host()||!host().callTool){a.appendChild(approvalLink());return a;}
190
279
  var ta=el("textarea",{"aria-label":"Instructions or changes (needed to approve with changes or send back)",placeholder:"Instructions or changes (needed to approve with changes or send back)"});
280
+ ta.value=typed[r.id]||"";boxes[r.id]=ta;
281
+ ta.addEventListener("input",function(){typed[r.id]=ta.value;});
191
282
  a.appendChild(ta);
192
283
  var row=el("div",{class:"row"});
193
- var buttons=[["approve","Approve","primary"],["approve_with_changes","Approve with my changes",""],["request_revision","Send back for revision",""],["decline","Decline",""]];
284
+ // The ring on a mission would move with its report and read as a verdict, so a mission's buttons carry none (jgwill/Miadi#939).
285
+ var mission=r.kind==="run_request"||r.kind==="work_proposal";
286
+ var buttons=[["approve","Approve",mission?"":"primary"],["approve_with_changes","Approve with my changes",""],["request_revision","Send back for revision",""],["decline","Decline",""]];
194
287
  var btns=[];
195
288
  buttons.forEach(function(b){var x=el("button",{type:"button",class:b[2]},b[1]);x.addEventListener("click",function(){go(b[0]);});btns.push(x);row.appendChild(x);});
196
289
  a.appendChild(row);a.appendChild(status);