@north-light/crouter 0.3.243 → 0.3.245

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.
@@ -1,7 +1,7 @@
1
- // The `crtr sys context admin` page: a rail, the document matrix, and the
2
- // dossier that edits one document's routing. The rail decides WHAT is being
3
- // planned and WHICH slice of it is shown, the matrix shows WHERE every document
4
- // lands, and the dossier is the only place a write starts.
1
+ // The `crtr sys context admin` page: a rail, the document list, and the dossier
2
+ // that edits one document's routing. The rail decides WHAT is being planned and
3
+ // WHICH slice of it is shown, the list states what each document does on the
4
+ // dialled event, and the dossier is the only place a write starts.
5
5
  import { Input, matchesKey, truncateToWidth, visibleWidth, } from '@earendil-works/pi-tui';
6
6
  import { formatGesture, matchesPiTuiInput, } from '../../../../core/keybindings/index.js';
7
7
  import { runTuiPage } from '../../../../core/tui/page-host.js';
@@ -10,7 +10,7 @@ import { RUNGS, SURFACE_EVENTS, SURFACE_RUNGS, } from '../../../../core/substrat
10
10
  import { profileNameFor } from '../../../../core/substrate/subject-fields.js';
11
11
  import { editBufferInEditor } from '../../../../clients/inbox/review/roundtrip.js';
12
12
  import { capOwnerFor, moveDoc, readBody, readOnlyReason, toggleFlag, writeBody, writeCap, writeGate, writeRoutingLine, writeShortForm, writeSurfaces, } from './actions.js';
13
- import { axesOf, cycleAxis, cycleFacet, facetsOf, renderRail, setTypedAxis, typedAxisValue, } from './rail-panel.js';
13
+ import { axesOf, cycleAxis, cycleFacet, facetsOf, payloadRowOf, railEnter, railRowKey, railRowsOf, renderRail, setTypedAxis, typedAxisValue, } from './rail-panel.js';
14
14
  import { ContextDetailPanel } from './detail-panel.js';
15
15
  import { ContextDocsPanel, buildRows } from './docs-panel.js';
16
16
  import { NO_FILTERS, costTotals } from './list-view.js';
@@ -20,8 +20,8 @@ const RAIL_MIN_TOTAL_WIDTH = 86;
20
20
  const THREE_COLUMN_MIN_WIDTH = 112;
21
21
  /** Columns the two dividers between the three zones spend. */
22
22
  const DIVIDER_WIDTH = 6;
23
- /** Below the floor a row cannot hold a name beside the matrix; above the
24
- * ceiling extra width stops buying legibility and the dossier wants it. */
23
+ /** Below the floor a row cannot hold a name beside its state; above the ceiling
24
+ * extra width stops buying legibility and the dossier wants it. */
25
25
  const DOCS_MIN_WIDTH = 48;
26
26
  const DOCS_MAX_WIDTH = 100;
27
27
  const DOCS_SHARE = 0.45;
@@ -78,14 +78,14 @@ export class ContextAdminShell {
78
78
  // planning
79
79
  /** Recompute the focused event's plan, refresh the panels from it, and let
80
80
  * the remaining events fill in behind it. */
81
- rebuild(selectedKey) {
81
+ rebuild() {
82
82
  this.plans.plan(this.snapshot.event);
83
83
  this.rows = buildRows(this.plans, this.snapshot.event);
84
- this.docs.setRows(this.rows, this.snapshot.event, selectedKey ?? this.docs.selected?.key);
84
+ this.docs.setRows(this.rows, this.snapshot.event);
85
85
  this.syncDetail();
86
86
  this.plans.warm(() => {
87
87
  this.rows = buildRows(this.plans, this.snapshot.event);
88
- this.docs.setRows(this.rows, this.snapshot.event, this.docs.selected?.key);
88
+ this.docs.setRows(this.rows, this.snapshot.event);
89
89
  this.syncDetail();
90
90
  this.tui.requestRender();
91
91
  });
@@ -101,13 +101,29 @@ export class ContextAdminShell {
101
101
  const row = this.docs.selected;
102
102
  const capOwner = row === undefined ? null : capOwnerFor(row.anchor, this.snapshot.profileId);
103
103
  const readOnly = row === undefined ? null : readOnlyReason(row.anchor, this.snapshot.profileId);
104
- this.detail.setContext(row, this.snapshot.event, capOwner, readOnly);
104
+ // The plan's own subject, not a re-derived one: what a failed gate is
105
+ // explained against must be exactly what it was judged against.
106
+ this.detail.setContext(row, this.snapshot.event, capOwner, readOnly, this.plans.plan(this.snapshot.event).subject);
105
107
  }
106
108
  setSnapshot(next) {
109
+ const held = this.railRows()[this.railIndex];
107
110
  this.snapshot = next;
108
111
  this.plans.setSnapshot(next);
109
112
  this.notice = undefined;
110
113
  this.rebuild();
114
+ this.holdRail(held);
115
+ }
116
+ /** Put the rail cursor back on the row it was on, since dialling a different
117
+ * event moves the payload row and every offset below it. The payload belongs
118
+ * to the dialled event, so when the new event reads none the cursor falls to
119
+ * that event's own row rather than to whatever slid into the offset. */
120
+ holdRail(held) {
121
+ if (held === undefined)
122
+ return;
123
+ const rows = this.railRows();
124
+ const key = railRowKey(held);
125
+ const at = rows.findIndex((row) => railRowKey(row) === key);
126
+ this.railIndex = Math.max(0, at >= 0 ? at : rows.findIndex((row) => row.kind === 'event' && row.event.dialled));
111
127
  }
112
128
  // input
113
129
  matches(id, data) {
@@ -158,7 +174,10 @@ export class ContextAdminShell {
158
174
  this.setSnapshot(cycleAxis(this.snapshot, 'event', step));
159
175
  return;
160
176
  }
161
- if (matchesKey(data, 'q') || this.matches('crtr.setup.cancel', data)) {
177
+ // `crtr.setup.cancel` also claims ctrl+d, so the read face's half-pane
178
+ // gesture has to outrank it or paging a body would close the page instead.
179
+ const pagesBody = this.zone === 'detail' && !this.detail.editing && matchesKey(data, 'ctrl+d');
180
+ if (!pagesBody && (matchesKey(data, 'q') || this.matches('crtr.setup.cancel', data))) {
162
181
  this.close();
163
182
  return;
164
183
  }
@@ -169,31 +188,47 @@ export class ContextAdminShell {
169
188
  else
170
189
  this.handleDetail(data);
171
190
  }
172
- /** The rail's rows are the snapshot axes followed by the filter facets, so
173
- * one cursor and one gesture drive both sections. */
191
+ /** Every surface event with what it delivers, which is what the rail states
192
+ * instead of the list drawing a cell per event on every row. An event whose
193
+ * plan has not warmed yet has no count to state. */
194
+ eventRows() {
195
+ return SURFACE_EVENTS.map((event) => {
196
+ const plan = this.plans.peek(event);
197
+ return {
198
+ event,
199
+ delivers: plan === undefined ? undefined : plan.docs.filter((record) => record.finalRung !== 'none').length,
200
+ dialled: event === this.snapshot.event,
201
+ };
202
+ });
203
+ }
204
+ /** The rail's rows: the events, then the snapshot axes, then the filter
205
+ * facets, so one cursor and one gesture drive all three sections. */
206
+ railRows() {
207
+ return railRowsOf(this.eventRows(), payloadRowOf(this.snapshot), axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none'), facetsOf(this.filters));
208
+ }
174
209
  handleRail(data) {
175
- const axes = axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none');
176
- const facets = facetsOf(this.filters);
177
- const total = axes.length + facets.length;
178
- this.railIndex = Math.max(0, Math.min(this.railIndex, total - 1));
179
- const axis = axes[this.railIndex];
180
- const facet = facets[this.railIndex - axes.length];
210
+ const rows = this.railRows();
211
+ this.railIndex = Math.max(0, Math.min(this.railIndex, rows.length - 1));
212
+ const row = rows[this.railIndex];
181
213
  const change = (delta) => {
182
- if (axis !== undefined) {
183
- if (axis.typed)
184
- this.openAxisField(axis.id, axis.label);
214
+ if (row === undefined)
215
+ return;
216
+ if (row.kind === 'event')
217
+ this.setSnapshot({ ...this.snapshot, event: row.event.event });
218
+ else if (row.kind === 'axis') {
219
+ if (row.axis.typed)
220
+ this.openAxisField(row.axis.id, row.axis.label);
185
221
  else
186
- this.setSnapshot(cycleAxis(this.snapshot, axis.id, delta));
187
- }
188
- else if (facet !== undefined) {
189
- if (facet.typed)
190
- this.openSearch();
191
- else
192
- this.setFilters(cycleFacet(this.filters, facet.id, delta));
222
+ this.setSnapshot(cycleAxis(this.snapshot, row.axis.id, delta));
193
223
  }
224
+ else if (row.facet.typed)
225
+ this.openSearch();
226
+ else
227
+ this.setFilters(cycleFacet(this.filters, row.facet.id, delta));
194
228
  };
229
+ const onEnter = railEnter(row);
195
230
  if (this.isDown(data)) {
196
- this.railIndex = Math.min(total - 1, this.railIndex + 1);
231
+ this.railIndex = Math.min(rows.length - 1, this.railIndex + 1);
197
232
  }
198
233
  else if (this.isUp(data)) {
199
234
  this.railIndex = Math.max(0, this.railIndex - 1);
@@ -202,7 +237,7 @@ export class ContextAdminShell {
202
237
  change(1);
203
238
  }
204
239
  else if (this.isDescend(data)) {
205
- if (matchesKey(data, 'enter') && (axis?.typed === true || facet?.typed === true))
240
+ if (matchesKey(data, 'enter') && onEnter !== 'documents')
206
241
  change(1);
207
242
  else
208
243
  this.zone = 'docs';
@@ -228,9 +263,8 @@ export class ContextAdminShell {
228
263
  this.syncDetail();
229
264
  }
230
265
  else if (this.isDescend(data)) {
231
- // A directory that is also a document keeps both gestures: space opens it,
232
- // and descending goes to its dossier like any other document.
233
- if (this.docs.onGroup && !this.docs.onDocument) {
266
+ // One gesture per row: a header expands, a document opens its dossier.
267
+ if (this.docs.onGroup) {
234
268
  this.docs.toggleGroup();
235
269
  this.syncDetail();
236
270
  }
@@ -268,35 +302,101 @@ export class ContextAdminShell {
268
302
  this.tui.requestRender();
269
303
  }
270
304
  handleDetail(data) {
305
+ if (this.detail.editing)
306
+ this.handleEdit(data);
307
+ else
308
+ this.handleRead(data);
309
+ }
310
+ /** The resting face: reading, and the two gestures that leave it. */
311
+ handleRead(data) {
271
312
  if (this.isDown(data))
272
313
  this.detail.move(1);
273
314
  else if (this.isUp(data))
274
315
  this.detail.move(-1);
275
- else if (this.isAscend(data) || matchesKey(data, 'escape'))
276
- this.zone = 'docs';
277
- else if (matchesKey(data, 'enter') || matchesKey(data, 'space') || this.matches('crtr.setup.toggle', data)) {
278
- this.activate();
279
- }
280
- else if (matchesKey(data, 'b'))
281
- this.editBody();
282
- else if (matchesKey(data, 'x'))
283
- this.removeEntry();
284
- else if (matchesKey(data, 'e'))
285
- this.composeEntry();
286
- else if (matchesKey(data, 'g'))
287
- this.editGate();
288
- else if (matchesKey(data, 'u'))
289
- this.flip('unlisted');
290
- else if (matchesKey(data, 's'))
291
- this.flip('slash');
292
- else if (matchesKey(data, 'm'))
293
- this.editCap();
294
- else if (matchesKey(data, 'r'))
295
- this.editMove();
296
- else if (matchesKey(data, 'w'))
297
- this.editRouting();
298
- else if (matchesKey(data, 'f'))
299
- this.editShortForm();
316
+ else if (matchesKey(data, 'ctrl+d'))
317
+ this.detail.page(1);
318
+ else if (matchesKey(data, 'ctrl+u'))
319
+ this.detail.page(-1);
320
+ else if (data === 'G' || matchesKey(data, 'shift+g'))
321
+ this.detail.jump('bottom');
322
+ else if (data === 'g' || matchesKey(data, 'g'))
323
+ this.detail.jump('top');
324
+ else if (data === 'e' || matchesKey(data, 'e'))
325
+ this.detail.edit();
326
+ else if (this.isDescend(data))
327
+ this.detail.openBody();
328
+ else if (this.isAscend(data) || matchesKey(data, 'escape')) {
329
+ if (!this.detail.back())
330
+ this.zone = 'docs';
331
+ }
332
+ }
333
+ /** The field list. Every write gesture is resolved before it is started, so a
334
+ * read-only document refuses at the keypress rather than after a modal. */
335
+ handleEdit(data) {
336
+ if (this.isDown(data)) {
337
+ this.detail.move(1);
338
+ return;
339
+ }
340
+ if (this.isUp(data)) {
341
+ this.detail.move(-1);
342
+ return;
343
+ }
344
+ if (this.isAscend(data) || matchesKey(data, 'escape')) {
345
+ this.detail.back();
346
+ return;
347
+ }
348
+ const gesture = this.writeGesture(data);
349
+ if (gesture === undefined)
350
+ return;
351
+ if (gesture.document && this.refusesWrite())
352
+ return;
353
+ gesture.start();
354
+ }
355
+ /** The write one edit-face key starts, or undefined when the key writes
356
+ * nothing. `document` marks the writes a read-only document refuses — the
357
+ * profile ceiling is not one of them; it lives in the profile manifest. */
358
+ writeGesture(data) {
359
+ const doc = (start) => ({ start, document: true });
360
+ if (matchesKey(data, 'enter') || matchesKey(data, 'space') || this.matches('crtr.setup.toggle', data)) {
361
+ const row = this.detail.selected;
362
+ if (row === undefined)
363
+ return undefined;
364
+ return row.kind === 'cap' ? { start: () => this.editCap(), document: false } : doc(() => this.activate());
365
+ }
366
+ if (matchesKey(data, 'm'))
367
+ return { start: () => this.editCap(), document: false };
368
+ if (matchesKey(data, 'b'))
369
+ return doc(() => this.editBody());
370
+ if (matchesKey(data, 'x'))
371
+ return doc(() => this.removeEntry());
372
+ if (matchesKey(data, 'e'))
373
+ return doc(() => this.composeEntry());
374
+ if (matchesKey(data, 'g'))
375
+ return doc(() => this.editGate());
376
+ if (matchesKey(data, 'u'))
377
+ return doc(() => this.flip('unlisted'));
378
+ if (matchesKey(data, 's'))
379
+ return doc(() => this.flip('slash'));
380
+ if (matchesKey(data, 'r'))
381
+ return doc(() => this.editMove());
382
+ if (matchesKey(data, 'w'))
383
+ return doc(() => this.editRouting());
384
+ if (matchesKey(data, 'f'))
385
+ return doc(() => this.editShortForm());
386
+ return undefined;
387
+ }
388
+ /** The refusal a read-only document owes the write just attempted, carried by
389
+ * the same footer channel every other write outcome uses. */
390
+ refusesWrite() {
391
+ const record = this.record;
392
+ if (record === undefined)
393
+ return false;
394
+ const refusal = readOnlyReason(record, this.snapshot.profileId);
395
+ if (refusal === null)
396
+ return false;
397
+ this.notice = { text: refusal, ok: false };
398
+ this.tui.requestRender();
399
+ return true;
300
400
  }
301
401
  /** What the focused dossier row does when opened — the same write its letter
302
402
  * key starts, so a user who never learns the letters can still edit. */
@@ -472,13 +572,6 @@ export class ContextAdminShell {
472
572
  const record = this.record;
473
573
  if (record === undefined)
474
574
  return;
475
- // Checked before the terminal leaves for $EDITOR, so a read-only record
476
- // refuses in place rather than after a whole editing session.
477
- const refusal = readOnlyReason(record, this.snapshot.profileId);
478
- if (refusal !== null) {
479
- this.notice = { text: refusal, ok: false };
480
- return;
481
- }
482
575
  let before;
483
576
  try {
484
577
  before = readBody(record);
@@ -633,7 +726,7 @@ export class ContextAdminShell {
633
726
  return out.slice(0, height);
634
727
  }
635
728
  // rendering
636
- footer(width) {
729
+ footer(width, railRow) {
637
730
  if (this.busy !== undefined)
638
731
  return theme.fg('warning', this.busy);
639
732
  if (this.modal?.kind === 'choice')
@@ -648,33 +741,33 @@ export class ContextAdminShell {
648
741
  return theme.fg(this.notice.ok ? 'success' : 'warning', truncateToWidth(this.notice.text, width));
649
742
  const move = `${this.label('crtr.setup.list-down', 'j')}/${this.label('crtr.setup.list-up', 'k')}`;
650
743
  const left = this.zone === 'rail'
651
- ? `${move} row · space change · enter documents`
744
+ ? `${move} row · ${railGesture(railRow)}`
652
745
  : this.zone === 'docs'
653
746
  ? `${move} row · ${this.docsGesture()} · / search`
654
- : `${move} field · enter edit · e add · x remove · g gate · b body · u unlisted · s slash · m cap · r rename`;
747
+ : this.detail.editing
748
+ ? `${move} field · enter edit · e add · x remove · g gate · b body · u unlisted · s slash · m cap · r rename · esc read`
749
+ : this.detail.bodyOpen
750
+ ? `${move} scroll · ctrl-d/u page · g/G ends · esc collapse`
751
+ : `${move} scroll · enter body · e edit · esc list`;
655
752
  const right = `${this.label('crtr.setup.tab-next', 'tab')} event · ${this.label('crtr.setup.cancel', 'q')} close`;
656
753
  const gap = width - visibleWidth(left) - visibleWidth(right);
657
754
  return gap >= 2
658
755
  ? `${theme.fg('dim', left)}${' '.repeat(gap)}${theme.fg('dim', right)}`
659
756
  : theme.fg('dim', truncateToWidth(`${left} · ${this.label('crtr.setup.cancel', 'q')} close`, width));
660
757
  }
661
- /** What the focused row does when opened. A directory answers to space, a
662
- * document to enter, and a directory that is also a document to both. */
758
+ /** What the focused row does when opened. A directory expands; a document
759
+ * opens its dossier. */
663
760
  docsGesture() {
664
- if (!this.docs.onGroup)
665
- return 'enter dossier';
666
- return this.docs.onDocument ? 'space open · enter dossier' : 'space/enter open';
761
+ return this.docs.onGroup ? 'space/enter open' : 'enter dossier';
667
762
  }
668
763
  render(width) {
669
764
  const height = this.terminalRows();
670
765
  const bodyHeight = Math.max(1, height - PAGE_CHROME_ROWS);
671
766
  const railVisible = width >= RAIL_MIN_TOTAL_WIDTH;
672
- const axes = axesOf(this.snapshot, profileNameFor(this.snapshot.profileId) ?? 'none');
673
- const facets = facetsOf(this.filters);
674
- this.railIndex = Math.max(0, Math.min(this.railIndex, axes.length + facets.length - 1));
767
+ const rows = this.railRows();
768
+ this.railIndex = Math.max(0, Math.min(this.railIndex, rows.length - 1));
675
769
  const rail = (railWidth) => renderRail({
676
- axes,
677
- facets,
770
+ rows,
678
771
  index: this.railIndex,
679
772
  width: railWidth,
680
773
  height: bodyHeight,
@@ -686,7 +779,7 @@ export class ContextAdminShell {
686
779
  if (width >= THREE_COLUMN_MIN_WIDTH) {
687
780
  const docsWidth = docsWidthFor(width);
688
781
  const detailWidth = Math.max(1, width - RAIL_WIDTH - docsWidth - DIVIDER_WIDTH);
689
- const middle = columns(this.docs.render(docsWidth, bodyHeight, this.zone === 'docs', this.plans), docsWidth, this.modal === undefined
782
+ const middle = columns(this.docs.render(docsWidth, bodyHeight, this.zone === 'docs'), docsWidth, this.modal === undefined
690
783
  ? this.detail.render(detailWidth, bodyHeight, this.zone === 'detail')
691
784
  : this.renderModal(detailWidth, bodyHeight), detailWidth);
692
785
  body = columns(rail(RAIL_WIDTH), RAIL_WIDTH, middle, width - RAIL_WIDTH - 3);
@@ -697,7 +790,7 @@ export class ContextAdminShell {
697
790
  ? this.renderModal(panelWidth, bodyHeight)
698
791
  : this.zone === 'detail'
699
792
  ? this.detail.render(panelWidth, bodyHeight, true)
700
- : this.docs.render(panelWidth, bodyHeight, this.zone === 'docs', this.plans);
793
+ : this.docs.render(panelWidth, bodyHeight, this.zone === 'docs');
701
794
  body = columns(rail(RAIL_WIDTH), RAIL_WIDTH, panel, panelWidth);
702
795
  }
703
796
  else if (this.modal !== undefined) {
@@ -710,7 +803,7 @@ export class ContextAdminShell {
710
803
  body = this.detail.render(width, bodyHeight, true);
711
804
  }
712
805
  else {
713
- body = this.docs.render(width, bodyHeight, true, this.plans);
806
+ body = this.docs.render(width, bodyHeight, true);
714
807
  }
715
808
  const pending = SURFACE_EVENTS.filter((event) => this.plans.peek(event) === undefined).length;
716
809
  const meta = [
@@ -721,7 +814,14 @@ export class ContextAdminShell {
721
814
  ]
722
815
  .filter((part) => part !== '')
723
816
  .join(' · ');
724
- return pageFrame({ width, height, title: 'crouter context', meta, body, footer: this.footer(width) });
817
+ return pageFrame({
818
+ width,
819
+ height,
820
+ title: 'crouter context',
821
+ meta,
822
+ body,
823
+ footer: this.footer(width, rows[this.railIndex]),
824
+ });
725
825
  }
726
826
  /** What the delivering documents spend on this event. An estimate, and a
727
827
  * floor on the rendered prompt: boot wraps these documents in its own intro
@@ -737,6 +837,19 @@ export class ContextAdminShell {
737
837
  return `docs load ${formatCost(total)} — ${split}`;
738
838
  }
739
839
  }
840
+ /** What the focused rail row does, which differs by row: an event dials, a
841
+ * typed row opens its field, and an enum cycles in place with enter left free
842
+ * to move zones. */
843
+ function railGesture(row) {
844
+ switch (railEnter(row)) {
845
+ case 'dial':
846
+ return 'space/enter dial · l documents';
847
+ case 'edit':
848
+ return 'space/enter edit · l documents';
849
+ default:
850
+ return 'space change · enter documents';
851
+ }
852
+ }
740
853
  /** Every field here is an edit-or-accept, never a blank to retype, so the
741
854
  * cursor lands at the end (`\x05`) — `setValue` alone leaves it at 0, where
742
855
  * typing prepends and backspace does nothing. */
@@ -7,6 +7,7 @@ import { realpathOrSelf } from '../../../core/fs-utils.js';
7
7
  import { resolveMemoryDocForTarget } from '../../../core/memory-resolver.js';
8
8
  import { InputError } from '../../../core/io.js';
9
9
  import { planDelivery } from '../../../core/substrate/plan.js';
10
+ import { explainRecordGate } from '../../../core/substrate/gate-explain.js';
10
11
  import { parseSubstrateDoc, SURFACE_EVENTS } from '../../../core/substrate/schema.js';
11
12
  import { buildSnapshot, cell, delivers, describeSubject, entryText, exclusionReason, gateText, optionalString, payloadFor, subjectSummary, } from './resolve.js';
12
13
  export const docLeaf = defineLeaf({
@@ -58,7 +59,7 @@ export const docLeaf = defineLeaf({
58
59
  const recordPathCache = new Map();
59
60
  const rows = events.map((event) => {
60
61
  const plan = planDelivery(snapshot.subject, snapshot.target, event, payloadFor(event, flags, snapshot.target));
61
- return eventRow(event, findRecord(plan.docs, targetPath, recordPathCache));
62
+ return eventRow(event, findRecord(plan.docs, targetPath, recordPathCache), plan.subject);
62
63
  });
63
64
  return {
64
65
  name: doc.name,
@@ -111,7 +112,7 @@ function surfaceRow(entry) {
111
112
  at: entry.at,
112
113
  };
113
114
  }
114
- function eventRow(event, record) {
115
+ function eventRow(event, record, subject) {
115
116
  if (record === null) {
116
117
  return {
117
118
  event,
@@ -133,12 +134,12 @@ function eventRow(event, record) {
133
134
  authored_rung: record.authoredRung,
134
135
  capped_rung: record.cappedRung,
135
136
  final_rung: record.finalRung,
136
- doc_gate: gateText(record.docGate),
137
- entry_gate: record.entryGate === null ? null : gateText(record.entryGate),
137
+ doc_gate: gateText(record.docGate, explainRecordGate(record, 'doc', subject)),
138
+ entry_gate: record.entryGate === null ? null : gateText(record.entryGate, explainRecordGate(record, 'entry', subject)),
138
139
  matched_entry: entryText(record.matchedEntry),
139
140
  winner: record.winner,
140
141
  shadowed_by: record.shadowedBy === null ? null : `${record.shadowedBy.scope}:${record.shadowedBy.path}`,
141
- excluded: delivers(record) ? null : exclusionReason(record),
142
+ excluded: delivers(record) ? null : exclusionReason(record, subject),
142
143
  };
143
144
  }
144
145
  function corpusNote(event) {
@@ -1,5 +1,6 @@
1
1
  import { type MemoryTarget } from '../../../core/memory-resolver.js';
2
2
  import { type DeliveryPayload, type DeliveryRecord, type GateOutcome } from '../../../core/substrate/plan.js';
3
+ import { type GateClause } from '../../../core/substrate/gate-explain.js';
3
4
  import { type Rung, type SurfaceEntry, type SurfaceEvent } from '../../../core/substrate/schema.js';
4
5
  import { type NodeConfigSubject } from '../../../core/substrate/subject-fields.js';
5
6
  export declare const resolveLeaf: import("../../../core/command.js").LeafDef;
@@ -45,11 +46,14 @@ export interface DocRow {
45
46
  * above the no-delivery floor — boot keeps rung-`none` winners in the plan so
46
47
  * the catalog can still count them. */
47
48
  export declare function delivers(record: DeliveryRecord): boolean;
48
- export declare function docRow(record: DeliveryRecord, includeAll: boolean): DocRow;
49
- export declare function gateText(gate: GateOutcome): string;
49
+ export declare function docRow(record: DeliveryRecord, includeAll: boolean, subject: NodeConfigSubject | null): DocRow;
50
+ /** A gate verdict as one cell: `pass`, or `fail:` plus the clauses that failed
51
+ * — the planner's generic sentence only when the explainer cannot decompose
52
+ * the predicate. */
53
+ export declare function gateText(gate: GateOutcome, clauses: readonly GateClause[]): string;
50
54
  export declare function entryText(entry: SurfaceEntry | null): string | null;
51
55
  /** Why a considered document delivers nothing, in the order the planner applies
52
56
  * its checks — the first failing one is the answer an engineer acts on. */
53
- export declare function exclusionReason(record: DeliveryRecord): string;
57
+ export declare function exclusionReason(record: DeliveryRecord, subject: NodeConfigSubject | null): string;
54
58
  export declare function describeSubject(subject: Record<string, unknown>): string;
55
59
  export declare function cell(v: unknown): string;
@@ -16,6 +16,7 @@ import { envNodeId, envProfileId } from '../../../shared/env.js';
16
16
  import { realpathOrSelf } from '../../../core/fs-utils.js';
17
17
  import { ambientMemoryTarget, resolveMemoryDocForTarget, } from '../../../core/memory-resolver.js';
18
18
  import { planDelivery, } from '../../../core/substrate/plan.js';
19
+ import { explainRecordGate, formatClause } from '../../../core/substrate/gate-explain.js';
19
20
  import { emptyContextExposureState, exposureTarget } from '../../../core/substrate/injected-store.js';
20
21
  import { renderPreferencesForSubject, renderKnowledgeForSubject } from '../../../core/substrate/render.js';
21
22
  import { memoryReadDocBlocks, renderOnCommandDocsForSubject, renderOnReadDocsForSubject, renderPreCommandDocsForSubject, renderWorkspaceOpenDocsForSubject, } from '../../../core/substrate/on-read.js';
@@ -193,7 +194,7 @@ function planSection(snapshot, event, payload, granularity, includeAll) {
193
194
  const plan = planDelivery(snapshot.subject, snapshot.target, event, payload);
194
195
  if (granularity === 'summary')
195
196
  return { event, counts: countsOf(plan) };
196
- const docs = plan.docs.filter((r) => includeAll || delivers(r)).map((r) => docRow(r, includeAll));
197
+ const docs = plan.docs.filter((r) => includeAll || delivers(r)).map((r) => docRow(r, includeAll, plan.subject));
197
198
  return { event, docs };
198
199
  }
199
200
  /** A document delivers when it won its canonical name AND resolved to a rung
@@ -202,7 +203,7 @@ function planSection(snapshot, event, payload, granularity, includeAll) {
202
203
  export function delivers(record) {
203
204
  return record.winner && rungAtLeast(record.finalRung, 'name');
204
205
  }
205
- export function docRow(record, includeAll) {
206
+ export function docRow(record, includeAll, subject) {
206
207
  const row = {
207
208
  name: record.name,
208
209
  kind: record.kind,
@@ -214,16 +215,22 @@ export function docRow(record, includeAll) {
214
215
  final_rung: record.finalRung,
215
216
  winner: record.winner,
216
217
  shadowed_by: record.shadowedBy === null ? null : `${record.shadowedBy.scope}:${record.shadowedBy.path}`,
217
- doc_gate: gateText(record.docGate),
218
- entry_gate: record.entryGate === null ? null : gateText(record.entryGate),
218
+ doc_gate: gateText(record.docGate, explainRecordGate(record, 'doc', subject)),
219
+ entry_gate: record.entryGate === null ? null : gateText(record.entryGate, explainRecordGate(record, 'entry', subject)),
219
220
  matched_entry: entryText(record.matchedEntry),
220
221
  };
221
222
  if (includeAll && !delivers(record))
222
- row.excluded = exclusionReason(record);
223
+ row.excluded = exclusionReason(record, subject);
223
224
  return row;
224
225
  }
225
- export function gateText(gate) {
226
- return gate.pass ? 'pass' : `fail: ${gate.reason}`;
226
+ /** A gate verdict as one cell: `pass`, or `fail:` plus the clauses that failed
227
+ * — the planner's generic sentence only when the explainer cannot decompose
228
+ * the predicate. */
229
+ export function gateText(gate, clauses) {
230
+ return gate.pass ? 'pass' : `fail: ${gateReason(gate.reason, clauses)}`;
231
+ }
232
+ function gateReason(fallback, clauses) {
233
+ return clauses.length === 0 ? fallback : clauses.map(formatClause).join('; ');
227
234
  }
228
235
  export function entryText(entry) {
229
236
  if (entry === null)
@@ -239,16 +246,17 @@ export function entryText(entry) {
239
246
  }
240
247
  /** Why a considered document delivers nothing, in the order the planner applies
241
248
  * its checks — the first failing one is the answer an engineer acts on. */
242
- export function exclusionReason(record) {
243
- if (!record.docGate.pass)
244
- return `document gate: ${record.docGate.reason}`;
249
+ export function exclusionReason(record, subject) {
250
+ if (!record.docGate.pass) {
251
+ return `document gate: ${gateReason(record.docGate.reason, explainRecordGate(record, 'doc', subject))}`;
252
+ }
245
253
  if (record.shadowedBy !== null)
246
254
  return `shadowed by ${record.shadowedBy.scope}:${record.shadowedBy.path}`;
247
255
  if (record.authoredEntries.length === 0)
248
256
  return 'no surface entry for this event';
249
257
  if (record.matchedEntry === null) {
250
258
  return record.entryGate !== null && !record.entryGate.pass
251
- ? `entry gate: ${record.entryGate.reason}`
259
+ ? `entry gate: ${gateReason(record.entryGate.reason, explainRecordGate(record, 'entry', subject))}`
252
260
  : 'no authored entry matched this event\u2019s payload';
253
261
  }
254
262
  if (record.cappedRung === 'none') {
@@ -18,7 +18,8 @@ import { PROFILE_PROJECT_MEMORY_VALUES } from '../../../api/dto/profiles.js';
18
18
  import { cliClient } from '../../api-client.js';
19
19
  import { clearDefaultProfile, defaultProfileDirs, getDefaultProfileId, setDefaultProfileId, } from '../../../core/profiles/default-binding.js';
20
20
  import { ROOT_PROFILE_ID, addProfileProject, createProfile, listProfiles, pauseProfile, removeProfileProject, renameProfile, resumeProfile, } from '../../../core/profiles/manifest.js';
21
- import { profileCoversCwd, tildify } from '../../../core/profiles/select.js';
21
+ import { tildify } from '../../../core/fs-utils.js';
22
+ import { profileCoversCwd } from '../../../core/profiles/select.js';
22
23
  import { padAnsi, theme, visibleRange, wrapText, } from '../../../core/tui/panel.js';
23
24
  /** Coarse "used ..." for telling otherwise-alike profiles apart. */
24
25
  function relativeUsed(iso) {
@@ -3,6 +3,9 @@ export declare function realpathOrSelf(p: string): string;
3
3
  /** Expand a leading `~` against `homeDir` (pi's own convention). Other forms
4
4
  * — absolute, relative, `~user` — pass through as pi leaves them. */
5
5
  export declare function expandTilde(p: string, homeDir?: string): string;
6
+ /** Collapse the home prefix to `~` so a path reads short. The inverse of
7
+ * `expandTilde`, and the ONE collapse every surface prints paths through. */
8
+ export declare function tildify(p: string, homeDir?: string): string;
6
9
  export declare function ensureDir(dir: string): void;
7
10
  export declare function writeJson(path: string, data: unknown): void;
8
11
  export interface AtomicWriteOptions {
@@ -1,5 +1,5 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, realpathSync, } from 'node:fs';
2
- import { dirname, join, relative } from 'node:path';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
3
  import { homedir, platform } from 'node:os';
4
4
  /** `realpathSync`, tolerant of a path that doesn't exist or can't be resolved. */
5
5
  export function realpathOrSelf(p) {
@@ -19,6 +19,15 @@ export function expandTilde(p, homeDir = homedir()) {
19
19
  return join(homeDir, p.slice(2));
20
20
  return p;
21
21
  }
22
+ /** Collapse the home prefix to `~` so a path reads short. The inverse of
23
+ * `expandTilde`, and the ONE collapse every surface prints paths through. */
24
+ export function tildify(p, homeDir = homedir()) {
25
+ if (p === homeDir)
26
+ return '~';
27
+ if (p.startsWith(homeDir + sep))
28
+ return '~' + p.slice(homeDir.length);
29
+ return p;
30
+ }
22
31
  export function ensureDir(dir) {
23
32
  mkdirSync(dir, { recursive: true });
24
33
  }
@@ -11,9 +11,6 @@ export declare function resolveProfileSearchAction(bindings: BindingResolution<B
11
11
  export declare function profileCoversCwd(entry: ProfileEntry, cwd: string): boolean;
12
12
  /** Resolve the headless selector against existing state without changing it. */
13
13
  export declare function selectProfileForCwdReadOnly(cwd: string): string;
14
- /** Collapse the home prefix to `~` so project paths read short. Exported so the
15
- * Profiles settings panel prints a project dir exactly as this menu does. */
16
- export declare function tildify(p: string): string;
17
14
  /** Select the profile a node about to boot at `cwd` should run under.
18
15
  *
19
16
  * 1. `explicitProfile` present → resolve it as a user-typed operand (exact id,