agentxchain 2.157.0 → 2.159.0

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.
@@ -0,0 +1,381 @@
1
+ /**
2
+ * M15: Govern Without Micromanaging — Human Attention Surface (VISION.md:51)
3
+ *
4
+ * Closes the final "Why This Must Exist" pain bullet: "humans lose the ability to
5
+ * govern without micromanaging." The triggers that legitimately require a human are
6
+ * real but scattered — pending phase/completion approvals live in `state.blocked_on`,
7
+ * open escalations in `human-escalations.jsonl`, approved-but-undispatched work in the
8
+ * intake system, credentialed gates in `approval-policy`, and budget/policy halts back
9
+ * in `state.blocked_on`. To govern today the operator must poll every surface
10
+ * (micromanage) or trust blindly (forbidden by VISION.md:220).
11
+ *
12
+ * This module is a govern-by-exception COMPOSITION layer (Architecture Invariant #1):
13
+ * it aggregates those already-existing signals into a single prioritized
14
+ * `HumanAttentionReport`, reaching each one through its public surface. It NEVER
15
+ * reimplements escalation, approval, or intake logic, and it is strictly READ-ONLY
16
+ * (Invariant #2) — it never mutates state, escalations, intents, artifacts, or config.
17
+ * Every category is evaluated independently; a failure or empty result in one never
18
+ * suppresses another (Invariant #4).
19
+ *
20
+ * The defining property: when no human decision is pending the queue is EMPTY and
21
+ * `overall === 'clear'` (Invariant #3). That empty state is the operational proof that
22
+ * the human can step back and let governed autonomy run.
23
+ *
24
+ * Public surface:
25
+ * evaluateHumanAttention(repoDir) — live single-repo cross-category queue
26
+ * buildHumanAttentionSummary(artifact) — compact summary embedded in a governance report
27
+ */
28
+
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { join } from 'node:path';
31
+
32
+ import { loadProjectContext } from './config.js';
33
+ import { readHumanEscalations } from './human-escalations.js';
34
+ import { findPendingApprovedIntents } from './intake.js';
35
+ import { isCredentialedExitGate } from './approval-policy.js';
36
+
37
+ const DEFAULT_STATE_PATH = '.agentxchain/state.json';
38
+
39
+ // Category ids surfaced by the attention queue.
40
+ export const HUMAN_ATTENTION_CATEGORIES = {
41
+ CREDENTIALED_GATE: 'credentialed_gate',
42
+ ESCALATION: 'escalation',
43
+ APPROVAL: 'approval',
44
+ MANUAL_ACTION: 'manual_action',
45
+ BUDGET_POLICY: 'budget_policy',
46
+ PENDING_INTENT: 'pending_intent',
47
+ };
48
+
49
+ // Deterministic priority key (lower = more urgent). Blocking categories are all < 100
50
+ // and informational categories >= 100, which — combined with the blocking-first sort —
51
+ // guarantees every blocking item precedes every non-blocking item (Ordering contract).
52
+ // Within the blocking tier: credentialed-gate and escalation outrank pending-approval,
53
+ // which outranks budget/policy; manual_action (gate_action/human blocks) sits just below
54
+ // approval. Pending-intent is the only informational tier.
55
+ const CATEGORY_PRIORITY = {
56
+ credentialed_gate: 10,
57
+ escalation: 20,
58
+ approval: 30,
59
+ manual_action: 35,
60
+ budget_policy: 40,
61
+ pending_intent: 100,
62
+ };
63
+
64
+ /**
65
+ * Read the governed state file WITHOUT writeback (loadProjectState may normalize and
66
+ * persist, which would violate the read-only invariant). Mirrors ship-status.js.
67
+ */
68
+ function readGovernedStateReadOnly(root, config) {
69
+ const rel = config?.files?.state || DEFAULT_STATE_PATH;
70
+ const filePath = join(root, rel);
71
+ if (!existsSync(filePath)) return null;
72
+ try {
73
+ return JSON.parse(readFileSync(filePath, 'utf8'));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ function blockedOnString(state) {
80
+ return typeof state?.blocked_on === 'string' ? state.blocked_on : null;
81
+ }
82
+
83
+ // ── category evaluators (pure; exported for focused unit testing) ─────────────
84
+
85
+ /**
86
+ * Identify the gate (if any) currently awaiting human approval, from the blocked
87
+ * state or a recorded pending transition / completion.
88
+ */
89
+ function pendingApprovalGate(state) {
90
+ if (!state) return null;
91
+ const blockedOn = blockedOnString(state);
92
+ if (blockedOn && blockedOn.startsWith('human_approval:')) {
93
+ return blockedOn.slice('human_approval:'.length) || null;
94
+ }
95
+ if (state.pending_run_completion?.gate) return state.pending_run_completion.gate;
96
+ if (state.pending_phase_transition?.gate) return state.pending_phase_transition.gate;
97
+ return null;
98
+ }
99
+
100
+ function approvalActionHint(state) {
101
+ if (state?.pending_run_completion) return 'agentxchain approve-completion';
102
+ return 'agentxchain approve-transition';
103
+ }
104
+
105
+ /**
106
+ * Categories 1 & 4 — Pending approvals (critical transitions / completion) and the
107
+ * credentialed-gate refinement. A pending human-approval gate is a single decision;
108
+ * it is classified as `credentialed_gate` when the current phase's exit gate is
109
+ * credentialed (VISION.md:36 — gates guarding irreversible or credentialed actions),
110
+ * otherwise as `approval`. Mutually exclusive, so the same decision is never
111
+ * double-counted while still honouring the ordering contract (credentialed outranks
112
+ * plain approval). Composes approval-policy.js `isCredentialedExitGate`.
113
+ */
114
+ export function evaluatePendingApprovalCategory(state, config) {
115
+ const gate = pendingApprovalGate(state);
116
+ if (!gate) return [];
117
+
118
+ const runId = state?.run_id || null;
119
+ const phase = state?.phase || null;
120
+ const phaseSuffix = phase ? ` (phase "${phase}")` : '';
121
+ const hint = approvalActionHint(state);
122
+
123
+ let credentialed = false;
124
+ try {
125
+ credentialed = isCredentialedExitGate(config, phase);
126
+ } catch {
127
+ credentialed = false;
128
+ }
129
+
130
+ if (credentialed) {
131
+ return [{
132
+ category: HUMAN_ATTENTION_CATEGORIES.CREDENTIALED_GATE,
133
+ priority: CATEGORY_PRIORITY.credentialed_gate,
134
+ blocking: true,
135
+ run_id: runId,
136
+ summary: `Credentialed gate "${gate}" requires human approval${phaseSuffix}.`,
137
+ action_hint: hint,
138
+ }];
139
+ }
140
+
141
+ return [{
142
+ category: HUMAN_ATTENTION_CATEGORIES.APPROVAL,
143
+ priority: CATEGORY_PRIORITY.approval,
144
+ blocking: true,
145
+ run_id: runId,
146
+ summary: `Gate "${gate}" awaits human approval${phaseSuffix}.`,
147
+ action_hint: hint,
148
+ }];
149
+ }
150
+
151
+ /**
152
+ * Category 2 — Open human escalations (intervene during escalation). Composes
153
+ * human-escalations.js `readHumanEscalations`; surfaces every record whose status is
154
+ * `open`. Each carries its own `resolution_command` action hint.
155
+ */
156
+ export function evaluateEscalationCategory(escalations) {
157
+ return (escalations || [])
158
+ .filter((record) => record && record.status === 'open')
159
+ .map((record) => {
160
+ const service = record.service ? ` (${record.service})` : '';
161
+ const what = record.detail || record.action || record.typed_reason || record.escalation_id;
162
+ return {
163
+ category: HUMAN_ATTENTION_CATEGORIES.ESCALATION,
164
+ priority: CATEGORY_PRIORITY.escalation,
165
+ blocking: true,
166
+ run_id: record.run_id || null,
167
+ summary: `${record.type || 'escalation'} escalated to human${service}: ${what}`,
168
+ action_hint: record.resolution_command || `agentxchain unblock ${record.escalation_id}`,
169
+ };
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Category 5 — Budget / policy blockers. A run halted on budget or policy needs a human
175
+ * to raise the budget or override the policy. Composes `state.blocked_on`
176
+ * (`budget:exhausted`, `policy:<id>`).
177
+ */
178
+ export function evaluateBudgetPolicyCategory(state) {
179
+ const blockedOn = blockedOnString(state);
180
+ if (!blockedOn) return [];
181
+ const runId = state?.run_id || null;
182
+
183
+ if (blockedOn === 'budget:exhausted') {
184
+ return [{
185
+ category: HUMAN_ATTENTION_CATEGORIES.BUDGET_POLICY,
186
+ priority: CATEGORY_PRIORITY.budget_policy,
187
+ blocking: true,
188
+ run_id: runId,
189
+ summary: 'Run halted: budget exhausted — raise the budget to continue.',
190
+ action_hint: 'agentxchain unblock',
191
+ }];
192
+ }
193
+
194
+ if (blockedOn.startsWith('policy:')) {
195
+ const policyId = blockedOn.slice('policy:'.length) || 'unknown';
196
+ return [{
197
+ category: HUMAN_ATTENTION_CATEGORIES.BUDGET_POLICY,
198
+ priority: CATEGORY_PRIORITY.budget_policy,
199
+ blocking: true,
200
+ run_id: runId,
201
+ summary: `Run halted by policy "${policyId}" — a human must override or adjust it.`,
202
+ action_hint: 'agentxchain unblock',
203
+ }];
204
+ }
205
+
206
+ return [];
207
+ }
208
+
209
+ /**
210
+ * Bonus category — Manual gate action / generic human block. Catches the remaining
211
+ * human-owned `state.blocked_on` encodings (`gate_action:<gate>`, `human:<detail>`) so a
212
+ * run blocked on operator intervention is never silently dropped from the queue. (The
213
+ * `escalation:<id>` encoding is already represented by its open escalation in Category 2.)
214
+ */
215
+ export function evaluateManualActionCategory(state) {
216
+ const blockedOn = blockedOnString(state);
217
+ if (!blockedOn) return [];
218
+ const runId = state?.run_id || null;
219
+
220
+ if (blockedOn.startsWith('gate_action:')) {
221
+ const gate = blockedOn.slice('gate_action:'.length) || 'unknown';
222
+ return [{
223
+ category: HUMAN_ATTENTION_CATEGORIES.MANUAL_ACTION,
224
+ priority: CATEGORY_PRIORITY.manual_action,
225
+ blocking: true,
226
+ run_id: runId,
227
+ summary: `Run awaits a manual gate action on "${gate}".`,
228
+ action_hint: 'agentxchain gate',
229
+ }];
230
+ }
231
+
232
+ if (blockedOn.startsWith('human:')) {
233
+ const detail = blockedOn.slice('human:'.length) || 'operator intervention required';
234
+ return [{
235
+ category: HUMAN_ATTENTION_CATEGORIES.MANUAL_ACTION,
236
+ priority: CATEGORY_PRIORITY.manual_action,
237
+ blocking: true,
238
+ run_id: runId,
239
+ summary: `Run awaits operator intervention: ${detail}.`,
240
+ action_hint: 'agentxchain unblock',
241
+ }];
242
+ }
243
+
244
+ return [];
245
+ }
246
+
247
+ /**
248
+ * Category 3 — Pending approved intents awaiting dispatch (set direction). Informational:
249
+ * work approved but not yet dispatched. Composes intake.js `findPendingApprovedIntents`.
250
+ */
251
+ export function evaluatePendingIntentCategory(intents) {
252
+ return (intents || []).map((intent) => {
253
+ const pri = intent.priority ? ` [${intent.priority}]` : '';
254
+ const charter = intent.charter ? `: ${intent.charter}` : '';
255
+ return {
256
+ category: HUMAN_ATTENTION_CATEGORIES.PENDING_INTENT,
257
+ priority: CATEGORY_PRIORITY.pending_intent,
258
+ blocking: false,
259
+ run_id: null,
260
+ summary: `Approved intent ${intent.intent_id}${pri} awaits dispatch${charter}.`,
261
+ action_hint: 'agentxchain start',
262
+ };
263
+ });
264
+ }
265
+
266
+ // ── ordering & assembly ──────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Deterministic Ordering contract: blocking items before non-blocking; within a tier,
270
+ * ascending `priority`; ties broken by `run_id` then `summary` for stability.
271
+ */
272
+ function sortItems(items) {
273
+ return [...items].sort((a, b) => {
274
+ if (a.blocking !== b.blocking) return a.blocking ? -1 : 1;
275
+ if (a.priority !== b.priority) return a.priority - b.priority;
276
+ const ra = a.run_id || '';
277
+ const rb = b.run_id || '';
278
+ if (ra !== rb) return ra < rb ? -1 : 1;
279
+ const sa = a.summary || '';
280
+ const sb = b.summary || '';
281
+ if (sa !== sb) return sa < sb ? -1 : 1;
282
+ return 0;
283
+ });
284
+ }
285
+
286
+ function assembleReport(items) {
287
+ const sorted = sortItems(items);
288
+ const blockingCount = sorted.filter((item) => item.blocking).length;
289
+ const categories = [...new Set(sorted.map((item) => item.category))];
290
+ const overall = sorted.length === 0 ? 'clear' : 'attention';
291
+
292
+ const evidence = overall === 'clear'
293
+ ? 'Nothing needs your attention; governed autonomy can run.'
294
+ : `${sorted.length} item${sorted.length === 1 ? '' : 's'} need human attention `
295
+ + `(${blockingCount} blocking) across ${categories.length} `
296
+ + `categor${categories.length === 1 ? 'y' : 'ies'}.`;
297
+
298
+ return {
299
+ overall,
300
+ items: sorted,
301
+ items_count: sorted.length,
302
+ blocking_count: blockingCount,
303
+ categories,
304
+ evidence_summary: evidence,
305
+ };
306
+ }
307
+
308
+ // Run each category in isolation so a throw or empty result in one never suppresses
309
+ // another (Architecture Invariant #4).
310
+ function collect(items, fn) {
311
+ try {
312
+ const result = fn();
313
+ if (Array.isArray(result)) items.push(...result);
314
+ } catch {
315
+ /* category isolated — a failure here must not blank the rest of the queue */
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Compose the cross-category human-decision queue for a single governed repo into a
321
+ * prioritized `HumanAttentionReport`. Read-only.
322
+ *
323
+ * @param {string} repoDir
324
+ * @returns {{ overall, items, items_count, blocking_count, categories, evidence_summary }}
325
+ */
326
+ export function evaluateHumanAttention(repoDir) {
327
+ const root = repoDir || process.cwd();
328
+
329
+ let config = null;
330
+ try {
331
+ const ctx = loadProjectContext(root);
332
+ config = ctx?.config || null;
333
+ } catch {
334
+ config = null;
335
+ }
336
+
337
+ const state = readGovernedStateReadOnly(root, config);
338
+
339
+ let escalations = [];
340
+ collect([], () => { escalations = readHumanEscalations(root) || []; return []; });
341
+
342
+ let intents = [];
343
+ collect([], () => { intents = findPendingApprovedIntents(root, { run_id: state?.run_id || null }) || []; return []; });
344
+
345
+ const items = [];
346
+ collect(items, () => evaluatePendingApprovalCategory(state, config));
347
+ collect(items, () => evaluateEscalationCategory(escalations));
348
+ collect(items, () => evaluateBudgetPolicyCategory(state));
349
+ collect(items, () => evaluateManualActionCategory(state));
350
+ collect(items, () => evaluatePendingIntentCategory(intents));
351
+
352
+ return assembleReport(items);
353
+ }
354
+
355
+ /**
356
+ * Build the compact human-attention summary embedded in a governance report. Operates on
357
+ * an export artifact (filesystem is not live), so only the state/config-derivable
358
+ * categories are evaluated — the live `agentxchain attention` command surfaces the full
359
+ * cross-category queue (escalations, pending intents).
360
+ *
361
+ * @param {object} artifact
362
+ * @returns {{ overall, items_count, blocking_count, categories } | null}
363
+ */
364
+ export function buildHumanAttentionSummary(artifact) {
365
+ if (!artifact) return null;
366
+ const state = artifact.state || null;
367
+ const config = artifact.config || null;
368
+
369
+ const items = [];
370
+ collect(items, () => evaluatePendingApprovalCategory(state, config));
371
+ collect(items, () => evaluateBudgetPolicyCategory(state));
372
+ collect(items, () => evaluateManualActionCategory(state));
373
+
374
+ const report = assembleReport(items);
375
+ return {
376
+ overall: report.overall,
377
+ items_count: report.items_count,
378
+ blocking_count: report.blocking_count,
379
+ categories: report.categories,
380
+ };
381
+ }
package/src/lib/report.js CHANGED
@@ -13,6 +13,9 @@ import { buildCoordinatorRepoStatusEntries } from './coordinator-repo-status-pre
13
13
  import { summarizeCoordinatorEvent } from './coordinator-event-narrative.js';
14
14
  import { extractGateActionDigest } from './gate-actions.js';
15
15
  import { buildRecoveryClassificationReport } from './recovery-classification.js';
16
+ import { buildShipStatusSummary } from './ship-status.js';
17
+ import { buildHumanAttentionSummary } from './human-attention.js';
18
+ import { buildRoleCharterSummary } from './role-charter.js';
16
19
 
17
20
  export const GOVERNANCE_REPORT_VERSION = '0.1';
18
21
 
@@ -1077,6 +1080,9 @@ function buildRunSubject(artifact) {
1077
1080
  continuity,
1078
1081
  workflow_kit_artifacts: extractWorkflowKitArtifacts(artifact),
1079
1082
  repo_decisions: artifact.summary?.repo_decisions || null,
1083
+ ship_status: buildShipStatusSummary(artifact),
1084
+ human_attention: buildHumanAttentionSummary(artifact),
1085
+ role_charters: buildRoleCharterSummary(artifact),
1080
1086
  },
1081
1087
  artifacts: {
1082
1088
  history_entries: artifact.summary?.history_entries || 0,
@@ -1425,6 +1431,17 @@ export function formatGovernanceReportText(report) {
1425
1431
  }
1426
1432
  }
1427
1433
 
1434
+ if (run.ship_status) {
1435
+ const ss = run.ship_status;
1436
+ const label = ss.overall === 'pass' ? 'YES' : ss.overall === 'fail' ? 'NO' : 'PENDING';
1437
+ lines.push('', `Ship Status: ${label} (${ss.dimensions_passed}/${ss.dimensions_total} dimensions pass)`);
1438
+ if (ss.blocking_reasons.length > 0) {
1439
+ for (const reason of ss.blocking_reasons) {
1440
+ lines.push(` - ${reason}`);
1441
+ }
1442
+ }
1443
+ }
1444
+
1428
1445
  if (run.turns && run.turns.length > 0) {
1429
1446
  const { items: boundedTurns, omitted: turnsOmitted } = boundedSlice(run.turns);
1430
1447
  lines.push('', 'Turn Timeline:');