@matthewfl/pi-jtodo 0.0.1

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/src/gates.ts ADDED
@@ -0,0 +1,771 @@
1
+ /**
2
+ * Pure gate logic — direct ports from:
3
+ * jcode-base/src/todo.rs (gate predicates, digest, confidence summary)
4
+ * jcode-app-core/src/tool/todo.rs (merge helpers, observation recording)
5
+ *
6
+ * Everything in this file is free of pi APIs and side effects so the gate
7
+ * behavior stays unit-testable and maps 1:1 to the jcode functions it ports.
8
+ */
9
+
10
+ import {
11
+ LOW_CLOSED_FEEDBACK_LOOP,
12
+ LOW_INTENT_UNDERSTANDING,
13
+ QUALITY_GATE_THRESHOLD,
14
+ SEVERE_INTENT_MISUNDERSTANDING,
15
+ TODO_CONFIDENCE_SPIKE,
16
+ TODO_COMPLETION_CONTINUATION_MESSAGE,
17
+ TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE,
18
+ TODO_GATE_DIGEST_PREFIX,
19
+ TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE,
20
+ TODO_OWNERSHIP_CONTINUATION_MESSAGE,
21
+ } from "./constants.js";
22
+ import {
23
+ goalGroupKey,
24
+ type GateObservation,
25
+ type GateObservationKind,
26
+ type TodoGoal,
27
+ type TodoGoalChange,
28
+ type TodoGoalField,
29
+ type TodoItem,
30
+ type TodoPlan,
31
+ type TodoPlanChange,
32
+ } from "./model.js";
33
+ import type { TodoGoalInput, TodoItemInput, TodoPlanInput } from "./schema.js";
34
+
35
+ // ----------------------------------------------------------------------------
36
+ // Score histories (tool-maintained; one observation per write max)
37
+ // ----------------------------------------------------------------------------
38
+
39
+ /** Append `value` when it is a new observation. */
40
+ export function recordScoreObservation(
41
+ history: number[],
42
+ value: number | undefined | null,
43
+ ): void {
44
+ if (value === undefined || value === null) return;
45
+ if (history.length > 0 && history[history.length - 1] === value) return;
46
+ history.push(value);
47
+ }
48
+
49
+ /**
50
+ * Fold each incoming todo's confidence into its tool-maintained history.
51
+ * The model reports `confidence` while working and `completion_confidence`
52
+ * at completion; each write contributes at most one observation, so a single
53
+ * completion update cannot manufacture an apparent intermediate step.
54
+ * Model-supplied histories are ignored: the tool owns this field.
55
+ */
56
+ export function mergeConfidenceHistory(previous: TodoItem[], incoming: TodoItem[]): void {
57
+ const prior = new Map(previous.map((t) => [t.id, t]));
58
+ for (const todo of incoming) {
59
+ const prev = prior.get(todo.id);
60
+ const history = prev ? [...prev.confidence_history] : [];
61
+ if (history.length === 0 && prev) {
62
+ const value =
63
+ prev.status === "completed"
64
+ ? (prev.completion_confidence ?? prev.confidence)
65
+ : prev.confidence;
66
+ if (value !== undefined) history.push(value);
67
+ }
68
+ const observation =
69
+ todo.status === "completed"
70
+ ? (todo.completion_confidence ?? todo.confidence)
71
+ : todo.confidence;
72
+ recordScoreObservation(history, observation);
73
+ todo.confidence_history = history;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Merge incoming goal assessments with the stored ones: incoming wins per
79
+ * group key, omitted fields inherit the stored assessment, unmentioned
80
+ * stored groups are retained. Score histories are tool-maintained.
81
+ */
82
+ export function mergeGoals(stored: TodoGoal[], incoming: TodoGoalInput[] | undefined): TodoGoal[] {
83
+ if (!incoming) return stored.map((g) => ({ ...g }));
84
+ const merged: TodoGoal[] = [];
85
+ for (const goalInput of incoming) {
86
+ const group = goalGroupKey(goalInput.group);
87
+ const previous = stored.find((g) => goalGroupKey(g.group) === group);
88
+ const goal: TodoGoal = {
89
+ group,
90
+ closed_feedback_loop: goalInput.closed_feedback_loop ?? previous?.closed_feedback_loop,
91
+ closed_feedback_loop_history: previous ? [...previous.closed_feedback_loop_history] : [],
92
+ feedback_loop: goalInput.feedback_loop ?? previous?.feedback_loop,
93
+ end_to_end_ownership: goalInput.end_to_end_ownership ?? previous?.end_to_end_ownership,
94
+ end_to_end_ownership_history: previous ? [...previous.end_to_end_ownership_history] : [],
95
+ };
96
+ recordScoreObservation(goal.closed_feedback_loop_history, goal.closed_feedback_loop);
97
+ recordScoreObservation(goal.end_to_end_ownership_history, goal.end_to_end_ownership);
98
+ const slot = merged.find((g) => goalGroupKey(g.group) === group);
99
+ if (slot) {
100
+ Object.assign(slot, goal);
101
+ } else {
102
+ merged.push(goal);
103
+ }
104
+ }
105
+ for (const prev of stored) {
106
+ const key = goalGroupKey(prev.group);
107
+ if (!merged.some((g) => goalGroupKey(g.group) === key)) {
108
+ merged.push({ ...prev });
109
+ }
110
+ }
111
+ return merged;
112
+ }
113
+
114
+ /**
115
+ * Merge the incoming plan-level intent assessment with the stored one. An
116
+ * omitted intention inherits the stored value; an empty string clears it.
117
+ * The history trail is tool-maintained.
118
+ */
119
+ export function mergePlan(stored: TodoPlan, incoming: TodoPlanInput | undefined): TodoPlan {
120
+ if (!incoming) {
121
+ return {
122
+ ...stored,
123
+ understands_user_intent_history: [...stored.understands_user_intent_history],
124
+ };
125
+ }
126
+ const plan: TodoPlan = {
127
+ user_intention:
128
+ incoming.user_intention !== undefined ? incoming.user_intention : stored.user_intention,
129
+ understands_user_intent: incoming.understands_user_intent ?? stored.understands_user_intent,
130
+ understands_user_intent_history: [...stored.understands_user_intent_history],
131
+ };
132
+ recordScoreObservation(plan.understands_user_intent_history, plan.understands_user_intent);
133
+ return plan;
134
+ }
135
+
136
+ // ----------------------------------------------------------------------------
137
+ // Concise assessment diffs (rendered on assessment-only writes)
138
+ // ----------------------------------------------------------------------------
139
+
140
+ export function planChange(before: TodoPlan, after: TodoPlan): TodoPlanChange | undefined {
141
+ const fields: TodoPlanChange["fields"] = [];
142
+ if (before.user_intention !== after.user_intention) fields.push("user_intention");
143
+ if (before.understands_user_intent !== after.understands_user_intent)
144
+ fields.push("understands_user_intent");
145
+ if (fields.length === 0) return undefined;
146
+ return { before, after, fields };
147
+ }
148
+
149
+ function changedGoalFields(
150
+ before: TodoGoal | undefined,
151
+ after: TodoGoal | undefined,
152
+ ): TodoGoalField[] {
153
+ const fields: TodoGoalField[] = [];
154
+ if (before?.closed_feedback_loop !== after?.closed_feedback_loop)
155
+ fields.push("closed_feedback_loop");
156
+ if (before?.feedback_loop !== after?.feedback_loop) fields.push("feedback_loop");
157
+ if (before?.end_to_end_ownership !== after?.end_to_end_ownership)
158
+ fields.push("end_to_end_ownership");
159
+ return fields;
160
+ }
161
+
162
+ export function goalChanges(before: TodoGoal[], after: TodoGoal[]): TodoGoalChange[] {
163
+ const changes: TodoGoalChange[] = [];
164
+ for (const current of after) {
165
+ const key = goalGroupKey(current.group);
166
+ const previous = before.find((g) => goalGroupKey(g.group) === key);
167
+ const fields = changedGoalFields(previous, current);
168
+ if (fields.length > 0) changes.push({ before: previous, after: current, fields });
169
+ }
170
+ for (const previous of before) {
171
+ const key = goalGroupKey(previous.group);
172
+ if (after.some((g) => goalGroupKey(g.group) === key)) continue;
173
+ const fields = changedGoalFields(previous, undefined);
174
+ if (fields.length > 0) changes.push({ before: previous, fields });
175
+ }
176
+ return changes;
177
+ }
178
+
179
+ // ----------------------------------------------------------------------------
180
+ // Group completion and the ownership gate
181
+ // ----------------------------------------------------------------------------
182
+
183
+ export function groupIsComplete(todos: TodoItem[], group: string | undefined): boolean {
184
+ const matching = todos.filter((t) => goalGroupKey(t.group) === group);
185
+ return matching.length > 0 && matching.every((t) => t.status === "completed");
186
+ }
187
+
188
+ /** Groups this update closes: complete in `incoming`, not complete before. */
189
+ export function groupsClosedByUpdate(
190
+ previous: TodoItem[],
191
+ incoming: TodoItem[],
192
+ ): (string | undefined)[] {
193
+ const groups: (string | undefined)[] = [];
194
+ for (const todo of incoming) {
195
+ const group = goalGroupKey(todo.group);
196
+ if (groups.includes(group)) continue;
197
+ if (groupIsComplete(incoming, group) && !groupIsComplete(previous, group)) {
198
+ groups.push(group);
199
+ }
200
+ }
201
+ return groups;
202
+ }
203
+
204
+ /**
205
+ * The single write-blocking gate: every group newly closed by this update
206
+ * (including the implicit ungrouped goal) must carry sufficient
207
+ * end_to_end_ownership. Groups completed before this write are grandfathered.
208
+ */
209
+ export interface OwnershipIssue {
210
+ key: string | null;
211
+ ownership: number | undefined;
212
+ }
213
+
214
+ /**
215
+ * Which groups this write closes without a sufficient ownership claim.
216
+ * The rejection message names these — a silent boolean rejection made
217
+ * agents flail (drop goals, spam ownership=100) instead of addressing
218
+ * the actual failing group(s).
219
+ */
220
+ export function findOwnershipIssues(
221
+ previous: TodoItem[],
222
+ incoming: TodoItem[],
223
+ goals: TodoGoal[],
224
+ ): OwnershipIssue[] {
225
+ const issues: OwnershipIssue[] = [];
226
+ const groups: (string | null)[] = [];
227
+ for (const todo of incoming) {
228
+ const group = goalGroupKey(todo.group);
229
+ if (!groups.includes(group)) groups.push(group);
230
+ }
231
+ for (const group of groups) {
232
+ if (!groupIsComplete(incoming, group) || groupIsComplete(previous, group)) continue;
233
+ const goal = goals.find((g) => goalGroupKey(g.group) === group);
234
+ const ownership = goal?.end_to_end_ownership;
235
+ if (ownership === undefined || ownership < QUALITY_GATE_THRESHOLD) {
236
+ issues.push({ key: group, ownership });
237
+ }
238
+ }
239
+ return issues;
240
+ }
241
+
242
+ export function newlyCompletedGroupsHaveSufficientOwnership(
243
+ previous: TodoItem[],
244
+ incoming: TodoItem[],
245
+ goals: TodoGoal[],
246
+ ): boolean {
247
+ return findOwnershipIssues(previous, incoming, goals).length === 0;
248
+ }
249
+
250
+ const ownershipGroupName = (key: string | null): string =>
251
+ key === null ? "the ungrouped todo list (the implicit goal)" : `"${key}"`;
252
+
253
+ export function buildOwnershipContinuationMessage(
254
+ issues: OwnershipIssue[],
255
+ omittedGoals: boolean,
256
+ ): string {
257
+ const names = issues.map((i) => ownershipGroupName(i.key)).join(", ");
258
+ const missing = issues.filter((i) => i.ownership === undefined).length;
259
+ let guidance =
260
+ ` Groups this write completes without an honest end_to_end_ownership claim: ${names}` +
261
+ (missing > 0 ? " (some lack the field entirely)" : "") +
262
+ ". end_to_end_ownership is your assertion that you delivered the group's full intended " +
263
+ "outcome: the checks actually ran and passed, the complete workflow was validated, and " +
264
+ "no follow-through the user cares about is left outside the remaining list. If you " +
265
+ "cannot honestly claim that for a group, leave its todos unfinished instead of raising " +
266
+ "the number — the gate is asking for the evidence behind the claim, not a bigger number.";
267
+ if (omittedGoals) {
268
+ guidance +=
269
+ " Goals omitted from the goals array are retained from the previous write; dropping a group's goal entry does not exempt it from this check.";
270
+ }
271
+ return TODO_OWNERSHIP_CONTINUATION_MESSAGE + guidance;
272
+ }
273
+
274
+ export function buildCompletionContinuationMessage(flaggedIds: string[]): string {
275
+ if (flaggedIds.length === 0) return TODO_COMPLETION_CONTINUATION_MESSAGE;
276
+ return (
277
+ TODO_COMPLETION_CONTINUATION_MESSAGE +
278
+ ` Specifically, completion confidence is missing or not high enough on: ${flaggedIds
279
+ .map((id) => `#${id}`)
280
+ .join(", ")}. Re-verify those items against concrete evidence — re-run the check that proves each one — then re-send scores reflecting what you actually validated, or re-open the items you cannot verify.`
281
+ );
282
+ }
283
+
284
+ export function buildSpikeContinuationMessage(spikedIds: string[]): string {
285
+ if (spikedIds.length === 0) return TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE;
286
+ return (
287
+ TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE +
288
+ ` The sudden rise happened on: ${spikedIds
289
+ .map((id) => `#${id}`)
290
+ .join(", ")}. Sudden rises usually mean verification was skipped mid-work and the score is an estimate now: re-validate those items with concrete evidence and report steady scores, or score them honestly if they were estimates.`
291
+ );
292
+ }
293
+
294
+ /**
295
+ * Completed todos whose final confidence step was abrupt rather than
296
+ * accumulated in evidence-backed steps. Legacy records with no history fall
297
+ * back to comparing planning confidence with completion confidence.
298
+ */
299
+ export function spikeCompletedTodos(todos: TodoItem[]): TodoItem[] {
300
+ return todos.filter((todo) => {
301
+ if (todo.status !== "completed") return false;
302
+ const history = todo.confidence_history;
303
+ if (history.length === 0) {
304
+ const first = todo.confidence;
305
+ const last = todo.completion_confidence;
306
+ if (first === undefined || last === undefined) return false;
307
+ return Math.max(0, last - first) >= TODO_CONFIDENCE_SPIKE;
308
+ }
309
+ if (history.length === 1) return false;
310
+ const n = history.length;
311
+ return Math.max(0, history[n - 1] - history[n - 2]) >= TODO_CONFIDENCE_SPIKE;
312
+ });
313
+ }
314
+
315
+ // ----------------------------------------------------------------------------
316
+ // Deferred quality observations
317
+ // ----------------------------------------------------------------------------
318
+
319
+ /**
320
+ * Record the points this write would previously have interrupted on, and
321
+ * return the rare continuation still worth sending immediately.
322
+ *
323
+ * Deferred, not forgiven: understanding starts low and rises as the agent
324
+ * explores, so interrupting on every low write mostly punishes agents that
325
+ * are already fixing it. The one exception is a first plan write that
326
+ * scores severely low — a whole turn of wrong work cannot be undone at
327
+ * turn end.
328
+ */
329
+ export function recordReframeObservations(
330
+ plan: TodoPlan,
331
+ goals: TodoGoal[],
332
+ todos: TodoItem[],
333
+ previous: TodoItem[],
334
+ ): { observations: GateObservation[]; immediates: string[] } {
335
+ const observations: GateObservation[] = [];
336
+ const immediates: string[] = [];
337
+ const anyOpen = todos.some((t) => t.status !== "completed" && t.status !== "cancelled");
338
+ if (
339
+ anyOpen &&
340
+ (plan.understands_user_intent === undefined ||
341
+ plan.understands_user_intent < LOW_INTENT_UNDERSTANDING)
342
+ ) {
343
+ observations.push({ kind: "intent_understanding", score: plan.understands_user_intent });
344
+ // Only on the first observation of the plan, so a persistently low
345
+ // score is reported once at turn end rather than on every write.
346
+ const firstAssessment = plan.understands_user_intent_history.length <= 1;
347
+ if (
348
+ firstAssessment &&
349
+ plan.understands_user_intent !== undefined &&
350
+ plan.understands_user_intent < SEVERE_INTENT_MISUNDERSTANDING
351
+ ) {
352
+ immediates.push(TODO_INTENT_UNDERSTANDING_CONTINUATION_MESSAGE);
353
+ }
354
+ }
355
+ const closedNow = groupsClosedByUpdate(previous, todos);
356
+ for (const goal of goals) {
357
+ const groupOpen = todos.some(
358
+ (t) =>
359
+ goalGroupKey(t.group) === goalGroupKey(goal.group) &&
360
+ t.status !== "completed" &&
361
+ t.status !== "cancelled",
362
+ );
363
+ // A group this write closes counts too: a goal created and finished in
364
+ // one step is otherwise never observed, and one-step completions are
365
+ // where a weak feedback loop hides best.
366
+ if (!groupOpen && !closedNow.includes(goalGroupKey(goal.group))) continue;
367
+ if (
368
+ goal.closed_feedback_loop === undefined ||
369
+ goal.closed_feedback_loop < LOW_CLOSED_FEEDBACK_LOOP
370
+ ) {
371
+ observations.push({
372
+ kind: "closed_feedback_loop",
373
+ group: goalGroupKey(goal.group),
374
+ score: goal.closed_feedback_loop,
375
+ });
376
+ }
377
+ }
378
+ return { observations, immediates };
379
+ }
380
+
381
+ /**
382
+ * Whether the score behind this observation has since reached its bar. No
383
+ * longer suppresses the observation — it selects the wording instead, so a
384
+ * late climb is described as a coverage gap rather than as never closed.
385
+ */
386
+ function observationScoreLaterCleared(
387
+ observation: GateObservation,
388
+ plan: TodoPlan,
389
+ goals: TodoGoal[],
390
+ ): boolean {
391
+ if (observation.kind === "intent_understanding") {
392
+ return (
393
+ plan.understands_user_intent !== undefined &&
394
+ plan.understands_user_intent >= LOW_INTENT_UNDERSTANDING
395
+ );
396
+ }
397
+ const goal = goals.find((g) => goalGroupKey(g.group) === goalGroupKey(observation.group));
398
+ return (
399
+ goal?.closed_feedback_loop !== undefined &&
400
+ goal.closed_feedback_loop >= LOW_CLOSED_FEEDBACK_LOOP
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Build the turn-end reminder from the turn's recorded observations. Every
406
+ * point is surfaced, including ones whose score later rose past the bar (a
407
+ * late climb is exactly the case worth raising). Repeats of the same point
408
+ * collapse into one line with a count.
409
+ */
410
+ export function buildGateDigest(
411
+ observations: GateObservation[],
412
+ plan: TodoPlan,
413
+ goals: TodoGoal[],
414
+ ): string | undefined {
415
+ if (observations.length === 0) return undefined;
416
+ const points: { kind: GateObservationKind; group?: string; count: number; cleared: boolean }[] =
417
+ [];
418
+ for (const observation of observations) {
419
+ const cleared = observationScoreLaterCleared(observation, plan, goals);
420
+ const existing = points.find(
421
+ (p) => p.kind === observation.kind && p.group === observation.group,
422
+ );
423
+ if (existing) {
424
+ existing.count += 1;
425
+ } else {
426
+ points.push({ kind: observation.kind, group: observation.group, count: 1, cleared });
427
+ }
428
+ }
429
+ if (points.length === 0) return undefined;
430
+ let message = TODO_GATE_DIGEST_PREFIX;
431
+ for (const point of points) {
432
+ message += `\n- ${buildGateDigestDetail(point.kind, point.group, point.cleared)}`;
433
+ if (point.count > 1) message += ` (flagged ${point.count} times this turn)`;
434
+ }
435
+ message +=
436
+ "\nAddress the points above, then update the todo tool with the assessments that reflect what you verified.";
437
+ return message;
438
+ }
439
+
440
+ function buildGateDigestDetail(
441
+ kind: GateObservationKind,
442
+ group: string | undefined,
443
+ cleared: boolean,
444
+ ): string {
445
+ if (kind === "intent_understanding") {
446
+ return cleared
447
+ ? "you started this work without understanding what the user actually wants, and only settled it later. Re-check the work you did before it settled against the request you now understand, and state any interpretation you had to guess at."
448
+ : "your understanding of what the user actually wants never became solid. Re-read the request, confirm the work you did matches it, and state any interpretation you had to guess at.";
449
+ }
450
+ const label = group ? ` for "${group}"` : "";
451
+ return cleared
452
+ ? `the goal${label} was worked on before its feedback loop was closed, so the loop you ended up with never ran over that earlier work. Run it over the whole result now and report what it actually reported back.`
453
+ : `the goal${label} never closed its feedback loop: no observation reported back on whether the work satisfied the requirements. Confirm the result is actually better, with concrete evidence rather than inspection.`;
454
+ }
455
+
456
+ export function buildAutoPokeMessage(incompleteCount: number): string {
457
+ return `You have ${incompleteCount} incomplete todo${incompleteCount === 1 ? "" : "s"}. Continue working, or update the todo tool.`;
458
+ }
459
+
460
+ const AUTO_POKE_SUFFIX = "Continue working, or update the todo tool.";
461
+
462
+ /**
463
+ * Identity of the completion-gate inputs: which todos are settled and with
464
+ * what completion confidence. Challenging the same signature twice cannot
465
+ * produce a different outcome, so a re-validation pass that moves no scores
466
+ * should stop the gate early instead of burning the remaining attempts
467
+ * (pi-specific; jcode relies on its challenged agents actually moving).
468
+ */
469
+ export function completionConfidenceSignature(todos: TodoItem[]): string {
470
+ return todos
471
+ .filter((t) => t.status === "completed" || t.status === "cancelled")
472
+ .map((t) => `${t.id}:${t.status}:${t.completion_confidence ?? "-"}`)
473
+ .sort()
474
+ .join("|");
475
+ }
476
+
477
+ // ----------------------------------------------------------------------------
478
+ // Branch runtime (cycle flags + pending observations) derived from the log
479
+ // ----------------------------------------------------------------------------
480
+
481
+ export interface CycleFlags {
482
+ digestDelivered: boolean;
483
+ spikeChallenged: boolean;
484
+ gateAttempts: number;
485
+ }
486
+
487
+ export function freshCycleFlags(): CycleFlags {
488
+ return { digestDelivered: false, spikeChallenged: false, gateAttempts: 0 };
489
+ }
490
+
491
+ /** A stored todo snapshot as one event in the branch walk. */
492
+ export interface BranchSnapshotEvent {
493
+ kind: "snapshot";
494
+ openAny: boolean;
495
+ intentHistory: number[];
496
+ goals: { key: string | null; loopHistory: number[] }[];
497
+ /** completionConfidenceSignature of this snapshot's todos */
498
+ confidenceSignature: string;
499
+ }
500
+
501
+ export interface BranchFollowupEvent {
502
+ kind: "followup";
503
+ content: string;
504
+ }
505
+
506
+ /**
507
+ * A persisted auto-poke cycle transition (CYCLE_CUSTOM_TYPE entry).
508
+ * Emitted wherever the live machine resets or would reset the gate flags:
509
+ * every disarm() and every fresh arm (write re-arm, /todos poke on).
510
+ * The walker resets flags on every marker, matching the live machine.
511
+ */
512
+ export interface BranchCycleEvent {
513
+ kind: "cycle";
514
+ armed: boolean;
515
+ }
516
+
517
+ export type BranchEvent = BranchSnapshotEvent | BranchFollowupEvent | BranchCycleEvent;
518
+
519
+ export interface BranchRuntime {
520
+ flags: CycleFlags;
521
+ observations: GateObservation[];
522
+ /**
523
+ * The completion signature when the LAST completion-gate challenge was
524
+ * sent, or undefined when the signature moved since. Mirrors the
525
+ * in-memory deadlock detector across reloads/tree jumps: a challenge
526
+ * whose target scores never moved is recoverable from the branch.
527
+ */
528
+ lastChallengedSignature: string | undefined;
529
+ }
530
+
531
+ /**
532
+ * Rebuild the turn-end machine's ephemera from the branch log so /reload
533
+ * and /tree jumps restore exactly what this branch already experienced.
534
+ *
535
+ * Cycle flags: digestDelivered and spikeChallenged stick once their
536
+ * message was sent; gateAttempts counts completion/spike messages since
537
+ * the last auto-poke (the in-session machine resets attempts on every
538
+ * incomplete settle, and every armed incomplete settle emits one poke).
539
+ *
540
+ * Pending observations: a DELTA walk, not a history scan. Score histories
541
+ * are cumulative across cycles and undated, so "any sub-threshold entry"
542
+ * would re-derive observations a long-delivered digest already consumed.
543
+ * Instead, each snapshot's entries beyond the previous snapshot's history
544
+ * length are the scores that specific write appended — the same stream
545
+ * recordReframeObservations saw live — and a digest follow-up clears the
546
+ * accumulator, mirroring the in-session clear-on-delivery. Two lossy
547
+ * corners, both quiet-er direction: re-sent scores that appended nothing
548
+ * can't be counted (digest "flagged N times" may under-count), and the
549
+ * in-session "group open / closed by this write" filter is dropped for
550
+ * reconstructed loop observations (extra review, never less).
551
+ */
552
+ export function deriveBranchRuntime(events: BranchEvent[]): BranchRuntime {
553
+ const flags = freshCycleFlags();
554
+ const observations: GateObservation[] = [];
555
+ let completionSincePoke = 0;
556
+ let spikeSincePoke = 0;
557
+ let prevIntentLen = 0;
558
+ const prevLoopLen = new Map<string | null, number>();
559
+ let latestSignature: string | undefined;
560
+ let signatureAtLastChallenge: string | undefined;
561
+ for (const ev of events) {
562
+ if (ev.kind === "snapshot") {
563
+ latestSignature = ev.confidenceSignature;
564
+ for (const score of ev.intentHistory.slice(prevIntentLen)) {
565
+ if (ev.openAny && score < LOW_INTENT_UNDERSTANDING) {
566
+ observations.push({ kind: "intent_understanding", score });
567
+ }
568
+ }
569
+ prevIntentLen = ev.intentHistory.length;
570
+ for (const goal of ev.goals) {
571
+ const fresh = goal.loopHistory.slice(prevLoopLen.get(goal.key) ?? 0);
572
+ for (const score of fresh) {
573
+ if (score < LOW_CLOSED_FEEDBACK_LOOP) {
574
+ observations.push({
575
+ kind: "closed_feedback_loop",
576
+ group: goal.key ?? undefined,
577
+ score,
578
+ });
579
+ }
580
+ }
581
+ prevLoopLen.set(goal.key, goal.loopHistory.length);
582
+ }
583
+ continue;
584
+ }
585
+ if (ev.kind === "cycle") {
586
+ flags.digestDelivered = false;
587
+ flags.spikeChallenged = false;
588
+ completionSincePoke = 0;
589
+ spikeSincePoke = 0;
590
+ signatureAtLastChallenge = undefined;
591
+ // NOTE: pending observations are kept across cycle boundaries on
592
+ // purpose (jcode clears them only when a digest is delivered).
593
+ continue;
594
+ }
595
+ const content = ev.content;
596
+ if (content.startsWith(TODO_GATE_DIGEST_PREFIX)) {
597
+ flags.digestDelivered = true;
598
+ observations.length = 0; // delivery consumed the observations, as in-session
599
+ } else if (content.startsWith(TODO_COMPLETION_CONTINUATION_MESSAGE)) {
600
+ completionSincePoke += 1;
601
+ signatureAtLastChallenge = latestSignature;
602
+ } else if (content.startsWith(TODO_CONFIDENCE_SPIKE_CONTINUATION_MESSAGE)) {
603
+ flags.spikeChallenged = true;
604
+ spikeSincePoke += 1; // spike-only message consumes a completion-gate attempt (jcode shares the budget)
605
+ } else if (content.endsWith(AUTO_POKE_SUFFIX)) {
606
+ completionSincePoke = 0;
607
+ spikeSincePoke = 0;
608
+ }
609
+ }
610
+ flags.gateAttempts = completionSincePoke + spikeSincePoke;
611
+ return {
612
+ flags,
613
+ observations,
614
+ lastChallengedSignature:
615
+ signatureAtLastChallenge !== undefined && signatureAtLastChallenge === latestSignature
616
+ ? signatureAtLastChallenge
617
+ : undefined,
618
+ };
619
+ }
620
+
621
+ // ----------------------------------------------------------------------------
622
+ // Completion-confidence summary (jcode-tui commands.rs::todo_confidence_summary)
623
+ // ----------------------------------------------------------------------------
624
+
625
+ export interface TodoConfidenceSummary {
626
+ completion_average: number | undefined;
627
+ needs_validation: boolean;
628
+ spike_detected: boolean;
629
+ }
630
+
631
+ function todoConfidenceWeight(priority: string): number {
632
+ if (priority === "high") return 3;
633
+ if (priority === "medium") return 2;
634
+ return 1;
635
+ }
636
+
637
+ /** Rounded weighted average (round-half-up), or undefined when empty. */
638
+ function weightedConfidenceAverage(scores: Iterable<[score: number, weight: number]>): number | undefined {
639
+ let weightedSum = 0;
640
+ let totalWeight = 0;
641
+ for (const [score, weight] of scores) {
642
+ weightedSum += score * weight;
643
+ totalWeight += weight;
644
+ }
645
+ if (totalWeight === 0) return undefined;
646
+ return Math.floor((weightedSum + Math.floor(totalWeight / 2)) / totalWeight);
647
+ }
648
+
649
+ export function todoConfidenceSummary(todos: TodoItem[]): TodoConfidenceSummary {
650
+ const completed = todos.filter((t) => t.status === "completed");
651
+ const scored: [number, number][] = [];
652
+ let missing = 0;
653
+ let below = 0;
654
+ for (const todo of completed) {
655
+ if (todo.completion_confidence === undefined) {
656
+ missing += 1;
657
+ continue;
658
+ }
659
+ scored.push([todo.completion_confidence, todoConfidenceWeight(todo.priority)]);
660
+ if (todo.completion_confidence < QUALITY_GATE_THRESHOLD) below += 1;
661
+ }
662
+ const average = weightedConfidenceAverage(scored);
663
+ const needsValidation =
664
+ average === undefined || average < QUALITY_GATE_THRESHOLD || missing > 0 || below > 0;
665
+ return {
666
+ completion_average: average,
667
+ needs_validation: needsValidation,
668
+ spike_detected: spikeCompletedTodos(todos).length > 0,
669
+ };
670
+ }
671
+
672
+ export function formatCompletionLabel(summary: TodoConfidenceSummary): string {
673
+ return summary.completion_average === undefined ? "unknown" : `${summary.completion_average}%`;
674
+ }
675
+
676
+ // ----------------------------------------------------------------------------
677
+ // Small shared helpers
678
+ // ----------------------------------------------------------------------------
679
+
680
+ export function todosEqual(a: TodoItem[], b: TodoItem[]): boolean {
681
+ return JSON.stringify(a) === JSON.stringify(b);
682
+ }
683
+
684
+ /** Build a stored TodoItem from validated input (whitelisted fields only). */
685
+ /** Field-level inheritance for update-style writes (pi deviation from jcode's
686
+ * whole-item replace): when an existing id is re-sent with an optional field
687
+ * omitted, the stored value is inherited instead of silently dropped. The one
688
+ * explicit clear is `group: ""` (a blank label lands the item in the
689
+ * ungrouped bucket). Membership semantics are unchanged: omitting an ITEM
690
+ * deletes it; omitting a FIELD inherits it. Mutates the inputs in place. */
691
+ /** Post-apply diff of the model-controlled item fields (confidence_history is
692
+ * tool-owned and deliberately ignored — otherwise every touched item would
693
+ * count as "changed"). Drives the "Changes: …" digest appended to accepted
694
+ * writes so the agent notices unintended effects (a dropped item, a cleared
695
+ * group) immediately, instead of discovering them turns later. */
696
+ export interface ItemChanges {
697
+ added: string[];
698
+ removed: string[];
699
+ updated: string[];
700
+ groupCleared: string[];
701
+ }
702
+
703
+ export function diffItemChanges(previous: TodoItem[], next: TodoItem[]): ItemChanges {
704
+ const prevById = new Map(previous.map((t) => [t.id, t]));
705
+ const nextById = new Map(next.map((t) => [t.id, t]));
706
+ const out: ItemChanges = { added: [], removed: [], updated: [], groupCleared: [] };
707
+ for (const t of next) {
708
+ const p = prevById.get(t.id);
709
+ if (!p) {
710
+ out.added.push(t.id);
711
+ continue;
712
+ }
713
+ if (
714
+ t.content !== p.content ||
715
+ t.status !== p.status ||
716
+ t.priority !== p.priority ||
717
+ t.group !== p.group ||
718
+ t.confidence !== p.confidence ||
719
+ t.completion_confidence !== p.completion_confidence
720
+ ) {
721
+ // Group clears get named separately from generic updates: the move
722
+ // re-buckets the item's goal, which is the dangerous silent case.
723
+ if (p.group !== undefined && t.group === undefined) out.groupCleared.push(t.id);
724
+ else out.updated.push(t.id);
725
+ }
726
+ }
727
+ for (const p of previous) if (!nextById.has(p.id)) out.removed.push(p.id);
728
+ return out;
729
+ }
730
+
731
+ /** One-line digest for the tool result text; undefined when nothing changed
732
+ * (quiet is golden — a no-op resend prints no Changes line). Removals and
733
+ * group clears are always named; updates/additions stay counts. */
734
+ export function formatItemChanges(c: ItemChanges): string | undefined {
735
+ const parts: string[] = [];
736
+ if (c.updated.length) parts.push(`${c.updated.length} updated`);
737
+ if (c.added.length) parts.push(`${c.added.length} new`);
738
+ if (c.removed.length) parts.push(`removed ${c.removed.map((id) => `#${id}`).join(", ")}`);
739
+ if (c.groupCleared.length)
740
+ parts.push(`group cleared on ${c.groupCleared.map((id) => `#${id}`).join(", ")}`);
741
+ return parts.length ? parts.join("; ") : undefined;
742
+ }
743
+
744
+ export function inheritItemFields(previous: TodoItem[], inputs: TodoItemInput[]): void {
745
+ const prior = new Map(previous.map((t) => [t.id, t]));
746
+ for (const input of inputs) {
747
+ const prev = prior.get(input.id);
748
+ if (!prev) continue;
749
+ if (input.group === undefined && prev.group !== undefined) input.group = prev.group;
750
+ if (input.completion_confidence === undefined && prev.completion_confidence !== undefined)
751
+ input.completion_confidence = prev.completion_confidence;
752
+ }
753
+ }
754
+
755
+ export function buildItem(input: TodoItemInput): TodoItem {
756
+ const item: TodoItem = {
757
+ id: input.id,
758
+ content: input.content,
759
+ status: input.status,
760
+ priority: input.priority,
761
+ confidence: input.confidence,
762
+ confidence_history: [],
763
+ };
764
+ // group: "" is the explicit clear (blank label → ungrouped); with
765
+ // inheritItemFields running earlier, undefined here means a genuinely
766
+ // new ungrouped item, not an omission.
767
+ if (input.group !== undefined && input.group.trim() !== "") item.group = input.group;
768
+ if (input.completion_confidence !== undefined)
769
+ item.completion_confidence = input.completion_confidence;
770
+ return item;
771
+ }