gitmem-mcp 1.1.3 → 1.2.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.
@@ -19,7 +19,7 @@ import { syncThreadsToSupabase, loadOpenThreadEmbeddings } from "../services/thr
19
19
  import { validateSessionClose, buildCloseCompliance, } from "../services/compliance-validator.js";
20
20
  import { normalizeReflectionKeys } from "../constants/closing-questions.js";
21
21
  import { Timer, recordMetrics, buildPerformanceData, updateRelevanceData, } from "../services/metrics.js";
22
- import { wrapDisplay, truncate } from "../services/display-protocol.js";
22
+ import { wrapDisplay, truncate, productLine, dimText, STATUS, ANSI } from "../services/display-protocol.js";
23
23
  import { recordScarUsageBatch } from "./record-scar-usage-batch.js";
24
24
  import { getEffectTracker } from "../services/effect-tracker.js";
25
25
  import { saveTranscript } from "./save-transcript.js";
@@ -242,107 +242,73 @@ async function sessionCloseFree(params, timer) {
242
242
  }
243
243
  function formatCloseDisplay(sessionId, compliance, params, learningsCount, success, errors, transcriptStatus) {
244
244
  const lines = [];
245
- // Header
246
- const B = "\x1b[1m"; // bold on
247
- const D = "\x1b[2m"; // dim on
248
- const R = "\x1b[0m"; // reset
249
- const closeLabel = compliance.close_type.toUpperCase();
250
- const status = success ? "COMPLETE" : "FAILED";
251
- lines.push(`${B}${closeLabel} CLOSE — ${status}${R}`);
252
- lines.push(`Session ${sessionId.slice(0, 8)} · ${compliance.agent}`);
245
+ // Header: branded product line
246
+ const status = success ? STATUS.complete : STATUS.failed;
247
+ lines.push(productLine("close", status));
248
+ // Stats line: compact one-liner with key counts
249
+ const stats = [];
250
+ const scarsApplied = params.scars_to_record?.filter(s => s.reference_type !== "none").length || 0;
251
+ if (scarsApplied > 0)
252
+ stats.push(`${scarsApplied} scars applied`);
253
+ if (learningsCount > 0)
254
+ stats.push(`${learningsCount} learnings`);
255
+ if (params.decisions?.length)
256
+ stats.push(`${params.decisions.length} decision${params.decisions.length > 1 ? "s" : ""}`);
257
+ const threads = params.open_threads || [];
258
+ const openCount = threads.filter(t => typeof t === "string" || t.status === "open").length;
259
+ if (openCount > 0)
260
+ stats.push(`${openCount} threads`);
261
+ if (stats.length > 0) {
262
+ lines.push(stats.join(" · "));
263
+ }
264
+ lines.push(dimText(`${sessionId.slice(0, 8)} · ${compliance.agent} · ${compliance.close_type}`));
265
+ // Errors — only on failure
253
266
  if (!success && errors?.length) {
254
267
  lines.push("");
255
268
  for (const e of errors)
256
- lines.push(` !! ${e}`);
257
- }
258
- // Checklist (compact)
259
- lines.push("");
260
- const ok = "\u2713";
261
- const no = "\u2717";
262
- if (compliance.close_type === "standard") {
263
- lines.push(` ${ok} Session state read`);
264
- lines.push(` ${compliance.questions_answered_by_agent ? ok : no} Reflection (9 questions)`);
265
- lines.push(` ${compliance.human_asked_for_corrections ? ok : no} Human corrections`);
266
- lines.push(` ${success ? ok : no} Persisted`);
269
+ lines.push(` ${STATUS.miss} ${e}`);
267
270
  }
268
- else {
269
- lines.push(` ${success ? ok : no} Persisted (${compliance.close_type})`);
271
+ // Reflection highlights FIRST — the most interesting part
272
+ if (params.closing_reflection) {
273
+ const r = params.closing_reflection;
274
+ if (r.what_worked || r.do_differently) {
275
+ lines.push("");
276
+ if (r.what_worked) {
277
+ lines.push(`${STATUS.pass} ${truncate(r.what_worked, 72)}`);
278
+ }
279
+ if (r.do_differently) {
280
+ lines.push(`${ANSI.yellow}>${ANSI.reset} ${truncate(r.do_differently, 72)}`);
281
+ }
282
+ }
270
283
  }
271
- // Decisions table
284
+ // Decisions — title only, one line each
272
285
  if (params.decisions?.length) {
273
286
  lines.push("");
274
- lines.push(`${B}Decisions${R}`);
275
287
  for (const d of params.decisions) {
276
- lines.push(` ${truncate(d.title, 60)}`);
277
- lines.push(` ${truncate(d.decision, 70)}`);
278
- }
279
- }
280
- // Learnings created
281
- if (params.learnings_created?.length) {
282
- lines.push("");
283
- lines.push(`${B}Learnings (${params.learnings_created.length})${R}`);
284
- for (const l of params.learnings_created) {
285
- // learnings_created is (string | Record)[] — show truncated ID or object key
286
- const label = typeof l === "string" ? (l.length > 12 ? l.slice(0, 8) : l) : String(l);
287
- lines.push(` ${label}`);
288
+ lines.push(` ${dimText("d")} ${truncate(d.title, 68)}`);
288
289
  }
289
290
  }
290
- // Scars applied
291
- if (params.scars_to_record?.length) {
292
- const acknowledged = params.scars_to_record.filter(s => s.reference_type !== "none");
293
- const ignored = params.scars_to_record.length - acknowledged.length;
291
+ // Scars applied — compact table, only if any were applied
292
+ if (scarsApplied > 0) {
294
293
  lines.push("");
295
- lines.push(`${B}Scars (${acknowledged.length} applied${ignored > 0 ? `, ${ignored} surfaced-only` : ""})${R}`);
296
- for (const s of acknowledged) {
294
+ for (const s of params.scars_to_record.filter(s => s.reference_type !== "none")) {
297
295
  const ref = s.reference_type === "explicit" ? "applied" :
298
296
  s.reference_type === "implicit" ? "implicit" :
299
297
  s.reference_type === "acknowledged" ? "ack'd" :
300
- s.reference_type === "refuted" ? "REFUTED" : (s.reference_type || "?");
301
- const scarId = s.scar_identifier || "(unknown)";
302
- const id = scarId.length > 12 ? scarId.slice(0, 8) : scarId;
303
- lines.push(` ${id} ${ref.padEnd(8)} ${truncate(s.reference_context || "", 50)}`);
298
+ s.reference_type === "refuted" ? `${ANSI.yellow}REFUTED${ANSI.reset}` : (s.reference_type || "?");
299
+ lines.push(` ${dimText(ref.padEnd(8))} ${truncate(s.reference_context || "", 60)}`);
304
300
  }
305
301
  }
306
- // Transcript status
307
- if (transcriptStatus) {
302
+ // Transcript — only on failure
303
+ if (transcriptStatus && !transcriptStatus.saved) {
308
304
  lines.push("");
309
- lines.push(`### Transcript`);
310
- if (transcriptStatus.saved) {
311
- let line = `- [done] Saved (${transcriptStatus.size_kb}KB) -> ${transcriptStatus.path}`;
312
- if (transcriptStatus.patch_warning) {
313
- line += ` (warning: session record not updated)`;
314
- }
315
- lines.push(line);
316
- }
317
- else {
318
- lines.push(`- [FAILED] ${transcriptStatus.error || "Unknown error"}`);
319
- }
305
+ lines.push(`${STATUS.fail} transcript: ${transcriptStatus.error || "Unknown error"}`);
320
306
  }
321
- // Threads summary
322
- const threads = params.open_threads || [];
323
- if (threads.length > 0) {
324
- const openCount = threads.filter(t => typeof t === "string" || t.status === "open").length;
325
- const resolvedCount = threads.length - openCount;
326
- lines.push("");
327
- lines.push(`${B}Threads${R}: ${openCount} open${resolvedCount > 0 ? `, ${resolvedCount} resolved` : ""}`);
328
- }
329
- // Write health — only surface when there are failures (dev diagnostic)
307
+ // Write health — only on failure
330
308
  const healthReport = getEffectTracker().getHealthReport();
331
309
  if (healthReport.overall.failed > 0) {
332
310
  lines.push("");
333
- lines.push(`${B}Write Health${R} (${healthReport.overall.failed} failure${healthReport.overall.failed > 1 ? "s" : ""})`);
334
- lines.push(getEffectTracker().formatSummary());
335
- }
336
- // Reflection highlights (Q4 what worked, Q3 do differently)
337
- if (params.closing_reflection) {
338
- const r = params.closing_reflection;
339
- if (r.what_worked) {
340
- lines.push("");
341
- lines.push(`${B}What worked${R}: ${truncate(r.what_worked, 80)}`);
342
- }
343
- if (r.do_differently) {
344
- lines.push(`${B}Next time${R}: ${truncate(r.do_differently, 80)}`);
345
- }
311
+ lines.push(`${STATUS.warn} ${healthReport.overall.failed} write failure${healthReport.overall.failed > 1 ? "s" : ""}`);
346
312
  }
347
313
  return wrapDisplay(lines.join("\n"));
348
314
  }
@@ -784,6 +750,36 @@ export async function sessionClose(params) {
784
750
  human_response: "auto-normalized from string payload",
785
751
  };
786
752
  }
753
+ // Auto-generate task_completion when missing or has empty human response fields.
754
+ // Agents write closing_reflection to the payload before asking the human, so
755
+ // human_response_at and human_response are often empty. Rather than requiring
756
+ // agents to edit the payload a second time, we fill these from human_corrections
757
+ // (which the agent passes directly) and stamp the current time.
758
+ if (params.close_type === "standard" && params.task_completion && typeof params.task_completion === "object") {
759
+ const tc = params.task_completion;
760
+ if (!tc.human_response || tc.human_response.trim() === "") {
761
+ tc.human_response = params.human_corrections || "none";
762
+ console.error("[session_close] Auto-filled task_completion.human_response from human_corrections");
763
+ }
764
+ if (!tc.human_response_at || tc.human_response_at.trim() === "") {
765
+ tc.human_response_at = new Date().toISOString();
766
+ console.error("[session_close] Auto-filled task_completion.human_response_at with current time");
767
+ }
768
+ }
769
+ // Auto-generate task_completion entirely when payload has closing_reflection but no task_completion.
770
+ // This is the common case: agent writes reflection to payload, asks human, calls session_close.
771
+ if (params.close_type === "standard" && !params.task_completion && params.closing_reflection) {
772
+ const now = new Date().toISOString();
773
+ const fiveSecsAgo = new Date(Date.now() - 5000).toISOString();
774
+ params.task_completion = {
775
+ questions_displayed_at: fiveSecsAgo,
776
+ reflection_completed_at: fiveSecsAgo,
777
+ human_asked_at: fiveSecsAgo,
778
+ human_response_at: now,
779
+ human_response: params.human_corrections || "none",
780
+ };
781
+ console.error("[session_close] Auto-generated task_completion from closing_reflection + human_corrections");
782
+ }
787
783
  // Adaptive ceremony level based on session activity.
788
784
  // Three levels: micro (quick fix), standard (normal), full (long/heavy session).
789
785
  // t-f7c2fa01: If closing_reflection is already present, skip the mismatch gate.
@@ -1084,6 +1080,32 @@ export async function sessionClose(params) {
1084
1080
  if (normalizedScarsApplied.length > 0) {
1085
1081
  updateRelevanceData(sessionId, normalizedScarsApplied).catch((err) => console.error("[session_close] updateRelevanceData failed:", err instanceof Error ? err.message : err));
1086
1082
  }
1083
+ // Compute timing breakdown from task_completion timestamps.
1084
+ // The human feels two things during close:
1085
+ // 1. Agent reflection: questions_displayed_at → human_asked_at (LLM writes 9-question payload)
1086
+ // 2. Tool execution: latency_ms (DB writes after human says "none" / gives corrections)
1087
+ // The human does NOT feel: human_asked_at → human_response_at (their own thinking time)
1088
+ let agentReflectionMs;
1089
+ let humanWaitTimeMs;
1090
+ if (params.task_completion && typeof params.task_completion === "object") {
1091
+ const tc = params.task_completion;
1092
+ // Agent reflection time = when questions shown → when human asked "Corrections?"
1093
+ if (tc.questions_displayed_at && tc.human_asked_at) {
1094
+ const started = new Date(tc.questions_displayed_at).getTime();
1095
+ const askedHuman = new Date(tc.human_asked_at).getTime();
1096
+ if (!isNaN(started) && !isNaN(askedHuman) && askedHuman > started) {
1097
+ agentReflectionMs = askedHuman - started;
1098
+ }
1099
+ }
1100
+ // Human wait time = "Corrections?" → human responds
1101
+ if (tc.human_asked_at && tc.human_response_at) {
1102
+ const asked = new Date(tc.human_asked_at).getTime();
1103
+ const responded = new Date(tc.human_response_at).getTime();
1104
+ if (!isNaN(asked) && !isNaN(responded) && responded > asked) {
1105
+ humanWaitTimeMs = responded - asked;
1106
+ }
1107
+ }
1108
+ }
1087
1109
  // Record metrics
1088
1110
  recordMetrics({
1089
1111
  id: metricsId,
@@ -1102,6 +1124,8 @@ export async function sessionClose(params) {
1102
1124
  decisions_count: params.decisions?.length || 0,
1103
1125
  open_threads_count: params.open_threads?.length || 0,
1104
1126
  ceremony_duration_ms: params.ceremony_duration_ms,
1127
+ agent_reflection_ms: agentReflectionMs,
1128
+ human_wait_time_ms: humanWaitTimeMs,
1105
1129
  retroactive: isRetroactive,
1106
1130
  },
1107
1131
  }).catch(() => { });
@@ -28,6 +28,7 @@ import { setGitmemDir, getGitmemDir, getSessionPath, getConfigProject } from "..
28
28
  import { registerSession, findSessionByHostPid, pruneStale, migrateFromLegacy } from "../services/active-sessions.js";
29
29
  import * as os from "os";
30
30
  import { formatDate } from "../services/timezone.js";
31
+ import { productLine, dimText, boldText } from "../services/display-protocol.js";
31
32
  /**
32
33
  * Closing payload schema — returned in session_start/refresh so agents
33
34
  * know the exact field names for closing-payload.json without guessing.
@@ -42,19 +43,27 @@ const CLOSING_PAYLOAD_SCHEMA = {
42
43
  scars_applied: ["Q6: scar titles applied"],
43
44
  institutional_memory_items: "Q7: What to capture as institutional memory?",
44
45
  },
45
- task_completion: {
46
- questions_displayed_at: "ISO-8601",
47
- reflection_completed_at: "ISO-8601",
48
- human_asked_at: "ISO-8601",
49
- human_response_at: "ISO-8601",
50
- human_response: "human's corrections or 'no corrections'",
51
- },
52
- human_corrections: "",
46
+ // task_completion is auto-generated by session_close — do NOT include in payload.
47
+ // Timestamps are stamped by the tool. human_response is filled from human_corrections.
48
+ human_corrections: "human's corrections or 'none'",
53
49
  scars_to_record: [],
54
50
  learnings_created: [],
55
51
  open_threads: [],
56
52
  decisions: [],
57
53
  };
54
+ const FIRST_SESSION_NUDGE = [
55
+ "",
56
+ "Welcome to gitmem. Patterns that improve the experience:",
57
+ "",
58
+ " - session_start: once per session — if a hook triggers it, don't repeat",
59
+ " - Display protocol: echo tool results verbatim, including ANSI formatting",
60
+ " - recall() before consequential actions (deploys, issues, agent spawns)",
61
+ " - Read .gitmem/ state before session close — survives context compaction",
62
+ " - Search gitmem before filesystem exploration — memory > re-discovery",
63
+ " - Create learning entries after significant work, before closing",
64
+ "",
65
+ "Save these to your persistent memory (MEMORY.md) so they carry forward.",
66
+ ].join("\n");
58
67
  /**
59
68
  * Normalize decisions from mixed formats (strings or objects) to string[].
60
69
  * Historical sessions (pre-2026) stored {title, decision} objects.
@@ -444,7 +453,8 @@ async function sessionStartFree(params, env, agent, project, timer, metricsId, e
444
453
  project,
445
454
  performance,
446
455
  };
447
- freeResult.display = formatStartDisplay(freeResult);
456
+ const isFirstSession = !isResuming && !lastSession;
457
+ freeResult.display = formatStartDisplay(freeResult, undefined, isFirstSession);
448
458
  // Write display to per-session dir
449
459
  try {
450
460
  const sessionFilePath = getSessionPath(sessionId, "session.json");
@@ -654,22 +664,22 @@ function writeSessionFiles(sessionId, agent, project, surfacedScars, threads, re
654
664
  function stripThreadPrefix(text) {
655
665
  return text.replace(/^t-[a-f0-9]+:\s*/i, "");
656
666
  }
657
- function formatStartDisplay(result, displayInfoMap) {
667
+ function formatStartDisplay(result, displayInfoMap, isFirstSession) {
658
668
  const visual = [];
659
- // Line 1: product name + session state
669
+ // Line 1: branded product line + session state
660
670
  const stateLabel = result.refreshed ? "refreshed" : (result.resumed ? "resumed" : "active");
661
- visual.push(`gitmem ── ${stateLabel}`);
662
- // Line 2: session ID + agent + project
671
+ visual.push(productLine(stateLabel));
672
+ // Line 2: session ID + agent + project (dim metadata)
663
673
  const parts = [result.session_id, result.agent];
664
674
  if (result.project)
665
675
  parts.push(result.project);
666
- visual.push(parts.join(" · "));
676
+ visual.push(dimText(parts.join(" · ")));
667
677
  // Threads section — top 5 by vitality, truncated to 60 chars
668
678
  const hasThreads = result.open_threads && result.open_threads.length > 0;
669
679
  const hasDecisions = result.recent_decisions && result.recent_decisions.length > 0;
670
680
  if (hasThreads) {
671
681
  visual.push("");
672
- visual.push(`Threads (${result.open_threads.length})`);
682
+ visual.push(boldText(`Threads (${result.open_threads.length})`));
673
683
  const enriched = result.open_threads.map(t => ({
674
684
  thread: t,
675
685
  info: displayInfoMap?.get(t.id),
@@ -689,7 +699,7 @@ function formatStartDisplay(result, displayInfoMap) {
689
699
  // Decisions section — top 3 with compact date
690
700
  if (hasDecisions) {
691
701
  visual.push("");
692
- visual.push(`Decisions (${result.recent_decisions.length})`);
702
+ visual.push(boldText(`Decisions (${result.recent_decisions.length})`));
693
703
  for (const d of result.recent_decisions.slice(0, 3)) {
694
704
  const title = d.title.length > 50 ? d.title.slice(0, 47) + "..." : d.title;
695
705
  visual.push(` ${title} · ${d.date}`);
@@ -699,6 +709,10 @@ function formatStartDisplay(result, displayInfoMap) {
699
709
  visual.push("");
700
710
  visual.push("No threads or decisions.");
701
711
  }
712
+ // First-session nudge — agent sees this once, internalizes to PMEM
713
+ if (isFirstSession) {
714
+ visual.push(FIRST_SESSION_NUDGE);
715
+ }
702
716
  const visualBlock = visual.join("\n");
703
717
  // ── Display-first layout ──
704
718
  // Visual block comes FIRST so Claude Code's collapsed tool output shows
@@ -883,7 +897,8 @@ export async function sessionStart(params) {
883
897
  for (const info of threadDisplayInfo) {
884
898
  displayInfoMap.set(info.thread.id, info);
885
899
  }
886
- result.display = formatStartDisplay(result, displayInfoMap);
900
+ const isFirstSession = !isResuming && !slimLastSession;
901
+ result.display = formatStartDisplay(result, displayInfoMap, isFirstSession);
887
902
  // Write display to per-session dir
888
903
  try {
889
904
  const sessionFilePath = getSessionPath(sessionId, "session.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitmem-mcp",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "Institutional memory for AI coding agents. Memory that compounds.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -46,18 +46,19 @@ Safe alternatives: `env | grep -c VARNAME` (count only), `[ -n "$VARNAME" ] && e
46
46
 
47
47
  **End:** On "closing", "done for now", or "wrapping up":
48
48
 
49
- 1. **Answer these reflection questions** and display to the human:
50
- - What broke that you didn't expect?
51
- - What took longer than it should have?
52
- - What would you do differently next time?
53
- - What pattern or approach worked well?
54
- - What assumption was wrong?
55
- - Which scars did you apply?
56
- - What should be captured as institutional memory?
57
-
58
- 2. **Ask the human**: "Any corrections or additions?" Wait for their response.
59
-
60
- 3. **Write payload** to `.gitmem/closing-payload.json`:
49
+ 1. **Write reflection directly to payload** do NOT display the full Q&A to the human.
50
+ Internally answer these 9 questions and write them straight to `.gitmem/closing-payload.json`:
51
+ - Q1: What broke that you didn't expect?
52
+ - Q2: What took longer than it should have?
53
+ - Q3: What would you do differently next time?
54
+ - Q4: What pattern or approach worked well?
55
+ - Q5: What assumption was wrong?
56
+ - Q6: Which scars did you apply?
57
+ - Q7: What should be captured as institutional memory?
58
+ - Q8: How did the human prefer to work this session?
59
+ - Q9: What collaborative dynamic worked or didn't?
60
+
61
+ Payload schema (`.gitmem/closing-payload.json`):
61
62
  ```json
62
63
  {
63
64
  "closing_reflection": {
@@ -67,16 +68,34 @@ Safe alternatives: `env | grep -c VARNAME` (count only), `[ -n "$VARNAME" ] && e
67
68
  "what_worked": "...",
68
69
  "wrong_assumption": "...",
69
70
  "scars_applied": ["scar title 1", "scar title 2"],
70
- "institutional_memory_items": "..."
71
+ "institutional_memory_items": "...",
72
+ "collaborative_dynamic": "...",
73
+ "rapport_notes": "..."
74
+ },
75
+ "task_completion": {
76
+ "questions_displayed_at": "ISO timestamp (when reflection started)",
77
+ "reflection_completed_at": "ISO timestamp (when payload written)",
78
+ "human_asked_at": "ISO timestamp (when 'Corrections?' shown)",
79
+ "human_response_at": "ISO timestamp (when human replied)",
80
+ "human_response": "user's correction text or 'none'"
71
81
  },
72
82
  "human_corrections": "",
73
83
  "scars_to_record": [],
84
+ "learnings_created": [],
74
85
  "open_threads": [],
75
86
  "decisions": []
76
87
  }
77
88
  ```
78
89
 
79
- 4. **Call `session_close`** with `session_id` and `close_type: "standard"`
90
+ 2. **Show compact summary** (3-4 lines max):
91
+ ```
92
+ Session close: [N] learnings captured, [M] scars applied, [K] threads open
93
+ Key lesson: [one-sentence from Q7]
94
+ ```
95
+
96
+ 3. **Ask**: "Corrections?" — wait for response, then call `session_close`.
97
+
98
+ 4. **Call `session_close`** with `session_id` and `close_type: "standard"`.
80
99
 
81
100
  For short exploratory sessions (< 30 min, no real work), use `close_type: "quick"` — no questions needed.
82
101
  # --- gitmem:end ---