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.
- package/README.md +1 -0
- package/assets/schema/pal-settings.schema.json +8 -0
- package/assets/skills/research-entity/SKILL.md +6 -0
- package/assets/templates/AGENTS.md.template +1 -0
- package/assets/templates/pal-settings.json +2 -0
- package/package.json +1 -1
- package/src/cli/knowledge-research.ts +6 -1
- package/src/cli/tree.ts +2 -0
- package/src/cli/work.ts +344 -0
- package/src/hooks/StopOrchestrator.ts +10 -0
- package/src/hooks/handlers/entity-extraction.ts +2 -0
- package/src/hooks/handlers/inject-retrieval.ts +2 -0
- package/src/hooks/lib/claim-check.ts +20 -12
- package/src/hooks/lib/claim-log.ts +1 -1
- package/src/hooks/lib/claude-md.ts +11 -2
- package/src/hooks/lib/company-register-router.ts +24 -0
- package/src/hooks/lib/company-register.ts +25 -0
- package/src/hooks/lib/context.ts +3 -0
- package/src/hooks/lib/entity-extraction.ts +1 -0
- package/src/hooks/lib/entity-research.ts +88 -3
- package/src/hooks/lib/hungarian-registry.ts +194 -0
- package/src/hooks/lib/work-tree-context.ts +25 -0
- package/src/hooks/lib/work-tree-gate.ts +67 -0
- package/src/hooks/lib/work-tree-git.ts +98 -0
- package/src/hooks/lib/work-tree-render.ts +157 -0
- package/src/hooks/lib/work-tree.ts +307 -0
- package/src/tools/lib/command.ts +8 -0
package/src/hooks/lib/context.ts
CHANGED
|
@@ -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);
|
|
@@ -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
|
|
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(
|
|
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
|
+
}
|