portable-agent-layer 0.110.0 → 0.112.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.
@@ -18,6 +18,7 @@ import { sessionDir } from "./session-dir";
18
18
  import * as settings from "./settings";
19
19
  import { readFramePrinciples } from "./wisdom";
20
20
  import { lastSessionTitle } from "./work-tracking";
21
+ import { loadWorkTreeContext } from "./work-tree-context";
21
22
 
22
23
  /** Load and concatenate loadAtStartup files */
23
24
  function loadStartupFiles(): string {
@@ -223,10 +224,12 @@ export function buildSystemReminder(
223
224
  !opts.withoutHandoff && settings.isEnabled("handoff")
224
225
  ? loadHandoffContext(sessionDir())
225
226
  : "";
227
+ const workTree = loadWorkTreeContext(sessionDir());
226
228
  const dueNudges = opts.agent === "cursor" ? dueNudgeSections() : [];
227
229
  const parts: string[] = [];
228
230
  if (startup) parts.push(startup);
229
231
  if (handoff) parts.push(handoff);
232
+ if (workTree) parts.push(workTree);
230
233
  parts.push(...dueNudges);
231
234
  if (selfModel) parts.push(selfModel);
232
235
  if (wisdom) parts.push(wisdom);
@@ -49,6 +49,7 @@ export interface ResearchProfile {
49
49
  taxNumber: string;
50
50
  seat: string;
51
51
  status: string;
52
+ activity: string;
52
53
  managers: string[];
53
54
  url: string;
54
55
  };
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { IngestInput, IngestResult } from "../../tools/knowledge/ingest";
8
+ import type { RegisterEntry } from "./company-register";
8
9
  import {
9
10
  closedObject,
10
11
  type ExtractedEntity,
@@ -27,6 +28,7 @@ export interface ResearchDeps {
27
28
  principal: string;
28
29
  infer: typeof inference;
29
30
  today?: string;
31
+ registry?: (name: string) => Promise<RegisterEntry | null>;
30
32
  }
31
33
 
32
34
  const MAX_TARGETS = 3;
@@ -83,6 +85,7 @@ export function researchSchema() {
83
85
  taxNumber: text,
84
86
  seat: text,
85
87
  status: text,
88
+ activity: text,
86
89
  managers: list(text),
87
90
  url: text,
88
91
  }),
@@ -96,7 +99,7 @@ const CHECKLIST = {
96
99
  "- website: the official website.",
97
100
  "- linkedin: the LinkedIn company page.",
98
101
  "- socials: other official accounts (X, Facebook, Instagram, YouTube, GitHub).",
99
- "- registry: its entry in the official company register of its country: registered name, registration number, tax number, registered seat, status (active, in liquidation…), managing directors, and the URL you read it on. For a Hungarian company (Kft., Zrt., Bt., Nyrt., a .hu site) that is the cégjegyzék: e-cegjegyzek.hu, or public mirrors such as ceginformacio.hu, nemzeticegtar.hu or opten.hu.",
102
+ "- registry: its entry in the official company register of its country: registered name, registration number, tax number, registered seat, status (active, in liquidation…), main activity, managing directors, and the URL you read it on. For a Hungarian company (Kft., Zrt., Bt., Nyrt., a .hu site) that is the cégjegyzék: e-cegjegyzek.hu, or public mirrors such as ceginformacio.hu, nemzeticegtar.hu or opten.hu.",
100
103
  "- news: up to five news items or public statements from the last 12 months, newest first.",
101
104
  "- summary: two or three sentences on what the company does, its size and where it is based.",
102
105
  ],
@@ -124,13 +127,30 @@ export function researchSystem(principal: string, kind: ResearchTarget["kind"]):
124
127
  ].join("\n");
125
128
  }
126
129
 
127
- function researchUser(target: ResearchTarget, today: string): string {
130
+ function registryFacts(entry: RegisterEntry): string {
131
+ return [
132
+ "The company register, already read: use it to tell this company from namesakes and do not search the register again.",
133
+ `registered name: ${entry.fullName || entry.name}`,
134
+ `registration number: ${entry.number}`,
135
+ entry.seat && `seat: ${entry.seat}`,
136
+ entry.activity && `main activity: ${entry.activity}`,
137
+ ]
138
+ .filter(Boolean)
139
+ .join("\n");
140
+ }
141
+
142
+ function researchUser(
143
+ target: ResearchTarget,
144
+ today: string,
145
+ registry: RegisterEntry | null
146
+ ): string {
128
147
  return [
129
148
  `Today is ${today}.`,
130
149
  `${target.kind}: ${target.name}`,
131
150
  target.organization && `organization: ${target.organization}`,
132
151
  target.fact &&
133
152
  `what ${target.kind === "person" ? "was said about them" : "was said about it"}: ${target.fact}`,
153
+ registry && registryFacts(registry),
134
154
  ]
135
155
  .filter(Boolean)
136
156
  .join("\n");
@@ -166,13 +186,76 @@ export type ResearchOutcome =
166
186
  | { found: true; profile: ResearchProfile }
167
187
  | { found: false; reason: string };
168
188
 
189
+ async function registryEntry(
190
+ target: ResearchTarget,
191
+ deps: ResearchDeps
192
+ ): Promise<RegisterEntry | null> {
193
+ if (target.kind !== "company" || !deps.registry) return null;
194
+ try {
195
+ return await deps.registry(target.name);
196
+ } catch (err) {
197
+ logError(`entity-research:registry:${target.name}`, err);
198
+ return null;
199
+ }
200
+ }
201
+
202
+ function registryOnlyProfile(entry: RegisterEntry): ResearchProfile {
203
+ const founded = entry.founded ? `, founded ${entry.founded}` : "";
204
+ const what = entry.activity || `company registered in ${entry.country}`;
205
+ return {
206
+ match: "sure",
207
+ summary: `${entry.name}: ${what}. Seat: ${entry.seat}${founded}.`,
208
+ role: "",
209
+ organization: "",
210
+ website: "",
211
+ linkedin: "",
212
+ socials: [],
213
+ registry: registryOf(entry),
214
+ news: [],
215
+ sources: [],
216
+ };
217
+ }
218
+
219
+ function registryOf(entry: RegisterEntry): ResearchProfile["registry"] {
220
+ return {
221
+ name: entry.fullName || entry.name,
222
+ number: entry.number,
223
+ taxNumber: entry.taxNumber,
224
+ seat: entry.seat,
225
+ status: entry.status,
226
+ activity: entry.activity,
227
+ managers: entry.managers,
228
+ url: entry.url,
229
+ };
230
+ }
231
+
232
+ /** What the register says replaces what the model read, and an exact register match is
233
+ * enough to keep the profile even when the web turned up nothing. */
234
+ function withRegistry(
235
+ profile: ResearchProfile | null,
236
+ entry: RegisterEntry
237
+ ): ResearchProfile {
238
+ const base =
239
+ profile?.match === "sure" ? withWebUrlsOnly(profile) : registryOnlyProfile(entry);
240
+ return {
241
+ ...base,
242
+ registry: registryOf(entry),
243
+ sources: [...new Set([...entry.sources, ...base.sources])],
244
+ };
245
+ }
246
+
169
247
  export async function researchEntity(
170
248
  target: ResearchTarget,
171
249
  deps: ResearchDeps
172
250
  ): Promise<ResearchOutcome> {
251
+ const registry = await registryEntry(target, deps);
173
252
  const result = await deps.infer({
174
253
  system: researchSystem(deps.principal, target.kind),
175
- user: researchUser(target, deps.today ?? new Date().toISOString().slice(0, 10)),
254
+ user: researchUser(
255
+ target,
256
+ deps.today ?? new Date().toISOString().slice(0, 10),
257
+ registry
258
+ ),
176
259
  tier: "medium",
177
260
  timeout: RESEARCH_TIMEOUT_MS,
178
261
  jsonSchema: researchSchema(),
@@ -180,6 +263,7 @@ export async function researchEntity(
180
263
  web: true,
181
264
  });
182
265
  const profile = result.success ? parseProfile(result.output) : null;
266
+ if (registry) return { found: true, profile: withRegistry(profile, registry) };
183
267
  if (!profile) return { found: false, reason: result.error ?? "no usable reply" };
184
268
  if (profile.match !== "sure")
185
269
  return { found: false, reason: `match: ${profile.match}. ${profile.summary}`.trim() };
@@ -243,6 +327,7 @@ function registryLines(r: ResearchProfile["registry"]): string[] {
243
327
  ["Tax number", r.taxNumber],
244
328
  ["Seat", r.seat],
245
329
  ["Status", r.status],
330
+ ["Main activity", r.activity],
246
331
  ["Managing directors", r.managers.join(", ")],
247
332
  ["Registry entry", r.url],
248
333
  ];
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The Hungarian company register, read directly. Web search does not index it, so a
3
+ * young or small Kft. is invisible to a search-only lookup. Only a name ending in a
4
+ * Hungarian legal form is recognized: ceginfo.hu resolves the exact name to its
5
+ * registration number, nemzeticegtar.hu shows the record for free.
6
+ */
7
+
8
+ import type { CompanyRegister, Fetch, RegisteredCompany } from "./company-register";
9
+
10
+ interface Page {
11
+ url: string;
12
+ html: string;
13
+ }
14
+
15
+ const CEGINFO = "https://ceginfo.hu/";
16
+ const NEMZETICEGTAR = "https://www.nemzeticegtar.hu/";
17
+ const USER_AGENT = "Mozilla/5.0 (compatible; PAL knowledge research)";
18
+ const REQUEST_TIMEOUT_MS = 15_000;
19
+ const MAX_REDIRECTS = 5;
20
+ const REDIRECTS = new Set([301, 302, 303, 307, 308]);
21
+ const SEARCH_TOKEN_RE = /name="ceginfo_csrf_token" value="([^"]+)"/;
22
+ const OFFICERS_RE = /Cégjegyzésre jogosultak<\/h3>([\s\S]*?)<\/ul>/;
23
+ const PROCEEDINGS_RE = /<p[^>]*>([^<]*(?:felszámolási|végelszámolási)[^<]*)<\/p>/;
24
+
25
+ const LONG_FORMS: Array<[RegExp, string]> = [
26
+ [/korlátolt felelősségű társaság$/i, "kft"],
27
+ [/zártkörűen működő részvénytársaság$/i, "zrt"],
28
+ [/nyilvánosan működő részvénytársaság$/i, "nyrt"],
29
+ [/betéti társaság$/i, "bt"],
30
+ [/közkereseti társaság$/i, "kkt"],
31
+ ];
32
+ const SHORT_FORM_RE = /(?:^|\s)(?:kft|zrt|nyrt|bt|kkt|rt)\.?$/i;
33
+
34
+ function isHungarianCompanyName(name: string): boolean {
35
+ return SHORT_FORM_RE.test(name) || LONG_FORMS.some(([re]) => re.test(name));
36
+ }
37
+
38
+ function comparable(name: string): string {
39
+ const short = LONG_FORMS.reduce((n, [re, form]) => n.replace(re, form), name);
40
+ return short.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
41
+ }
42
+
43
+ const ENTITIES: Record<string, string> = { amp: "&", quot: '"', nbsp: " " };
44
+
45
+ function decoded(html: string): string {
46
+ return html.replace(/&(#x?[0-9a-f]+|\w+);/gi, (whole, code: string) => {
47
+ if (code.startsWith("#x"))
48
+ return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
49
+ if (code.startsWith("#")) return String.fromCodePoint(Number(code.slice(1)));
50
+ return ENTITIES[code] ?? whole;
51
+ });
52
+ }
53
+
54
+ function plainText(html: string): string {
55
+ return decoded(html.replace(/<[^>]+>/g, ""))
56
+ .replace(/\s+/g, " ")
57
+ .trim();
58
+ }
59
+
60
+ function remember(response: Response, jar: Map<string, string>): void {
61
+ for (const cookie of response.headers.getSetCookie()) {
62
+ const [pair] = cookie.split(";");
63
+ const at = pair.indexOf("=");
64
+ jar.set(pair.slice(0, at), pair.slice(at + 1));
65
+ }
66
+ }
67
+
68
+ /** One browsing session: cookies carried across requests and redirects followed by hand,
69
+ * because a search result lives in the session the search was posted from. */
70
+ function session(fetchImpl: Fetch) {
71
+ const jar = new Map<string, string>();
72
+ return async (url: string, form?: URLSearchParams): Promise<Page> => {
73
+ let current = url;
74
+ let body = form;
75
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
76
+ const response = await fetchImpl(current, {
77
+ method: body ? "POST" : "GET",
78
+ body,
79
+ redirect: "manual",
80
+ headers: {
81
+ "user-agent": USER_AGENT,
82
+ cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
83
+ },
84
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
85
+ });
86
+ remember(response, jar);
87
+ const location = response.headers.get("location");
88
+ if (location && REDIRECTS.has(response.status)) {
89
+ current = new URL(location, current).href;
90
+ body = undefined;
91
+ continue;
92
+ }
93
+ if (!response.ok) throw new Error(`${current}: HTTP ${response.status}`);
94
+ return { url: current, html: await response.text() };
95
+ }
96
+ throw new Error(`${url}: too many redirects`);
97
+ };
98
+ }
99
+
100
+ interface Hit {
101
+ name: string;
102
+ number: string;
103
+ slug: string;
104
+ url: string;
105
+ }
106
+
107
+ function searchHits(html: string): Hit[] {
108
+ const results = html.matchAll(
109
+ /<a href="([^"]*\/ceg-adatlap\/([a-z0-9-]+)-(\d{10})\.html)"[^>]*>[\s\S]*?<h2[^>]*>([\s\S]*?)<\/h2>/g
110
+ );
111
+ return Array.from(results, ([, url, slug, number, title]) => ({
112
+ name: plainText(title.replace(/<small[\s\S]*?<\/small>/g, "")),
113
+ number,
114
+ slug,
115
+ url: url.replace(/(?<!:)\/\//g, "/"),
116
+ }));
117
+ }
118
+
119
+ async function findRegistered(
120
+ name: string,
121
+ browse: ReturnType<typeof session>
122
+ ): Promise<Hit | null> {
123
+ const home = await browse(CEGINFO);
124
+ const token = SEARCH_TOKEN_RE.exec(home.html)?.[1];
125
+ if (!token) throw new Error("ceginfo.hu: the search form has no token");
126
+ const results = await browse(
127
+ `${CEGINFO}cegkereso/rapid`,
128
+ new URLSearchParams({ ceginfo_csrf_token: token, rapid: name })
129
+ );
130
+ const exact = searchHits(results.html).filter(
131
+ (hit) => comparable(hit.name) === comparable(name)
132
+ );
133
+ return exact.length === 1 ? exact[0] : null;
134
+ }
135
+
136
+ function field(html: string, label: string): string {
137
+ const value = new RegExp(
138
+ String.raw`<h6[^>]*>\s*${label}\s*</h6>\s*<p[^>]*>([\s\S]*?)</p>`,
139
+ "i"
140
+ ).exec(html)?.[1];
141
+ return value ? plainText(value) : "";
142
+ }
143
+
144
+ /** Names only: the register also prints each officer's mother's name and home address. */
145
+ function managers(html: string): string[] {
146
+ const block = OFFICERS_RE.exec(html)?.[1] ?? "";
147
+ return Array.from(block.matchAll(/<strong[^>]*>([\s\S]*?)<\/strong>/g), ([, n]) =>
148
+ plainText(n)
149
+ );
150
+ }
151
+
152
+ function proceedings(html: string): string {
153
+ const sentence = PROCEEDINGS_RE.exec(html)?.[1];
154
+ return sentence ? plainText(sentence) : "";
155
+ }
156
+
157
+ function formattedNumber(digits: string): string {
158
+ return `${digits.slice(0, 2)}-${digits.slice(2, 4)}-${digits.slice(4)}`;
159
+ }
160
+
161
+ function entryFrom(hit: Hit, record: Page): RegisteredCompany {
162
+ const html = record.html;
163
+ return {
164
+ name: hit.name,
165
+ fullName: field(html, "Teljes név"),
166
+ number: formattedNumber(hit.number),
167
+ taxNumber: field(html, "Adószám"),
168
+ seat: field(html, "székhely"),
169
+ status: proceedings(html),
170
+ activity: field(html, "Főtevékenység"),
171
+ founded: field(html, "Alapítás éve"),
172
+ managers: managers(html),
173
+ url: record.url,
174
+ sources: [hit.url, record.url],
175
+ };
176
+ }
177
+
178
+ /** Null for anything but one exact match. */
179
+ async function lookupHungarianCompany(
180
+ name: string,
181
+ fetchImpl: Fetch
182
+ ): Promise<RegisteredCompany | null> {
183
+ const browse = session(fetchImpl);
184
+ const hit = await findRegistered(name, browse);
185
+ if (!hit) return null;
186
+ const record = await browse(`${NEMZETICEGTAR}${hit.slug}-c${hit.number}.html`);
187
+ return entryFrom(hit, record);
188
+ }
189
+
190
+ export const hungarianRegister: CompanyRegister = {
191
+ country: "Hungary",
192
+ recognizes: isHungarianCompanyName,
193
+ lookup: lookupHungarianCompany,
194
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where the work tree enters an agent's context: the block every new or
3
+ * compacted session starts with, and the line every prompt carries.
4
+ */
5
+
6
+ import { reactionTo } from "./interaction-reaction";
7
+ import { sessionDir } from "./session-dir";
8
+ import { isOptedIn } from "./settings";
9
+ import { readTree, scopeOf } from "./work-tree";
10
+ import { renderPromptLine, renderSessionContext } from "./work-tree-render";
11
+
12
+ export function loadWorkTreeContext(cwd: string = sessionDir()): string {
13
+ if (!isOptedIn("workTree")) return "";
14
+ return renderSessionContext(readTree(scopeOf(cwd)));
15
+ }
16
+
17
+ export function workTreePromptLine(
18
+ userText: string,
19
+ cwd: string = sessionDir()
20
+ ): string | null {
21
+ if (!isOptedIn("workTree")) return null;
22
+ const newTopic = userText !== "" && reactionTo(userText, undefined) === "new-topic";
23
+ const line = renderPromptLine(readTree(scopeOf(cwd)), newTopic);
24
+ return line ? `<system-reminder>${line}</system-reminder>` : null;
25
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The end-of-turn check that keeps the work tree true: a turn that produced
3
+ * something (a commit, a branch, a PR, a published artifact) and recorded
4
+ * nothing in the tree is sent back once to record it.
5
+ */
6
+
7
+ import { callsThisTurn, commandsThisTurn } from "./claim-check";
8
+ import { alreadySentBack, transcriptLines } from "./claim-log";
9
+ import { sessionDir } from "./session-dir";
10
+ import { isOptedIn } from "./settings";
11
+ import type { StopTurnPayload } from "./stop";
12
+ import { readTree, scopeOf } from "./work-tree";
13
+ import { attachGitProgress } from "./work-tree-git";
14
+ import { renderPromptLine } from "./work-tree-render";
15
+
16
+ const RECORDS_WORK_RE = /\bpal\s+cli\s+work\b/;
17
+
18
+ const PRODUCERS: { what: string; re: RegExp }[] = [
19
+ { what: "committed", re: /\bgit\s+commit\b/ },
20
+ { what: "made a branch", re: /\bgit\s+(?:checkout\s+-b|switch\s+-c|branch\s+(?!-)\S)/ },
21
+ { what: "opened a PR", re: /\bgh\s+pr\s+create\b/ },
22
+ ];
23
+
24
+ const ARTIFACT_TOOLS: ReadonlySet<string> = new Set(["Artifact"]);
25
+ const NON_PUBLISH_ACTION_RE = /"action":"(?!publish")/;
26
+
27
+ function producedThisTurn(commands: string[], lines: string[]): string[] {
28
+ const produced = PRODUCERS.filter((p) => commands.some((c) => p.re.test(c))).map(
29
+ (p) => p.what
30
+ );
31
+ const artifacts = callsThisTurn(lines, ARTIFACT_TOOLS) ?? [];
32
+ if (artifacts.some((call) => !NON_PUBLISH_ACTION_RE.test(call)))
33
+ produced.push("published an artifact");
34
+ return produced;
35
+ }
36
+
37
+ function sendBackReason(produced: string[], scope: string): string {
38
+ const where = renderPromptLine(readTree(scope), false) ?? "Work: nothing recorded yet";
39
+ return [
40
+ `This turn ${produced.join(", ")} but recorded nothing in the work tree (${where}).`,
41
+ 'Record where the work stands with `pal cli work stopped "…"`, plus `decide` or `attach` where they fit;',
42
+ 'if no node fits, open one first with `pal cli work open "…"` (`--root` for unrelated work).',
43
+ ].join(" ");
44
+ }
45
+
46
+ export function workTreeSendBack(
47
+ payload: StopTurnPayload,
48
+ lines: string[],
49
+ cwd: string
50
+ ): string | null {
51
+ if (!isOptedIn("workTree") || !isOptedIn("workTreeGate")) return null;
52
+ if (alreadySentBack(payload)) return null;
53
+ const commands = commandsThisTurn(lines);
54
+ if (commands === null || commands.some((c) => RECORDS_WORK_RE.test(c))) return null;
55
+ const produced = producedThisTurn(commands, lines);
56
+ return produced.length > 0 ? sendBackReason(produced, scopeOf(cwd)) : null;
57
+ }
58
+
59
+ /** The reason to send the turn back with, or null after attaching what git moved. */
60
+ export function stopWorkTree(payload: StopTurnPayload | null): string | null {
61
+ if (!payload || !isOptedIn("workTree")) return null;
62
+ const cwd = sessionDir();
63
+ const sendBack = workTreeSendBack(payload, transcriptLines(payload), cwd);
64
+ if (sendBack) return sendBack;
65
+ attachGitProgress(cwd);
66
+ return null;
67
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * New commits and branch switches land on the active work-tree node at the end
3
+ * of a turn, so the tree knows what the work produced without being told.
4
+ */
5
+
6
+ import { spawnSync } from "node:child_process";
7
+ import { appendEvent, readTree, scopeOf } from "./work-tree";
8
+
9
+ const GIT_TIMEOUT_MS = 1000;
10
+ const MAX_COMMITS = 20;
11
+
12
+ interface GitSnapshot {
13
+ branch: string;
14
+ head: string;
15
+ email: string;
16
+ }
17
+
18
+ interface Commit {
19
+ sha: string;
20
+ subject: string;
21
+ }
22
+
23
+ function git(cwd: string, ...args: string[]): string | null {
24
+ const result = spawnSync("git", ["-C", cwd, ...args], {
25
+ encoding: "utf-8",
26
+ timeout: GIT_TIMEOUT_MS,
27
+ });
28
+ return result.status === 0 ? result.stdout.trim() : null;
29
+ }
30
+
31
+ function snapshot(cwd: string): GitSnapshot | null {
32
+ const head = git(cwd, "rev-parse", "HEAD");
33
+ const branch = git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
34
+ if (!head || !branch) return null;
35
+ return { head, branch, email: git(cwd, "config", "user.email") ?? "" };
36
+ }
37
+
38
+ /** Only the user's own commits: a pull brings other people's work, which this turn did not make. */
39
+ function ownCommitsSince(cwd: string, from: string, to: string, email: string): Commit[] {
40
+ const log = git(
41
+ cwd,
42
+ "log",
43
+ `-${MAX_COMMITS}`,
44
+ "--format=%h%x09%ae%x09%s",
45
+ `${from}..${to}`
46
+ );
47
+ if (!log) return [];
48
+ return log
49
+ .split("\n")
50
+ .map((line) => line.split("\t"))
51
+ .filter(([, author]) => author === email)
52
+ .map(([sha, , subject]) => ({ sha, subject }))
53
+ .reverse();
54
+ }
55
+
56
+ /** A scope with no work yet is left alone, so visiting a repo writes nothing. */
57
+ export function attachGitProgress(
58
+ cwd: string,
59
+ file?: string,
60
+ now: Date = new Date()
61
+ ): void {
62
+ const scope = scopeOf(cwd);
63
+ const tree = readTree(scope, file);
64
+ if (tree.nodes.size === 0) return;
65
+ const current = snapshot(cwd);
66
+ if (!current) return;
67
+ const cursor = tree.gitCursor;
68
+ if (cursor?.head === current.head && cursor.branch === current.branch) return;
69
+
70
+ if (cursor && tree.active) {
71
+ if (cursor.branch !== current.branch)
72
+ appendEvent(
73
+ scope,
74
+ tree.active,
75
+ "attach",
76
+ { kind: "branch", ref: current.branch },
77
+ file,
78
+ now
79
+ );
80
+ for (const commit of ownCommitsSince(cwd, cursor.head, current.head, current.email))
81
+ appendEvent(
82
+ scope,
83
+ tree.active,
84
+ "attach",
85
+ { kind: "commit", ref: commit.sha, label: commit.subject },
86
+ file,
87
+ now
88
+ );
89
+ }
90
+ appendEvent(
91
+ scope,
92
+ "",
93
+ "git-cursor",
94
+ { branch: current.branch, head: current.head },
95
+ file,
96
+ now
97
+ );
98
+ }