@opencode-cockpit/trust 0.0.0-stage → 0.8.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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/dist/cli/preview.js +251 -0
  4. package/dist/core/adapt/seen.js +24 -0
  5. package/dist/core/adapt/v1.js +109 -0
  6. package/dist/core/adapt/v2.js +109 -0
  7. package/dist/core/config.js +93 -0
  8. package/dist/core/danger.js +366 -0
  9. package/dist/core/engine.js +245 -0
  10. package/dist/core/family.js +512 -0
  11. package/dist/core/history.js +169 -0
  12. package/dist/core/index.js +16 -0
  13. package/dist/core/keys.js +149 -0
  14. package/dist/core/ledger.js +202 -0
  15. package/dist/core/paths.js +38 -0
  16. package/dist/core/policy.js +128 -0
  17. package/dist/core/rules.js +152 -0
  18. package/dist/core/sample.js +311 -0
  19. package/dist/core/shell.js +253 -0
  20. package/dist/core/signature.js +38 -0
  21. package/dist/core/view/actions.js +321 -0
  22. package/dist/core/view/activity.js +557 -0
  23. package/dist/core/view/explorer.js +970 -0
  24. package/dist/core/view/model.js +193 -0
  25. package/dist/core/view/parts.js +290 -0
  26. package/dist/core/view/rows.js +165 -0
  27. package/dist/core/view/sidebar.js +170 -0
  28. package/dist/tui/index.js +920 -0
  29. package/dist/tui/journal.js +80 -0
  30. package/dist/tui/render.js +85 -0
  31. package/dist/tui/source.js +112 -0
  32. package/dist/tui/view/dialog.js +43 -0
  33. package/dist/tui/view/rows.js +69 -0
  34. package/package.json +61 -4
  35. package/tui.js +6 -0
  36. package/types/cli/preview.d.ts +22 -0
  37. package/types/core/adapt/seen.d.ts +46 -0
  38. package/types/core/adapt/v1.d.ts +18 -0
  39. package/types/core/adapt/v2.d.ts +20 -0
  40. package/types/core/config.d.ts +57 -0
  41. package/types/core/danger.d.ts +50 -0
  42. package/types/core/engine.d.ts +117 -0
  43. package/types/core/family.d.ts +97 -0
  44. package/types/core/history.d.ts +81 -0
  45. package/types/core/index.d.ts +16 -0
  46. package/types/core/keys.d.ts +68 -0
  47. package/types/core/ledger.d.ts +158 -0
  48. package/types/core/paths.d.ts +24 -0
  49. package/types/core/policy.d.ts +52 -0
  50. package/types/core/rules.d.ts +55 -0
  51. package/types/core/sample.d.ts +22 -0
  52. package/types/core/shell.d.ts +40 -0
  53. package/types/core/signature.d.ts +18 -0
  54. package/types/core/view/actions.d.ts +76 -0
  55. package/types/core/view/activity.d.ts +101 -0
  56. package/types/core/view/explorer.d.ts +154 -0
  57. package/types/core/view/model.d.ts +106 -0
  58. package/types/core/view/parts.d.ts +76 -0
  59. package/types/core/view/rows.d.ts +60 -0
  60. package/types/core/view/sidebar.d.ts +62 -0
  61. package/types/tui/index.d.ts +15 -0
  62. package/types/tui/journal.d.ts +19 -0
  63. package/types/tui/render.d.ts +14 -0
  64. package/types/tui/source.d.ts +35 -0
  65. package/types/tui/view/dialog.d.ts +24 -0
  66. package/types/tui/view/rows.d.ts +19 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Trust in one window: the requests it is watching, the replies it sent, and the events each turn
3
+ * of that produces. No clock, no file, no OpenCode — times are passed in, events are handed back to
4
+ * be written, and the file's events (this window's and every other's) come back in through `load`.
5
+ *
6
+ * That last loop is deliberate. An event this window writes is applied when it is read back from the
7
+ * file, in the file's order, exactly like another window's: the state is one fold over one sequence,
8
+ * never a fold of "ours" patched with "theirs".
9
+ *
10
+ * **Who answered.** `permission.replied` does not say (docs/opencode/permissions.md), so:
11
+ * our own replies are known by request id; a reply under `PERSON_MS` after the request is not a
12
+ * person — OpenCode's `--auto` answers in 15–22ms, a person in a second or more — and is not counted;
13
+ * a request asked before this window was watching has no time to measure, and is not counted either.
14
+ * Every doubt under-counts a person, never over-counts one. A reject always counts: resetting is the
15
+ * safe direction.
16
+ */
17
+
18
+ import { emptyHistory, note } from "./history.js";
19
+ import { apply, emptyState } from "./ledger.js";
20
+ import { decide } from "./policy.js";
21
+ /** Faster than this, nobody read the prompt. Measured: auto mode 15–22ms, a person 1.2s and up. */
22
+ export const PERSON_MS = 300;
23
+
24
+ /** How long a request must have been pending before the host's list may say it is gone. */
25
+ export const RECONCILE_AFTER_MS = 10_000;
26
+ /** Whose reply this was, as far as can be known — and so what it counts for. */
27
+ export function credit(input) {
28
+ if (input.ours) return {
29
+ kind: "ignored",
30
+ why: "answered by Trust"
31
+ };
32
+ if (input.reply === "reject") return {
33
+ kind: "rejected"
34
+ };
35
+ if (input.askedAt === undefined) return {
36
+ kind: "ignored",
37
+ why: "asked before Trust was watching"
38
+ };
39
+ const took = input.repliedAt - input.askedAt;
40
+ if (took < PERSON_MS) return {
41
+ kind: "ignored",
42
+ why: `answered in ${took}ms — not a person`
43
+ };
44
+ return input.reply === "always" && input.always?.length ? {
45
+ kind: "approved",
46
+ always: input.always
47
+ } : {
48
+ kind: "approved"
49
+ };
50
+ }
51
+
52
+ /** One answer Trust gave, for the sidebar's list. */
53
+
54
+ export function createEngine(initial) {
55
+ let options = initial;
56
+ const foldOptions = () => ({
57
+ expireMs: options.expireDays * 86_400_000
58
+ });
59
+ let events = [];
60
+ let state = emptyState();
61
+ let history = emptyHistory();
62
+ /** One pass: each event noted in the history before the state settles it, so both count it once. */
63
+ const fold = (into, more) => {
64
+ for (const event of more) {
65
+ note(history, event, into.settled);
66
+ apply(into, event, foldOptions());
67
+ }
68
+ return into;
69
+ };
70
+ const pending = new Map();
71
+ /** Requests this window decided to answer, until the answer is recorded or fails. */
72
+ const ours = new Map();
73
+ /**
74
+ * Every request this window answered, kept after the answer is recorded: the reply our answer causes
75
+ * can arrive before or after our call returns, and either way it is ours.
76
+ */
77
+ const mine = new Set();
78
+ const answered = [];
79
+ let total = 0;
80
+ const about = entry => ({
81
+ request: entry.request.id,
82
+ session: entry.request.sessionID,
83
+ ...(entry.request.call ? {
84
+ call: entry.request.call
85
+ } : {}),
86
+ permission: entry.request.permission,
87
+ agent: entry.agent,
88
+ items: entry.judgement.items
89
+ });
90
+ return {
91
+ get state() {
92
+ return state;
93
+ },
94
+ get history() {
95
+ return history;
96
+ },
97
+ load(more, {
98
+ reset = false
99
+ } = {}) {
100
+ if (reset) {
101
+ events = [];
102
+ state = emptyState();
103
+ history = emptyHistory();
104
+ }
105
+ events = events.concat(more);
106
+ fold(state, more);
107
+ },
108
+ ask({
109
+ request,
110
+ context,
111
+ agent,
112
+ rules,
113
+ at
114
+ }) {
115
+ const judgement = decide({
116
+ request,
117
+ context,
118
+ agent,
119
+ rules,
120
+ state,
121
+ settings: options,
122
+ now: at
123
+ });
124
+ const entry = {
125
+ request,
126
+ agent,
127
+ askedAt: at,
128
+ judgement
129
+ };
130
+ pending.set(request.id, entry);
131
+ if (judgement.answer) {
132
+ ours.set(request.id, entry);
133
+ mine.add(request.id);
134
+ if (mine.size > 500) mine.delete(mine.values().next().value);
135
+ }
136
+ return {
137
+ judgement,
138
+ event: {
139
+ v: 1,
140
+ at,
141
+ type: "asked",
142
+ why: judgement.why,
143
+ ...about(entry)
144
+ }
145
+ };
146
+ },
147
+ answered(requestID, at) {
148
+ const entry = ours.get(requestID);
149
+ if (!entry) return undefined;
150
+ ours.delete(requestID);
151
+ pending.delete(requestID);
152
+ total++;
153
+ const subjects = entry.judgement.items.map(item => item.subject);
154
+ const via = entry.judgement.items.find(item => item.via !== undefined)?.via;
155
+ answered.unshift({
156
+ at,
157
+ request: requestID,
158
+ permission: entry.request.permission,
159
+ agent: entry.agent,
160
+ label: subjects.join(" && "),
161
+ subjects,
162
+ why: entry.judgement.why,
163
+ ...(via !== undefined ? {
164
+ via
165
+ } : {})
166
+ });
167
+ answered.splice(Math.max(1, options.keep ?? 20));
168
+ return {
169
+ v: 1,
170
+ at,
171
+ type: "auto",
172
+ rule: entry.judgement.why,
173
+ ...about(entry)
174
+ };
175
+ },
176
+ failed(requestID) {
177
+ ours.delete(requestID);
178
+ mine.delete(requestID);
179
+ },
180
+ replied({
181
+ requestID,
182
+ reply,
183
+ at
184
+ }) {
185
+ const entry = pending.get(requestID);
186
+ pending.delete(requestID);
187
+ const given = credit({
188
+ reply,
189
+ ...(entry ? {
190
+ askedAt: entry.askedAt,
191
+ always: entry.request.always
192
+ } : {}),
193
+ repliedAt: at,
194
+ ours: mine.has(requestID)
195
+ });
196
+ if (!entry || given.kind === "ignored") return {
197
+ events: [],
198
+ credit: given
199
+ };
200
+ /** Nothing to count — unless it was an "always", which the ledger shows whatever it covered. */
201
+ const always = given.kind === "approved" && given.always !== undefined;
202
+ if (entry.judgement.items.length === 0 && !always) return {
203
+ events: [],
204
+ credit: given
205
+ };
206
+ if (given.kind === "rejected") return {
207
+ events: [{
208
+ v: 1,
209
+ at,
210
+ type: "rejected",
211
+ ...about(entry)
212
+ }],
213
+ credit: given
214
+ };
215
+ return {
216
+ events: [{
217
+ v: 1,
218
+ at,
219
+ type: "approved",
220
+ ...(given.always ? {
221
+ always: given.always
222
+ } : {}),
223
+ ...about(entry)
224
+ }],
225
+ credit: given
226
+ };
227
+ },
228
+ reconcile(stillPending, now) {
229
+ for (const [id, entry] of [...pending]) {
230
+ if (stillPending.has(id) || ours.has(id)) continue;
231
+ /** A list is a moment, and events lag it: a request this young may simply not be in it yet. */
232
+ if (now - entry.askedAt < RECONCILE_AFTER_MS) continue;
233
+ pending.delete(id);
234
+ }
235
+ },
236
+ pending: () => [...pending.values()],
237
+ recent: () => answered,
238
+ count: () => total,
239
+ reset(next) {
240
+ options = next;
241
+ history = emptyHistory();
242
+ state = fold(emptyState(), events);
243
+ }
244
+ };
245
+ }