@cstart/coldstart 2.1.1 → 2.2.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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * trigger.mjs — the capture arming/firing state machine. Pure: no I/O, no
3
+ * clock, no client specifics. The hook feeds it one stop's observations and
4
+ * it returns the updated state + a fire decision.
5
+ *
6
+ * FROZEN SPEC (2026-07-15, replay + wave-lab validated):
7
+ * score = uncaptured contentRead files ×1
8
+ * + settled edited files ×2 (settled = 3 active stops w/o re-edit)
9
+ * + active stops since last fire (synthesis turns count as active)
10
+ * fresh-noted files contribute NOTHING — genuinely new knowledge drives firing.
11
+ * arm at score ≥ T(10), requiring ≥2 active stops AND ≥2 uncaptured files.
12
+ * fire armed + descent (2 quiet stops) → non-blocking (inject)
13
+ * armed + surge (≥2 new files after a quiet) → non-blocking (inject)
14
+ * score ≥ CAP(20) → non-blocking (backlog
15
+ * rescue — replay showed dense sessions starve descent and hit cap
16
+ * repeatedly; blocking each cap re-created the v4 agitation)
17
+ * .git/HEAD drift, ≥2 uncaptured files → BLOCKING (instant —
18
+ * the one boundary where waiting for a next prompt loses the moment)
19
+ * NEVER: first-stop fire, wall-clock, gap/resume, conversation classification.
20
+ *
21
+ * State lives in the session marker (JSON-serializable, owned by the caller).
22
+ * Files enter state ONLY if they passed the ignore filter and have contentRead
23
+ * evidence — mentions and ignored files never arm anything.
24
+ */
25
+
26
+ export const T_ARM = 10;
27
+ export const T_CAP = 20;
28
+ export const SETTLE_ACTIVE_STOPS = 3;
29
+ export const DESCENT_QUIET = 2;
30
+ export const SURGE_NEW_FILES = 2;
31
+ export const MIN_FILES = 2;
32
+
33
+ export function initialState() {
34
+ return {
35
+ v: 2,
36
+ stop: 0, // stops processed
37
+ activeStops: 0, // ACTIVE stops since last fire
38
+ quietRun: 0, // consecutive quiet stops
39
+ wasQuiet: false, // previous stop was quiet (surge detection)
40
+ armed: false,
41
+ fires: 0,
42
+ lineCount: 0, // transcript lines already consumed (caller-owned)
43
+ head: "", // .git/HEAD fingerprint at last stop (caller-owned)
44
+ files: {}, // rel → { reads, edits, gs, firstStop, lastStop,
45
+ // lastEditActive, retouches, captured, fresh }
46
+ };
47
+ }
48
+
49
+ /**
50
+ * step(state, obs) → { state, decision }
51
+ * obs = {
52
+ * delta: Map/obj rel → {reads, edits, gs} (this stop's NEW evidence,
53
+ * ignore-filtered, contentRead tiers only — no mentions),
54
+ * synthesis: bool (prose-heavy tool-light segment),
55
+ * freshNoted: Set<rel> (files whose note is currently fresh),
56
+ * headDrift: bool (.git/HEAD changed since last stop — manual commits too),
57
+ * }
58
+ * decision = null | { fire: "descent"|"surge"|"cap"|"head-drift",
59
+ * mode: "inject"|"block", files: [rel…] }
60
+ */
61
+ export function step(state, obs) {
62
+ const s = state;
63
+ s.stop++;
64
+
65
+ // ---- merge this stop's evidence -------------------------------------------
66
+ let newFiles = 0;
67
+ let editsThisStop = 0;
68
+ const entries = obs.delta instanceof Map ? obs.delta.entries() : Object.entries(obs.delta || {});
69
+ const freshNoted = obs.freshNoted || new Set();
70
+ for (const [rel, d] of entries) {
71
+ let f = s.files[rel];
72
+ if (!f) {
73
+ // A file whose note is currently FRESH contributes nothing to the score
74
+ // (genuinely new knowledge drives firing). An edit clears the discount:
75
+ // the note no longer covers what the file just became.
76
+ f = { reads: 0, edits: 0, gs: 0, firstStop: s.stop, lastStop: 0, lastEditActive: -1, retouches: 0, captured: false, fresh: freshNoted.has(rel) };
77
+ s.files[rel] = f;
78
+ newFiles++;
79
+ } else if (f.lastStop !== s.stop && f.lastStop < s.stop) {
80
+ f.retouches++;
81
+ }
82
+ f.reads += d.reads || 0;
83
+ f.gs += d.gs || 0;
84
+ if (d.edits) {
85
+ f.edits += d.edits;
86
+ editsThisStop++;
87
+ f.fresh = false;
88
+ f.captured = false; // re-edit after a capture = new knowledge to capture again
89
+ f.lastEditActive = -2; // provisional: fixed up after active-stop accounting below
90
+ }
91
+ f.lastStop = s.stop;
92
+ }
93
+
94
+ // ---- active vs quiet --------------------------------------------------------
95
+ const active = newFiles > 0 || editsThisStop > 0 || obs.synthesis === true;
96
+ if (active) { s.activeStops++; s.quietRun = 0; } else { s.quietRun++; }
97
+ for (const f of Object.values(s.files)) {
98
+ if (f.lastEditActive === -2) f.lastEditActive = s.activeStops; // edit stamped at current active count
99
+ }
100
+
101
+ // ---- score -------------------------------------------------------------------
102
+ const uncaptured = Object.entries(s.files).filter(([, f]) => !f.captured && !f.fresh);
103
+ const readPts = uncaptured.length;
104
+ const settledEdits = uncaptured.filter(([, f]) =>
105
+ f.edits > 0 && f.lastEditActive >= 0 && s.activeStops - f.lastEditActive >= SETTLE_ACTIVE_STOPS,
106
+ ).length;
107
+ const score = readPts + settledEdits * 2 + s.activeStops;
108
+
109
+ // ---- arm / fire ----------------------------------------------------------------
110
+ if (!s.armed && score >= T_ARM && s.activeStops >= 2 && uncaptured.length >= MIN_FILES) {
111
+ s.armed = true;
112
+ }
113
+
114
+ let fire = null;
115
+ if (obs.headDrift && uncaptured.length >= MIN_FILES) {
116
+ fire = { fire: "head-drift", mode: "block" };
117
+ } else if (score >= T_CAP && uncaptured.length >= MIN_FILES) {
118
+ fire = { fire: "cap", mode: "inject" };
119
+ } else if (s.armed && s.quietRun >= DESCENT_QUIET) {
120
+ fire = { fire: "descent", mode: "inject" };
121
+ } else if (s.armed && s.wasQuiet && newFiles >= SURGE_NEW_FILES) {
122
+ fire = { fire: "surge", mode: "inject" };
123
+ }
124
+ s.wasQuiet = !active;
125
+
126
+ let decision = null;
127
+ if (fire) {
128
+ // worklist = the uncaptured set, most-worked first (edits, then retouches)
129
+ const files = uncaptured
130
+ .sort((a, b) => (b[1].edits - a[1].edits) || (b[1].retouches - a[1].retouches) || (b[1].reads - a[1].reads))
131
+ .map(([rel]) => rel);
132
+ for (const [, f] of uncaptured) f.captured = true;
133
+ s.armed = false;
134
+ s.activeStops = 0;
135
+ s.quietRun = 0;
136
+ s.fires++;
137
+ decision = { ...fire, files, score };
138
+ }
139
+ return { state: s, decision };
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cstart/coldstart",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },