honestweek 0.2.0 → 0.3.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/README.md +252 -197
- package/SKILL.md +25 -74
- package/bin/honestweek.mjs +81 -24
- package/flows/client.md +19 -0
- package/flows/digest.md +11 -0
- package/flows/mine.md +18 -0
- package/flows/view.md +45 -0
- package/flows/weekly.md +29 -0
- package/lib/ask.mjs +1142 -0
- package/lib/build.mjs +12 -3
- package/lib/config-lookup.mjs +179 -0
- package/lib/config.mjs +20 -0
- package/lib/demo/week.mjs +3 -0
- package/lib/digest-carry.mjs +3 -2
- package/lib/digest-store.mjs +3 -2
- package/lib/digest.mjs +21 -14
- package/lib/discover.mjs +28 -22
- package/lib/emit/index.mjs +18 -6
- package/lib/harvest.mjs +19 -1
- package/lib/history.mjs +10 -2
- package/lib/init.mjs +241 -55
- package/lib/mine.mjs +18 -8
- package/lib/preview.mjs +66 -13
- package/lib/private-words.mjs +25 -5
- package/lib/problems/index.mjs +60 -7
- package/lib/prompt-lane.mjs +14 -13
- package/lib/prompt-store.mjs +2 -1
- package/lib/prompts.mjs +13 -7
- package/lib/replay/assemble.mjs +25 -8
- package/lib/replay/index.mjs +200 -12
- package/lib/replay/lookup.mjs +6 -3
- package/lib/replay/saved-sessions.mjs +315 -0
- package/lib/replay/views.mjs +12 -1
- package/lib/{view → replay}/word-index.mjs +3 -3
- package/lib/replay/words.mjs +122 -0
- package/lib/repo-identity.mjs +81 -13
- package/lib/saved/checks.mjs +381 -0
- package/lib/saved/history.mjs +235 -0
- package/lib/saved/saver.mjs +111 -0
- package/lib/saved/store.mjs +256 -0
- package/lib/status.mjs +288 -0
- package/lib/validate.mjs +12 -3
- package/lib/view/assets/common.css +2 -0
- package/lib/view/assets/common.js +20 -2
- package/lib/view/assets/problems.js +4 -3
- package/lib/view/assets/replay.js +3 -1
- package/lib/view/assets/search.js +1 -1
- package/lib/view/assets/sessions.js +1 -0
- package/lib/view/assets/settings.html +16 -0
- package/lib/view/assets/settings.js +108 -4
- package/lib/view/assets/setup.html +7 -0
- package/lib/view/assets/setup.js +9 -0
- package/lib/view/codex-judge.mjs +1 -1
- package/lib/view/data.mjs +160 -76
- package/lib/view/own-week.mjs +119 -0
- package/lib/view/page-link.mjs +84 -0
- package/lib/view/problems-route.mjs +29 -3
- package/lib/view/replay-export.mjs +1 -1
- package/lib/view/selftest/clickthrough.js +9 -5
- package/lib/view/server.mjs +3 -1
- package/lib/view/settings.mjs +102 -34
- package/lib/view/setup.mjs +27 -14
- package/lib/view/suggest-words.mjs +67 -0
- package/lib/view.mjs +82 -133
- package/lib/worktrees.mjs +31 -18
- package/package.json +2 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
// lib/replay/saved-sessions.mjs: sessions the engine didn't read from a log this run, given to it
|
|
2
|
+
// as they were saved by an earlier run (issue 151).
|
|
3
|
+
//
|
|
4
|
+
// `honestweek view` can keep each day's history between runs (lib/saved/). A session saved that
|
|
5
|
+
// way comes back into a later build, as if its log had been read, once its log is gone (Claude
|
|
6
|
+
// Code deletes logs after 30 days by default). The engine takes such a session through
|
|
7
|
+
// `buildWorkHistory({ saved })`, and everything downstream (threads, goals, lookups, git
|
|
8
|
+
// outcomes, timelines, the checks' context) works on it as on any other. This module turns a
|
|
9
|
+
// built session into what's saved, and what's saved back into the engine's own shapes.
|
|
10
|
+
//
|
|
11
|
+
// What's saved is the redacted build's own output for the session: its steps, agents, links,
|
|
12
|
+
// token counts, and the joins that connect it to other sessions and to goals and git (pull
|
|
13
|
+
// requests, commits, branches). Nothing raw: fields the engine keeps in memory only (an event's
|
|
14
|
+
// `_raw`, `_command`, `_workdir`, `_lineCwd`) are dropped; record ids, message ids and token call
|
|
15
|
+
// ids are kept only as hashes; a goal id found in its text is kept as a hash; a log file only as
|
|
16
|
+
// the hash of its path. Git outcomes and quiet intervals aren't saved: the build works them out
|
|
17
|
+
// again. When saved sessions come back, every string passes the current redactor again.
|
|
18
|
+
//
|
|
19
|
+
// Ids a saved session shares with another session (record ids a resumed copy repeats, a message's
|
|
20
|
+
// id) are compared as hashes, so with `saved` in play the engine hashes the fresh sessions' ids
|
|
21
|
+
// the same way before it joins them. Without `saved`, nothing here runs.
|
|
22
|
+
|
|
23
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
import { prRefsInCommand } from './classify.mjs';
|
|
26
|
+
import { pathKey, letterHash } from './ids.mjs';
|
|
27
|
+
|
|
28
|
+
/** The shape of a saved session; one of another is left out of a build. */
|
|
29
|
+
export const SAVED_SESSION_SCHEMA = 1;
|
|
30
|
+
|
|
31
|
+
/** An id as saved and as compared once saved sessions are in a build. */
|
|
32
|
+
export const idHash = (s) => letterHash(`id\u0000${String(s)}`, 24);
|
|
33
|
+
/** A log's own session id (a Claude Code file id, a Codex thread id), as saved. */
|
|
34
|
+
export const logIdHash = (id) => (typeof id === 'string' && id ? letterHash(`log-id\u0000${id.toLowerCase()}`, 24) : null);
|
|
35
|
+
let VERSION = null;
|
|
36
|
+
/** honestweek's own version, from its package.json, which says which version saved a session. */
|
|
37
|
+
export function honestweekVersion() {
|
|
38
|
+
if (VERSION === null) {
|
|
39
|
+
try {
|
|
40
|
+
VERSION = String(JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version ?? 'unknown');
|
|
41
|
+
} catch {
|
|
42
|
+
VERSION = 'unknown';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return VERSION;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A fingerprint of the private-word settings a session was saved with: a hash, never the words. */
|
|
49
|
+
export function redactionPrint(config) {
|
|
50
|
+
return letterHash(`redaction\u0000${JSON.stringify(config?.redaction ?? null)}`, 16);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** How many bytes from a log's start its fingerprint reads. */
|
|
54
|
+
export const HEAD_BYTES = 4096;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A log file's fingerprint: { size, mtimeMs, head }, `head` a hash of its first HEAD_BYTES bytes,
|
|
58
|
+
* so a file replaced by another of the same size and time still reads as changed. Null when it
|
|
59
|
+
* can't be read.
|
|
60
|
+
*/
|
|
61
|
+
export function fileFingerprint(path) {
|
|
62
|
+
if (typeof path !== 'string' || !path) return null;
|
|
63
|
+
let fd;
|
|
64
|
+
try {
|
|
65
|
+
const st = statSync(path);
|
|
66
|
+
if (!st.isFile()) return null;
|
|
67
|
+
const buf = Buffer.alloc(Math.min(HEAD_BYTES, st.size));
|
|
68
|
+
fd = openSync(path, 'r');
|
|
69
|
+
const n = buf.length ? readSync(fd, buf, 0, buf.length, 0) : 0;
|
|
70
|
+
return { size: st.size, mtimeMs: st.mtimeMs, head: letterHash(buf.subarray(0, n), 16) };
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
} finally {
|
|
74
|
+
if (fd !== undefined) {
|
|
75
|
+
try {
|
|
76
|
+
closeSync(fd);
|
|
77
|
+
} catch {
|
|
78
|
+
/* already closed */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A repository label, as saved: the config's label is matched to it on the way back. */
|
|
85
|
+
export const labelHash = (label) => (typeof label === 'string' ? letterHash(`repo-label\u0000${label}`, 16) : null);
|
|
86
|
+
/** A goal id or goal-event id found in a session's text, as saved. */
|
|
87
|
+
export const tokenHash = (token) => letterHash(`goal-token\u0000${String(token)}`, 24);
|
|
88
|
+
|
|
89
|
+
/** The links the engine makes from one session's own records, which a saved session brings back
|
|
90
|
+
* as saved, since the joins behind them aren't saved. */
|
|
91
|
+
const OWN_LINKS = new Set(['spawned', 'completion-notice', 'stopped', 'handback', 'program-launch']);
|
|
92
|
+
/** Links between sessions a saved session also brings back: a resumed copy's records were let go
|
|
93
|
+
* when it was saved, and a message's two ends may not both be in this build. The engine makes
|
|
94
|
+
* them again too, where both sessions are here; each is kept once. */
|
|
95
|
+
const SHARED_LINKS = new Set(['continuation', 'message-delivered', 'handoff']);
|
|
96
|
+
/** A pull request's repository as it's matched on the way back, without its name on disk. */
|
|
97
|
+
export const repoNameHash = (repo) => (typeof repo === 'string' && repo ? letterHash(`repo\u0000${repo.toLowerCase()}`, 24) : null);
|
|
98
|
+
|
|
99
|
+
/** A pull-request, commit or branch join as saved: every field but its step, which is named by id,
|
|
100
|
+
* each string redacted except a commit id. */
|
|
101
|
+
function joinOut(x, redact) {
|
|
102
|
+
const out = {};
|
|
103
|
+
for (const [k, v] of Object.entries(x)) {
|
|
104
|
+
if (k === 'event' || k.startsWith('_')) continue;
|
|
105
|
+
out[k] = typeof v === 'string' && k !== 'sha' ? redact(v) : v;
|
|
106
|
+
}
|
|
107
|
+
if (typeof x.repo === 'string' && x.repo) out.repoHash = repoNameHash(x.repo);
|
|
108
|
+
out.event = x.event.id;
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** What git says about a step, which every build asks git again rather than keeping. */
|
|
113
|
+
const GIT_MISSING = new Set(['readable-session-repository', 'commit-in-session-repository', 'default-branch']);
|
|
114
|
+
|
|
115
|
+
const ownFields = (e) => {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const [k, v] of Object.entries(e)) if (!k.startsWith('_')) out[k] = v;
|
|
118
|
+
if (Array.isArray(out.missing)) out.missing = out.missing.filter((m) => !GIT_MISSING.has(m));
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* exportSession({ key, parsed, a, sessions, links, usage, redact, fileStat }) -> a saved session
|
|
124
|
+
* The redacted build's own parts for one session, ready to write. `a` is the assembled history,
|
|
125
|
+
* `sessions` the history's session records, `usage` its token calls. Null for a session the
|
|
126
|
+
* build doesn't hold. `hashed` says the build already hashed the shared ids (it had saved sessions).
|
|
127
|
+
*/
|
|
128
|
+
export function exportSession({ key, parsed, a, sessions, links, usage, redact, fileStat = () => null, ranOutside = () => false, hashed = false }) {
|
|
129
|
+
const record = sessions.find((s) => s.key === key);
|
|
130
|
+
if (!record) return null;
|
|
131
|
+
// With saved sessions in the build, the engine has already hashed these ids for the join.
|
|
132
|
+
const hashOnce = hashed ? (x) => x : idHash;
|
|
133
|
+
const mine = parsed.filter((p) => p.source.sessionKey === key);
|
|
134
|
+
const main = mine.find((p) => p.source.role === 'session') ?? null;
|
|
135
|
+
const sourceKeys = new Set(mine.map((p) => p.source.key));
|
|
136
|
+
const events = a.events.filter((e) => e.session === key && sourceKeys.has(e.source) && e.kind !== 'quiet').map(ownFields);
|
|
137
|
+
const eventIds = new Set(events.map((e) => e.id));
|
|
138
|
+
const agents = [...a.agents.values()].filter((x) => x.session === key);
|
|
139
|
+
const agentKeys = new Set(agents.map((x) => x.key));
|
|
140
|
+
const touches = (v) => typeof v === 'string' && (eventIds.has(v) || agentKeys.has(v) || sourceKeys.has(v) || v === key);
|
|
141
|
+
const ownLinks = links.filter((l) => touches(l.from) || touches(l.to) || (Array.isArray(l.sessions) && l.sessions.includes(key)));
|
|
142
|
+
const live = (x) => x?.event && eventIds.has(x.event.id);
|
|
143
|
+
// A join is kept once across files, so one on this session's step can sit in another file's
|
|
144
|
+
// list (a resumed copy's): every file's joins are looked through.
|
|
145
|
+
const j = (k) => parsed.flatMap((p) => p.result.joins?.[k] ?? []).filter(live);
|
|
146
|
+
const repo = a.sessionsByKey.get(key)?.repo ?? null;
|
|
147
|
+
const id = main ? (main.source.tool === 'codex' ? main.source.threadId : main.source.fileId) : null;
|
|
148
|
+
return {
|
|
149
|
+
schema: SAVED_SESSION_SCHEMA,
|
|
150
|
+
key,
|
|
151
|
+
tool: record.tool,
|
|
152
|
+
record,
|
|
153
|
+
repo: repo ? { label: labelHash(repo.label), role: repo.role ?? null } : null,
|
|
154
|
+
logId: logIdHash(id),
|
|
155
|
+
sources: mine.map((p) => {
|
|
156
|
+
const st = fileStat(p.source.file);
|
|
157
|
+
return {
|
|
158
|
+
key: p.source.key,
|
|
159
|
+
tool: p.source.tool,
|
|
160
|
+
role: p.source.role,
|
|
161
|
+
agent: p.ctx.agentKey,
|
|
162
|
+
file: pathKey(p.source.file),
|
|
163
|
+
size: st?.size ?? p.source.size ?? null,
|
|
164
|
+
mtimeMs: st?.mtimeMs ?? null,
|
|
165
|
+
head: st?.head ?? null,
|
|
166
|
+
firstAt: p.result.stats.firstAt ?? p.source.firstAt ?? null,
|
|
167
|
+
lastAt: p.result.stats.lastAt ?? p.source.lastAt ?? null,
|
|
168
|
+
records: p.result.stats.records ?? 0,
|
|
169
|
+
unparsable: p.result.stats.unparsable ?? 0,
|
|
170
|
+
oversized: p.result.stats.oversized ?? 0,
|
|
171
|
+
models: p.result.stats.models ?? [],
|
|
172
|
+
entrypoint: p.result.stats.entrypoint ?? null,
|
|
173
|
+
origin: p.result.stats.origin ? { kind: p.result.stats.origin.kind ?? null } : null,
|
|
174
|
+
// How each record type in the file was handled, and what reading it noticed.
|
|
175
|
+
coverage: [...(p.result.coverage ?? new Map())].map(([k, v]) => [redact(k), { count: v.count, handling: redact(v.handling) }]),
|
|
176
|
+
anomalies: p.result.anomalies ?? [],
|
|
177
|
+
};
|
|
178
|
+
}),
|
|
179
|
+
events,
|
|
180
|
+
agents,
|
|
181
|
+
links: ownLinks,
|
|
182
|
+
// What assembly noticed about its steps (a result stamped before its call, say).
|
|
183
|
+
anomalies: a.anomalies.filter((x) => eventIds.has(x.event)),
|
|
184
|
+
usage: usage.filter((c) => c.session === key).map((c) => ({ ...c, id: c.idHash ?? null })),
|
|
185
|
+
joins: {
|
|
186
|
+
uuids: mine.flatMap((p) => p.result.joins?.uuids ?? []).map((u) => ({ uuid: hashOnce(u.uuid), eventIds: u.eventIds })),
|
|
187
|
+
// A record this session's file repeats from another (a resumed copy) was let go in favour of
|
|
188
|
+
// the original; its id, kind and place are kept, so the original can still say it was copied.
|
|
189
|
+
copies: mine.flatMap((p) => p.result.events).filter((e) => !eventIds.has(e.id) && e.kind !== 'quiet').map((e) => ({ id: e.id, source: e.source, kind: e.kind, refs: e.refs ?? [] })),
|
|
190
|
+
sentMessages: j('sentMessages').map((m) => ({ msgId: hashOnce(m.msgId), event: m.event.id })),
|
|
191
|
+
receivedMessages: j('receivedMessages').map((m) => ({ msgId: hashOnce(m.msgId), event: m.event.id })),
|
|
192
|
+
chips: j('chips').map((c) => ({ digest: c.digest, event: c.event.id })),
|
|
193
|
+
prs: j('prs').map((x) => joinOut(x, redact)),
|
|
194
|
+
commits: j('commits').map((x) => joinOut(x, redact)),
|
|
195
|
+
branches: j('branches').map((x) => joinOut(x, redact)),
|
|
196
|
+
watched: j('watched').map((x) => ({ token: tokenHash(x.token), where: x.where, event: x.event.id })),
|
|
197
|
+
// The pull requests a gh command names, read from the command when it was saved, since the
|
|
198
|
+
// command's own text isn't kept: each repository redacted, with a hash to match it back.
|
|
199
|
+
commandPrs: a.events
|
|
200
|
+
.filter((e) => e.session === key && eventIds.has(e.id) && e.kind === 'action' && e.facts?.category === 'shell' && typeof e._command === 'string')
|
|
201
|
+
.map((e) => ({ event: e.id, ranOutside: ranOutside(e), refs: prRefsInCommand(e._command).map((r) => ({ number: r.number, via: r.via, repoKnown: r.repoKnown, repo: typeof r.repo === 'string' ? redact(r.repo) : r.repo ?? null, ...(typeof r.repo === 'string' && r.repo ? { repoHash: repoNameHash(r.repo) } : {}) })) }))
|
|
202
|
+
.filter((x) => x.refs.length),
|
|
203
|
+
firstPromptDigest: main?.result.joins?.firstPromptDigest ?? null,
|
|
204
|
+
firstPromptEvent: main && eventIds.has(main.result.joins?.firstPromptEvent) ? main.result.joins.firstPromptEvent : null,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The engine's parsed entries for one saved session, as if its sources had been read: each with
|
|
211
|
+
* the saved steps, the joins the engine matches across sessions, and its token calls. `ctxOf(source)`
|
|
212
|
+
* gives each source the build's context (its repository and whether it's private, from the config
|
|
213
|
+
* as it is now). `resolve(token hash)` is the goal id or goal-event id it stands for, or null.
|
|
214
|
+
*/
|
|
215
|
+
export function savedAsParsed(saved, { ctxOf, resolveToken = () => null, repoSlug = () => null }) {
|
|
216
|
+
// A pull request's repository comes back as the name the session's repository has now, when it
|
|
217
|
+
// was that one; otherwise it stays as saved, redacted, and matches nothing.
|
|
218
|
+
const slugBack = (x) => {
|
|
219
|
+
if (!x.repoHash) return x;
|
|
220
|
+
const slug = repoSlug();
|
|
221
|
+
const { repoHash, ...rest } = x;
|
|
222
|
+
return slug && repoNameHash(slug) === repoHash ? { ...rest, repo: slug } : rest;
|
|
223
|
+
};
|
|
224
|
+
const events = saved.events.map((e) => ({ ...e, refs: e.refs ?? [], facts: e.facts ?? {}, derived: e.derived ?? {}, inferred: e.inferred ?? [], missing: [...(e.missing ?? [])] }));
|
|
225
|
+
// Marked, in memory only, so assembly says nothing twice that a saved step already says.
|
|
226
|
+
for (const e of events) Object.defineProperty(e, '_saved', { value: true, enumerable: false });
|
|
227
|
+
const byId = new Map(events.map((e) => [e.id, e]));
|
|
228
|
+
// A gh command's pull requests, kept in memory beside its step for the reverse lookup, as the
|
|
229
|
+
// command's own text is for a step read from a log.
|
|
230
|
+
for (const c of saved.joins?.commandPrs ?? []) {
|
|
231
|
+
const e = byId.get(c.event);
|
|
232
|
+
if (!e) continue;
|
|
233
|
+
Object.defineProperty(e, '_prRefs', { value: (c.refs ?? []).map((r) => slugBack({ ...r })), enumerable: false });
|
|
234
|
+
Object.defineProperty(e, '_ranOutside', { value: c.ranOutside === true, enumerable: false });
|
|
235
|
+
}
|
|
236
|
+
const ev = (id) => byId.get(id) ?? null;
|
|
237
|
+
const withEvent = (list, map) => (list ?? []).map((x) => ({ ...map(x), event: ev(x.event) })).filter((x) => x.event);
|
|
238
|
+
const joins = saved.joins ?? {};
|
|
239
|
+
const out = [];
|
|
240
|
+
for (const s of saved.sources) {
|
|
241
|
+
const source = { key: s.key, tool: s.tool, role: s.role, sessionKey: saved.key, file: null, cwd: null, firstAt: s.firstAt, lastAt: s.lastAt, size: s.size, saved: true };
|
|
242
|
+
const ctx = ctxOf(source, s);
|
|
243
|
+
const isMain = s.role === 'session';
|
|
244
|
+
const own = events.filter((e) => e.source === s.key);
|
|
245
|
+
const ownIds = new Set(own.map((e) => e.id));
|
|
246
|
+
const onlyOwn = (list) => list.filter((x) => ownIds.has(x.event.id));
|
|
247
|
+
out.push({
|
|
248
|
+
source,
|
|
249
|
+
ctx,
|
|
250
|
+
result: {
|
|
251
|
+
events: own,
|
|
252
|
+
joins: {
|
|
253
|
+
calls: new Map(),
|
|
254
|
+
uuids: (joins.uuids ?? []).filter((u) => u.eventIds?.some((x) => String(x).startsWith(`${s.key}.`))),
|
|
255
|
+
sentMessages: onlyOwn(withEvent(joins.sentMessages, (m) => ({ msgId: m.msgId }))),
|
|
256
|
+
receivedMessages: onlyOwn(withEvent(joins.receivedMessages, (m) => ({ msgId: m.msgId }))),
|
|
257
|
+
chips: onlyOwn(withEvent(joins.chips, (c) => ({ digest: c.digest }))),
|
|
258
|
+
prs: onlyOwn(withEvent(joins.prs, (x) => slugBack({ ...x }))),
|
|
259
|
+
commits: onlyOwn(withEvent(joins.commits, (x) => slugBack({ ...x }))),
|
|
260
|
+
branches: onlyOwn(withEvent(joins.branches, (x) => ({ ...x }))),
|
|
261
|
+
watched: onlyOwn(withEvent(joins.watched, (x) => ({ token: resolveToken(x.token), where: x.where }))).filter((x) => x.token != null),
|
|
262
|
+
firstPromptDigest: isMain ? joins.firstPromptDigest ?? null : null,
|
|
263
|
+
firstPromptEvent: isMain ? joins.firstPromptEvent ?? null : null,
|
|
264
|
+
programStart: null,
|
|
265
|
+
hookLaunches: [],
|
|
266
|
+
},
|
|
267
|
+
coverage: new Map(Array.isArray(s.coverage) ? s.coverage : []),
|
|
268
|
+
anomalies: Array.isArray(s.anomalies) ? s.anomalies : [],
|
|
269
|
+
copyStubs: (joins.copies ?? []).filter((c) => c.source === s.key).map((c) => ({ id: c.id, kind: c.kind, refs: c.refs ?? [], facts: {}, derived: {}, inferred: [], missing: [] })),
|
|
270
|
+
stats: { records: s.records ?? 0, unparsable: s.unparsable ?? 0, oversized: s.oversized ?? 0, firstAt: s.firstAt, lastAt: s.lastAt, models: s.models ?? [], titles: [], entrypoint: s.entrypoint ?? null, origin: s.origin ?? null },
|
|
271
|
+
usage: (saved.usage ?? []).filter((c) => c.source === s.key).map((c) => ({ id: c.id, agent: c.agent, t: c.t, lines: c.lines ?? [], input: c.input, cacheWrite: c.cacheWrite, cacheRead: c.cacheRead, output: c.output, ...(c.ttl ? { ttl: c.ttl } : {}), hashed: true })),
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* After assembly: each saved session's own agents and the links its own records made come back as
|
|
280
|
+
* saved, replacing what assembly could make without the joins behind them; anomalies about its
|
|
281
|
+
* steps are left out (they weren't saved). Links between sessions that assembly made (a resumed
|
|
282
|
+
* copy, a message delivered, a hand-off) stay.
|
|
283
|
+
*/
|
|
284
|
+
export function restoreSaved(a, savedList) {
|
|
285
|
+
if (!savedList.length) return;
|
|
286
|
+
const keys = new Set(savedList.map((s) => s.key));
|
|
287
|
+
const savedEvents = new Set(savedList.flatMap((s) => s.events.map((e) => e.id)));
|
|
288
|
+
const savedSources = new Set(savedList.flatMap((s) => s.sources.map((x) => x.key)));
|
|
289
|
+
for (const [k, agent] of a.agents) if (keys.has(agent.session)) a.agents.delete(k);
|
|
290
|
+
for (const s of savedList) for (const agent of s.agents ?? []) a.agents.set(agent.key, { ...agent, missing: [...(agent.missing ?? [])] });
|
|
291
|
+
const isSaved = (v) => typeof v === 'string' && (savedEvents.has(v) || keys.has(v) || savedSources.has(v) || savedSources.has(v.replace(/:.*$/, '')));
|
|
292
|
+
const kept = a.links.filter((l) => !(OWN_LINKS.has(l.type) && (isSaved(l.from) || isSaved(l.to))));
|
|
293
|
+
const seen = new Set(kept.map((l) => `${l.type}\u0000${l.from}\u0000${l.to}`));
|
|
294
|
+
for (const s of savedList) {
|
|
295
|
+
for (const l of s.links ?? []) {
|
|
296
|
+
if (!OWN_LINKS.has(l.type) && !SHARED_LINKS.has(l.type)) continue;
|
|
297
|
+
const k = `${l.type}\u0000${l.from}\u0000${l.to}`;
|
|
298
|
+
if (seen.has(k)) continue;
|
|
299
|
+
seen.add(k);
|
|
300
|
+
kept.push(l);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
a.links.length = 0;
|
|
304
|
+
a.links.push(...kept);
|
|
305
|
+
const keptAnomalies = a.anomalies.filter((x) => !savedEvents.has(x.event));
|
|
306
|
+
a.anomalies.length = 0;
|
|
307
|
+
a.anomalies.push(...keptAnomalies, ...savedList.flatMap((s) => s.anomalies ?? []));
|
|
308
|
+
for (const s of savedList) {
|
|
309
|
+
const sess = a.sessionsByKey.get(s.key);
|
|
310
|
+
if (!sess) continue;
|
|
311
|
+
if (s.record.launchedBy && !sess.launchedBy) sess.launchedBy = s.record.launchedBy;
|
|
312
|
+
if (s.record.startedFrom && !sess.startedFrom) sess.startedFrom = s.record.startedFrom;
|
|
313
|
+
sess.missing = [...(s.record.missing ?? [])];
|
|
314
|
+
}
|
|
315
|
+
}
|
package/lib/replay/views.mjs
CHANGED
|
@@ -22,8 +22,19 @@ const I = EVIDENCE.INFERRED;
|
|
|
22
22
|
|
|
23
23
|
const metric = (value, evidence, extra = {}) => ({ value, evidence, ...extra });
|
|
24
24
|
|
|
25
|
+
const QUARTER_HOUR = 15 * 60 * 1000;
|
|
26
|
+
const dayCache = new Map();
|
|
25
27
|
export function localDay(t, tz) {
|
|
26
|
-
|
|
28
|
+
// Every timezone's offset, and every change of it, falls on a quarter hour, so one quarter hour
|
|
29
|
+
// never spans two local days: the day is worked out once per quarter hour and zone.
|
|
30
|
+
const key = `${tz}\u0000${Math.floor(t / QUARTER_HOUR)}`;
|
|
31
|
+
let day = dayCache.get(key);
|
|
32
|
+
if (day === undefined) {
|
|
33
|
+
day = localDateInTimezone(new Date(t), tz).toISOString().slice(0, 10);
|
|
34
|
+
if (dayCache.size > 200_000) dayCache.clear();
|
|
35
|
+
dayCache.set(key, day);
|
|
36
|
+
}
|
|
37
|
+
return day;
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
function hasInference(e, value) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// lib/
|
|
1
|
+
// lib/replay/word-index.mjs: "search everywhere", for `honestweek view`'s Find page and the `find` command.
|
|
2
2
|
//
|
|
3
3
|
// It reads the prompts and titles of every session in the window, from the same log
|
|
4
4
|
// folders and window the history was built from, display-only repositories and folders
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
// run's starting instruction. A child thread is skipped whole: its user messages are its
|
|
17
17
|
// parent agent's hand-offs.
|
|
18
18
|
|
|
19
|
-
import { readJsonlRecords, tryParse } from '
|
|
20
|
-
import { enumerateClaudeSources, enumerateCodexSources } from '
|
|
19
|
+
import { readJsonlRecords, tryParse } from './jsonl.mjs';
|
|
20
|
+
import { enumerateClaudeSources, enumerateCodexSources } from './sources.mjs';
|
|
21
21
|
import { createCodexTurnReader, isCodexExecSession } from '../codex-records.mjs';
|
|
22
22
|
|
|
23
23
|
/** Harness text that arrives as a user record but isn't something a person typed. */
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// lib/replay/words.mjs: phrase search over a built work history, for `honestweek view`'s Find
|
|
2
|
+
// page and the `find` command alike, so both read one implementation.
|
|
3
|
+
//
|
|
4
|
+
// Words match only sessions in configured repositories: a display-only or outside session never
|
|
5
|
+
// joins a lookup, whichever build answers. Goal titles and ids match when the history was built
|
|
6
|
+
// with a goal record. Each match says how it's known: words in a prompt, a title or a branch are
|
|
7
|
+
// recorded there; ranking prompts by how many of the words they share is a named rule's reading.
|
|
8
|
+
// Nothing here redacts: the history's strings already passed its redactor, and each caller shapes
|
|
9
|
+
// and scrubs its own answer. "Search everywhere", which reads every session's prompts again from
|
|
10
|
+
// the logs, is lib/replay/word-index.mjs.
|
|
11
|
+
//
|
|
12
|
+
// Zero runtime dependencies: Node built-ins only.
|
|
13
|
+
|
|
14
|
+
/** The rules behind word matches, beside the engine's own. Their ids keep the `view.` prefix
|
|
15
|
+
* they were first published under. */
|
|
16
|
+
export const WORD_RULES = Object.freeze({
|
|
17
|
+
'view.shared-words': 'A prompt shares some of the words you typed. The score counts how many of your words appear in it as whole words; sharing words doesn\'t mean it was about the same thing.',
|
|
18
|
+
'view.similar-prompt': 'Two prompts share a large part of their words (the shared words divided by all the words either uses). Similar wording doesn\'t mean similar work.',
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/** The most prompts one word search returns, and the most a "similar prompts" answer does. */
|
|
22
|
+
export const MAX_PROMPTS = 20;
|
|
23
|
+
export const MAX_SIMILAR = 10;
|
|
24
|
+
/** The least share of words two prompts need to count as similar. */
|
|
25
|
+
export const SIMILAR_MIN = 0.2;
|
|
26
|
+
|
|
27
|
+
const STOP = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by', 'can', 'do', 'for', 'from', 'if', 'in', 'into', 'is', 'it', 'its', 'just', 'me', 'my', 'no', 'not', 'of', 'on', 'or', 'so', 'that', 'the', 'then', 'this', 'to', 'up', 'we', 'with', 'you', 'your', 'i', 'redacted']);
|
|
28
|
+
const MARKERS = /\[redacted:[a-z]+\]/g;
|
|
29
|
+
|
|
30
|
+
/** Lowercased words of a text, its redaction markers taken out first. */
|
|
31
|
+
export function wordsOf(text) {
|
|
32
|
+
return (String(text ?? '').replace(MARKERS, ' ').toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}_'-]*/gu) ?? []).filter((w) => w.length >= 2 && !STOP.has(w));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const PR_RE = /^(?:(?:[\w.-]+\/)?[\w.-]+#|pr\s*#?\s*|pull\/|#)(\d+)$/i;
|
|
36
|
+
const PR_URL = /github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/i;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Whether `text` reads as a reference (a pull request, a commit, a file or a branch) rather than
|
|
40
|
+
* words: the Find page's rule (isRef in lib/view/assets/search.js, which keeps its own copy),
|
|
41
|
+
* plus the `pr:`, `pull:`, `commit:` and `path:` prefixes the find command names, so a prefix
|
|
42
|
+
* always makes it a reference.
|
|
43
|
+
*/
|
|
44
|
+
export function isReference(text) {
|
|
45
|
+
const q = String(text ?? '').trim();
|
|
46
|
+
if (/^(pr|pull|commit|path):/i.test(q)) return true;
|
|
47
|
+
return PR_RE.test(q) || PR_URL.test(q) || /^[0-9a-f]{7,40}$/i.test(q) || /^(file|branch):/i.test(q) || (!/\s/.test(q) && (/[\\/]/.test(q) || /^[\w.-]+\.[a-z0-9]{1,6}$/i.test(q)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The prompts a word search reads: those with text, in sessions in configured repositories. */
|
|
51
|
+
export function readablePrompts(h) {
|
|
52
|
+
const readable = new Set(h.sessions.filter((s) => s.private === false).map((s) => s.key));
|
|
53
|
+
return h.events.filter((e) => readable.has(e.session) && e.kind === 'prompt' && typeof e.facts?.text === 'string' && e.facts.text);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* matchWords(h, text) -> { want, goals, sessions, branches, prompts }
|
|
58
|
+
* want the query's words (wordsOf), each once
|
|
59
|
+
* goals [{ index, matchedIn: 'title' | 'id' }], an index into h.goals, for a goal whose
|
|
60
|
+
* title or id holds every word
|
|
61
|
+
* sessions [key] of configured sessions whose title holds every word
|
|
62
|
+
* branches [{ branch, sessions: [{ session, event }] }] for each branch name (pushed, checked
|
|
63
|
+
* out, or the session's mode) holding the text, with each session's first such step
|
|
64
|
+
* prompts [{ event, shared }] up to MAX_PROMPTS prompts sharing any of the words, most shared
|
|
65
|
+
* first, then newest; `event` is the history's own event
|
|
66
|
+
*/
|
|
67
|
+
export function matchWords(h, text) {
|
|
68
|
+
const q = String(text ?? '');
|
|
69
|
+
const want = [...new Set(wordsOf(q))];
|
|
70
|
+
const low = q.toLowerCase();
|
|
71
|
+
const has = (t) => want.length > 0 && want.every((w) => String(t ?? '').toLowerCase().includes(w));
|
|
72
|
+
const goals = (h.goals ?? []).flatMap((g, index) => {
|
|
73
|
+
const inTitle = has(g.title);
|
|
74
|
+
return inTitle || has(g.id) ? [{ index, matchedIn: inTitle ? 'title' : 'id' }] : [];
|
|
75
|
+
});
|
|
76
|
+
const sessions = h.sessions.filter((s) => s.private === false && s.title && has(s.title)).map((s) => s.key);
|
|
77
|
+
const readable = new Set(h.sessions.filter((s) => s.private === false).map((s) => s.key));
|
|
78
|
+
const branchMap = new Map();
|
|
79
|
+
for (const e of h.events) {
|
|
80
|
+
if (!readable.has(e.session)) continue;
|
|
81
|
+
const f = e.facts ?? {};
|
|
82
|
+
for (const b of [f.git?.push?.branch, f.git?.branch?.ref, e.kind === 'mode' ? f.branch : null]) {
|
|
83
|
+
if (typeof b !== 'string' || !b || !b.toLowerCase().includes(low)) continue;
|
|
84
|
+
if (!branchMap.has(b)) branchMap.set(b, new Map());
|
|
85
|
+
if (!branchMap.get(b).has(e.session)) branchMap.get(b).set(e.session, e.id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const branches = [...branchMap].map(([branch, bySession]) => ({ branch, sessions: [...bySession].map(([session, event]) => ({ session, event })) }));
|
|
89
|
+
const prompts = readablePrompts(h)
|
|
90
|
+
.map((e) => {
|
|
91
|
+
const theirs = new Set(wordsOf(e.facts.text));
|
|
92
|
+
return { event: e, shared: want.filter((w) => theirs.has(w)).length };
|
|
93
|
+
})
|
|
94
|
+
.filter((x) => x.shared > 0)
|
|
95
|
+
.sort((a, b) => b.shared - a.shared || b.event.t - a.event.t)
|
|
96
|
+
.slice(0, MAX_PROMPTS);
|
|
97
|
+
return { want, goals, sessions, branches, prompts };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* similarPrompts(h, eventId) -> null when no readable prompt has that id, else
|
|
102
|
+
* [{ event, score }]: up to MAX_SIMILAR other prompts sharing at least SIMILAR_MIN of their
|
|
103
|
+
* words with it (shared words over all the words either uses), most similar first, then newest.
|
|
104
|
+
*/
|
|
105
|
+
export function similarPrompts(h, eventId) {
|
|
106
|
+
const prompts = readablePrompts(h);
|
|
107
|
+
const base = prompts.find((e) => e.id === eventId);
|
|
108
|
+
if (!base) return null;
|
|
109
|
+
const mine = new Set(wordsOf(base.facts.text));
|
|
110
|
+
return prompts
|
|
111
|
+
.filter((e) => e.id !== base.id)
|
|
112
|
+
.map((e) => {
|
|
113
|
+
const theirs = new Set(wordsOf(e.facts.text));
|
|
114
|
+
let shared = 0;
|
|
115
|
+
for (const w of theirs) if (mine.has(w)) shared += 1;
|
|
116
|
+
const union = mine.size + theirs.size - shared;
|
|
117
|
+
return { event: e, score: union ? shared / union : 0 };
|
|
118
|
+
})
|
|
119
|
+
.filter((x) => x.score >= SIMILAR_MIN)
|
|
120
|
+
.sort((a, b) => b.score - a.score || b.event.t - a.event.t)
|
|
121
|
+
.slice(0, MAX_SIMILAR);
|
|
122
|
+
}
|
package/lib/repo-identity.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync, realpathSync } from 'node:fs';
|
|
7
7
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
8
8
|
|
|
9
|
-
import { gitDirOf, resolveCommonDir } from './worktrees.mjs';
|
|
9
|
+
import { gitDirOf, linkedWorkTrees, resolveCommonDir } from './worktrees.mjs';
|
|
10
10
|
|
|
11
11
|
const samePath = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
|
|
12
12
|
|
|
@@ -45,36 +45,104 @@ const isUnder = (child, parent) => child.length > parent.length && child.startsW
|
|
|
45
45
|
* or null. Git reading a repository reads the history of every folder in it, so a display
|
|
46
46
|
* folder inside a featured or reference repository (or inside another worktree of it) would be
|
|
47
47
|
* read after all (AGENTS.md invariant 4); a read repository inside a display folder is refused
|
|
48
|
-
* too, since the person said that whole folder is display-only
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* too, since the person said that whole folder is display-only, and so is one with a worktree
|
|
49
|
+
* (or its main checkout) inside a display folder, since git reads the work done there with the
|
|
50
|
+
* rest of the repository. Folders are compared by their real paths, so a junction, a symlink or
|
|
51
|
+
* another case on Windows can't hide the overlap. The same folder twice is left to
|
|
52
|
+
* checkSameRepos. `repos` are { path, label?, role }, paths resolved.
|
|
51
53
|
*/
|
|
52
54
|
export function checkNestedRoles(repos) {
|
|
53
55
|
const keyed = repos.map((r) => ({ ...r, key: folderKey(r.path), name: r.label || basename(r.path) }));
|
|
54
56
|
const display = keyed.filter((r) => r.role === 'display');
|
|
55
|
-
|
|
57
|
+
if (display.length === 0) return null;
|
|
58
|
+
const read = keyed.filter((r) => r.role !== 'display').map((r) => ({ ...r, trees: workTreesOf(r.path) }));
|
|
56
59
|
for (const d of display) {
|
|
57
60
|
const walked = repoKey(d.path, { walk: true });
|
|
58
61
|
for (const r of read) {
|
|
59
62
|
if (isUnder(d.key, r.key) || (walked && !repoKey(d.path) && walked === repoKey(r.path))) return `${d.name} is display-only but sits inside ${r.name}, which git reads, so its history would be read too. Move it out of ${r.name}, or mark ${r.name} display as well.`;
|
|
60
63
|
if (isUnder(r.key, d.key)) return `${r.name} is read by git but sits inside ${d.name}, which is display-only. Mark ${r.name} display as well, or remove one of them.`;
|
|
64
|
+
const wt = r.trees.find((t) => isUnder(t.key, d.key));
|
|
65
|
+
if (wt) return worktreeInside(r.name, wt, d.name);
|
|
61
66
|
}
|
|
62
67
|
}
|
|
63
68
|
return null;
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/** The refusal for a read repository with a checkout inside a display folder. A worktree whose
|
|
72
|
+
* folder is gone still counts, since git lists it until it's pruned and sessions run there would
|
|
73
|
+
* count as the repository's. Each way out is one git can follow: a hand move needs a repair
|
|
74
|
+
* before a prune would drop the moved worktree's records, and a locked worktree needs an unlock. */
|
|
75
|
+
function worktreeInside(name, wt, display) {
|
|
76
|
+
const tree = basename(wt.path);
|
|
77
|
+
if (wt.main) return `${name} is read by git but its main checkout, ${tree}, sits inside ${display}, which is display-only, so the work done there would be read too. Move it out of ${display}, or mark ${name} display as well.`;
|
|
78
|
+
if (!existsSync(wt.path) && wt.locked) return `${name} is read by git, which lists a locked worktree of it, ${tree}, inside ${display}, which is display-only, though that folder can't be found. If you moved it by hand, run git worktree repair with its new folder in ${name}; if its drive isn't connected, reconnect it and run git worktree unlock and git worktree move there; if it's gone for good, run git worktree unlock and git worktree prune there; or mark ${name} display as well.`;
|
|
79
|
+
if (!existsSync(wt.path)) return `${name} is read by git, which still lists a worktree of it, ${tree}, inside ${display}, which is display-only, though that folder is gone. If you moved it by hand, run git worktree repair with its new folder in ${name}; if it's gone for good, run git worktree prune there; or mark ${name} display as well.`;
|
|
80
|
+
const unlock = wt.locked ? 'git worktree unlock and ' : '';
|
|
81
|
+
return `${name} is read by git but has a worktree, ${tree}, inside ${display}, which is display-only, so the work done there would be read too. Move that worktree out of ${display} with ${unlock}git worktree move, or mark ${name} display as well.`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Whether git running in folder `p` would reach a display-only folder too: `p` holds one, or
|
|
85
|
+
* sits inside one, or is a plain subfolder of a checkout that holds one, since git there reads
|
|
86
|
+
* that whole checkout (checkNestedRoles). Never runs git. */
|
|
87
|
+
export function nestsDisplay(displayPaths) {
|
|
88
|
+
const display = displayPaths.map((d) => ({ path: d, role: 'display' }));
|
|
89
|
+
const nested = (q) => checkNestedRoles([{ path: q, role: 'reference' }, ...display]) !== null;
|
|
90
|
+
return (p) => {
|
|
91
|
+
const top = checkoutOf(p);
|
|
92
|
+
return nested(p) || (top !== null && nested(top));
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Whether git can't be asked about folder `p` without reaching a display-only folder: `p` is,
|
|
97
|
+
* or sits in, one of `displayPaths` or another worktree of one (displayTest), or git there would
|
|
98
|
+
* reach one (nestsDisplay). Settings and discover both use it, so they skip git in the same
|
|
99
|
+
* places. Never runs git. */
|
|
100
|
+
export function reachesDisplay(displayPaths) {
|
|
101
|
+
const isDisplay = displayTest(displayPaths);
|
|
102
|
+
const nests = nestsDisplay(displayPaths);
|
|
103
|
+
return (p) => isDisplay(p, { walk: true }) || nests(p);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The nearest folder at or above `p` that holds a .git, or null outside a checkout. It walks up
|
|
107
|
+
* from `p`'s real path, as git does, so a junction or symlink into a checkout finds that
|
|
108
|
+
* checkout, not the one its name sits in. Never runs git. */
|
|
109
|
+
export function checkoutOf(p) {
|
|
110
|
+
let start;
|
|
111
|
+
try {
|
|
112
|
+
start = realpathSync.native(p);
|
|
113
|
+
} catch {
|
|
114
|
+
start = resolve(p);
|
|
115
|
+
}
|
|
116
|
+
for (let d = start; ; d = dirname(d)) {
|
|
117
|
+
if (existsSync(join(d, '.git'))) return d;
|
|
118
|
+
if (dirname(d) === d) return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
66
122
|
/** The git repository a folder belongs to, as its shared git folder (the one every worktree
|
|
67
123
|
* of it points at), read from git's files on disk, never by running git. With `walk`, a
|
|
68
124
|
* subfolder is read from the checkout that holds it. Null outside a repository. */
|
|
69
125
|
export function repoKey(p, { walk = false } = {}) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
126
|
+
const d = walk ? checkoutOf(p) : existsSync(join(resolve(p), '.git')) ? resolve(p) : null;
|
|
127
|
+
return d === null ? null : folderKey(sharedGitDir(d));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A worktree points at its repository's shared folder; a main checkout uses its own git
|
|
131
|
+
* folder, which `git init --separate-git-dir` puts elsewhere and names in a .git file. */
|
|
132
|
+
const sharedGitDir = (checkout) => resolveCommonDir(checkout) ?? gitDirOf(checkout) ?? join(checkout, '.git');
|
|
133
|
+
|
|
134
|
+
/** Every folder the repository holding `p` is checked out in, as { path, key, main, locked }: its main
|
|
135
|
+
* checkout and each linked worktree git lists in its shared folder. Read from git's files on
|
|
136
|
+
* disk, never by running git. Empty for a folder that isn't there or isn't in a repository. */
|
|
137
|
+
function workTreesOf(p) {
|
|
138
|
+
if (!existsSync(p)) return [];
|
|
139
|
+
const checkout = checkoutOf(p);
|
|
140
|
+
if (checkout === null) return [];
|
|
141
|
+
const common = sharedGitDir(checkout);
|
|
142
|
+
const trees = linkedWorkTrees(common, { withLocks: true }).map((t) => ({ ...t, main: false }));
|
|
143
|
+
// A main checkout keeps its git folder as `.git` inside it; a bare repository has no checkout.
|
|
144
|
+
if (basename(common) === '.git') trees.push({ path: dirname(common), main: true, locked: false });
|
|
145
|
+
return trees.map((t) => ({ ...t, key: folderKey(t.path) }));
|
|
78
146
|
}
|
|
79
147
|
|
|
80
148
|
/**
|