dsh-context-guardian 0.1.0-alpha.10

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/cg_handoff.js ADDED
@@ -0,0 +1,253 @@
1
+ // cg_handoff.js -- context-guardian hand-off writer (C-Handoff)
2
+ //
3
+ // Turns the context-guardian per-session memory (pinned facts, decisions, files)
4
+ // into a hand-off file another session -- or Claude, via the escalate_to_claude
5
+ // tool -- can read to continue the work without the conversation.
6
+ //
7
+ // Standalone ES module, Node 20+, built-ins only: node:fs, node:path, node:url.
8
+
9
+ import { mkdirSync, writeFileSync, renameSync, mkdtempSync, rmSync, existsSync } from 'node:fs';
10
+ import { join, resolve } from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+
13
+ export const SCHEMA = 1;
14
+
15
+ // A session id becomes a file name, so it is restricted to a conservative set.
16
+ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
17
+
18
+ function isSessionId(s) {
19
+ return typeof s === 'string' && SESSION_ID_RE.test(s);
20
+ }
21
+
22
+ function itemsOf(memory) {
23
+ return memory && Array.isArray(memory.items) ? memory.items : [];
24
+ }
25
+
26
+ function bySeq(a, b) {
27
+ return (a && a.seq ? a.seq : 0) - (b && b.seq ? b.seq : 0);
28
+ }
29
+
30
+ /**
31
+ * Build the plain hand-off object from a context-guardian memory.
32
+ * Pins and files are scoped to `session`; decisions carry across sessions.
33
+ * Superseded pins are kept here (with their flag) and only hidden in the
34
+ * markdown rendering.
35
+ */
36
+ export function buildHandoff(memory, session, opts = {}) {
37
+ opts = opts || {};
38
+
39
+ const items = itemsOf(memory);
40
+
41
+ const pins = items
42
+ .filter(item => item && item.cat === 'pins' && item.session === session)
43
+ .sort(bySeq)
44
+ .map(item => ({
45
+ text: item.text,
46
+ seq: item.seq,
47
+ saidBy: item.saidBy || 'user',
48
+ superseded: item.superseded || false
49
+ }));
50
+
51
+ const decisions = items
52
+ .filter(item => item && item.cat === 'decisions')
53
+ .sort(bySeq)
54
+ .map(item => item.text);
55
+
56
+ const files = items
57
+ .filter(item => item && item.cat === 'files' && item.session === session)
58
+ .sort(bySeq)
59
+ .map(item => ({
60
+ path: item.text,
61
+ last_action: item.action || 'read'
62
+ }));
63
+
64
+ return {
65
+ schema: SCHEMA,
66
+ session,
67
+ written_at: opts.now || new Date().toISOString(),
68
+ goal: opts.goal || '',
69
+ pins,
70
+ decisions,
71
+ rejected: [],
72
+ files,
73
+ open_question: opts.openQuestion ?? null,
74
+ last_step: opts.lastStep ?? null
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Render the hand-off as markdown. Lines joined with "\n", ending with "\n".
80
+ * Superseded pins are not shown.
81
+ */
82
+ export function renderHandoffMd(h) {
83
+ h = h || {};
84
+ const pins = Array.isArray(h.pins) ? h.pins : [];
85
+ const decisions = Array.isArray(h.decisions) ? h.decisions : [];
86
+ const files = Array.isArray(h.files) ? h.files : [];
87
+
88
+ const lines = [];
89
+ lines.push(`# Hand-off ${h.session}`);
90
+ lines.push('');
91
+ lines.push(`GOAL: ${h.goal}`);
92
+ lines.push('');
93
+
94
+ lines.push('## Pins');
95
+ for (const pin of pins) {
96
+ if (!pin || pin.superseded) continue;
97
+ lines.push(`- ${pin.text}`);
98
+ }
99
+ lines.push('');
100
+
101
+ lines.push('## Decisions');
102
+ for (const decision of decisions) {
103
+ lines.push(`- ${decision}`);
104
+ }
105
+ lines.push('');
106
+
107
+ lines.push('## Files');
108
+ for (const file of files) {
109
+ lines.push(`- ${file.path} (${file.last_action})`);
110
+ }
111
+ lines.push('');
112
+
113
+ if (h.open_question != null) {
114
+ lines.push(`OPEN QUESTION: ${h.open_question}`);
115
+ }
116
+ if (h.last_step != null) {
117
+ lines.push(`LAST STEP: ${h.last_step}`);
118
+ }
119
+
120
+ // Exactly one trailing newline, whether or not an OPEN QUESTION / LAST STEP
121
+ // line was appended above.
122
+ if (lines[lines.length - 1] !== '') lines.push('');
123
+
124
+ return lines.join('\n');
125
+ }
126
+
127
+ /**
128
+ * Write the hand-off files into `dir` (created if needed) and return the
129
+ * absolute paths written: {json, md, latest}.
130
+ * Synchronous on purpose -- callers may read the files back immediately.
131
+ */
132
+ export function writeHandoff(dir, h) {
133
+ const session = h && h.session;
134
+
135
+ // Validate before touching the filesystem so a bad id writes nothing.
136
+ if (!isSessionId(session)) {
137
+ throw new Error('bad session id');
138
+ }
139
+
140
+ const base = resolve(dir);
141
+ mkdirSync(base, { recursive: true });
142
+
143
+ const jsonFile = join(base, `handoff_${session}.json`);
144
+ const latestJsonFile = join(base, 'handoff_latest.json');
145
+ const mdFile = join(base, `handoff_${session}.md`);
146
+
147
+ const json = JSON.stringify(h, null, 2);
148
+ const md = renderHandoffMd(h);
149
+
150
+ const writeAtomic = (target, content) => {
151
+ const tmp = `${target}.tmp`;
152
+ writeFileSync(tmp, content, 'utf8');
153
+ renameSync(tmp, target);
154
+ };
155
+
156
+ writeAtomic(jsonFile, json);
157
+ writeAtomic(latestJsonFile, json);
158
+ writeAtomic(mdFile, md);
159
+
160
+ return { json: jsonFile, md: mdFile, latest: latestJsonFile };
161
+ }
162
+
163
+ // A writable scratch directory, using only node:fs/node:path.
164
+ function scratchBase() {
165
+ const env = process.env.TMPDIR || process.env.TEMP || process.env.TMP;
166
+ if (env) return env;
167
+ return process.platform === 'win32' ? process.cwd() : '/tmp';
168
+ }
169
+
170
+ export function selftest() {
171
+ const MEM = {
172
+ version: 1,
173
+ updated: null,
174
+ items: [
175
+ { cat: 'pins', text: 'B pin', seq: 5, session: 's1' },
176
+ { cat: 'pins', text: 'A pin', seq: 2, session: 's1' },
177
+ { cat: 'pins', text: 'old', seq: 3, session: 's1', superseded: true },
178
+ { cat: 'pins', text: 'other', seq: 1, session: 's2' },
179
+ { cat: 'decisions', text: 'use py3.11', seq: 4, session: 's2' },
180
+ { cat: 'files', text: 'a.py', seq: 6, session: 's1', action: 'edit' }
181
+ ]
182
+ };
183
+
184
+ const checks = [
185
+ ['pins_sorted_and_scoped', () => {
186
+ return buildHandoff(MEM, 's1').pins.map(p => p.text).join(',') === 'A pin,old,B pin';
187
+ }],
188
+ ['saidBy_default_user', () => {
189
+ return buildHandoff(MEM, 's1').pins[0].saidBy === 'user';
190
+ }],
191
+ ['decisions_any_session', () => {
192
+ return buildHandoff(MEM, 's1').decisions[0] === 'use py3.11';
193
+ }],
194
+ ['files_action', () => {
195
+ return buildHandoff(MEM, 's1').files[0].last_action === 'edit';
196
+ }],
197
+ ['empty_memory', () => {
198
+ return buildHandoff(null, 'x').pins.length === 0;
199
+ }],
200
+ ['md_hides_superseded', () => {
201
+ return !renderHandoffMd(buildHandoff(MEM, 's1')).includes('- old');
202
+ }],
203
+ ['md_goal_line', () => {
204
+ return renderHandoffMd(buildHandoff(MEM, 's1', { goal: 'G' })).split('\n')[2] === 'GOAL: G';
205
+ }],
206
+ ['write_and_bad_id', () => {
207
+ const tmpDir = mkdtempSync(join(scratchBase(), 'cgh-selftest-'));
208
+ try {
209
+ const h = buildHandoff(MEM, 's1');
210
+ const out = writeHandoff(tmpDir, h);
211
+ if (!existsSync(out.latest) || !existsSync(out.json) || !existsSync(out.md)) {
212
+ return false;
213
+ }
214
+ let threw = false;
215
+ try {
216
+ writeHandoff(tmpDir, { ...h, session: '../x' });
217
+ } catch (e) {
218
+ threw = e instanceof Error && e.message === 'bad session id';
219
+ }
220
+ return threw;
221
+ } finally {
222
+ rmSync(tmpDir, { recursive: true, force: true });
223
+ }
224
+ }]
225
+ ];
226
+
227
+ let passed = 0;
228
+ let failed = 0;
229
+
230
+ for (const [name, fn] of checks) {
231
+ let ok = false;
232
+ try {
233
+ ok = !!fn();
234
+ } catch (e) {
235
+ ok = false;
236
+ }
237
+ if (ok) passed++;
238
+ else failed++;
239
+ }
240
+
241
+ console.log(`cg_handoff selftest: ${checks.length} checks, ${passed} passed, ${failed} failed`);
242
+
243
+ return { checks: checks.length, passed, failed };
244
+ }
245
+
246
+ // Run directly: `node cg_handoff.mjs --selftest`. The argv guard matters --
247
+ // importing this module where process.argv[1] is undefined must not throw.
248
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
249
+ if (process.argv.includes('--selftest')) {
250
+ const result = selftest();
251
+ process.exit(result.failed === 0 && result.checks > 0 ? 0 : 1);
252
+ }
253
+ }