@brainervirus/workit-core 8.8.0 → 8.9.1
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/package.json +1 -1
- package/src/core/task-context.ts +68 -30
- package/src/core/task-engine.ts +65 -3
- package/src/core/task-store.ts +158 -3
- package/src/git/session-hook.ts +521 -0
- package/src/hooks/context.ts +45 -23
package/package.json
CHANGED
package/src/core/task-context.ts
CHANGED
|
@@ -73,11 +73,20 @@ type CompactDecision = {
|
|
|
73
73
|
digest: string;
|
|
74
74
|
choice: string;
|
|
75
75
|
references: CompactReference[];
|
|
76
|
+
/** Where an inherited decision was recorded ("branch develop"); absent for the task's own. */
|
|
77
|
+
origin?: string;
|
|
76
78
|
};
|
|
77
79
|
type CompactMethod = Pick<SelectedMethod, "id" | "assurance"> & { reason: string };
|
|
80
|
+
/** Decisions of a task the current one inherits from (its base branch's task). */
|
|
81
|
+
export type InheritedDecisions = {
|
|
82
|
+
origin: string;
|
|
83
|
+
taskId: string;
|
|
84
|
+
decisions: TaskView["task"]["decisions"];
|
|
85
|
+
};
|
|
78
86
|
export type CompactTaskContext = {
|
|
79
87
|
objective: string;
|
|
80
|
-
|
|
88
|
+
/** Null when the checkout has no task yet and only inherited decisions apply. */
|
|
89
|
+
status: TaskView["task"]["status"] | null;
|
|
81
90
|
decisions: CompactDecision[];
|
|
82
91
|
methods: CompactMethod[];
|
|
83
92
|
gaps: string[];
|
|
@@ -128,54 +137,83 @@ const compactReference = (ref: Ref): CompactReference => {
|
|
|
128
137
|
return { kind: ref.kind };
|
|
129
138
|
};
|
|
130
139
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
140
|
+
type TaskDecision = TaskView["task"]["decisions"][number];
|
|
141
|
+
const newestDecisionFirst = (left: TaskDecision, right: TaskDecision): number => {
|
|
142
|
+
const leftAt = compactTimestamp(left.recordedAt);
|
|
143
|
+
const rightAt = compactTimestamp(right.recordedAt);
|
|
144
|
+
if (leftAt !== rightAt) return leftAt > rightAt ? -1 : 1;
|
|
145
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* The task's own decisions (newest first), then the ones it inherits (origin
|
|
149
|
+
* set; the engine passes only unrevoked ones), at most COMPACT_MAX_ITEMS.
|
|
150
|
+
*/
|
|
151
|
+
const compactDecisions = (
|
|
152
|
+
sources: Array<{ origin: string | null; taskId: string; decisions: TaskDecision[] } | null>,
|
|
153
|
+
): CompactDecision[] =>
|
|
154
|
+
sources
|
|
155
|
+
.flatMap((source) =>
|
|
156
|
+
source
|
|
157
|
+
? source.decisions.toSorted(newestDecisionFirst).map((entry) => ({
|
|
158
|
+
id: entry.id,
|
|
159
|
+
purpose: entry.data.purpose,
|
|
160
|
+
status: entry.data.response,
|
|
161
|
+
digest: entry.data.digest,
|
|
162
|
+
choice:
|
|
163
|
+
compactDecisionChoice(entry) ||
|
|
164
|
+
`task.inspect taskId=${source.taskId} view=full; decision=${entry.id}`,
|
|
165
|
+
references: entry.data.binding.contentRefs.slice(0, 1).map(compactReference),
|
|
166
|
+
// Undefined (dropped from the JSON) for the task's own decisions.
|
|
167
|
+
origin: source.origin ?? undefined,
|
|
168
|
+
}))
|
|
169
|
+
: [],
|
|
170
|
+
)
|
|
171
|
+
.slice(0, COMPACT_MAX_ITEMS);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The compact context of `view`'s task plus the decisions it inherits; with
|
|
175
|
+
* a null `view` (the branch has no task of its own yet) only the inherited
|
|
176
|
+
* decisions. Stale evidence is one counted gap with the command that lists it.
|
|
177
|
+
*/
|
|
178
|
+
export function compactTaskContext(
|
|
179
|
+
view: TaskView | null,
|
|
180
|
+
inherited: InheritedDecisions | null = null,
|
|
181
|
+
): string {
|
|
182
|
+
const decisions = compactDecisions([
|
|
183
|
+
view && { origin: null, taskId: view.task.id, decisions: view.task.decisions },
|
|
184
|
+
inherited,
|
|
185
|
+
]);
|
|
186
|
+
const methods: CompactMethod[] = view?.task.policy
|
|
152
187
|
? selectMethods(view.task.policy, view.capabilities).map((method) => ({
|
|
153
188
|
id: method.id,
|
|
154
189
|
assurance: method.assurance,
|
|
155
190
|
reason: compactText(method.reason, COMPACT_TEXT_BYTES) ?? "",
|
|
156
191
|
}))
|
|
157
192
|
: [];
|
|
193
|
+
const stale = view?.evidence.filter((entry) => entry.status === "stale").length;
|
|
158
194
|
const gaps = Array.from(
|
|
159
195
|
new Set([
|
|
160
|
-
...
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
...view
|
|
196
|
+
...(stale
|
|
197
|
+
? [`stale evidence: ${stale}; workit task inspect --task ${view.task.id} --view full`]
|
|
198
|
+
: []),
|
|
199
|
+
...(view?.requirements ?? [])
|
|
164
200
|
.filter((entry) => entry.status !== "satisfied")
|
|
165
201
|
.map((entry) => entry.reason),
|
|
166
|
-
...view
|
|
202
|
+
...(view?.task.progress.blockers ?? []).map((entry) => entry.reason),
|
|
167
203
|
]),
|
|
168
204
|
)
|
|
169
205
|
.toSorted()
|
|
170
206
|
.slice(0, COMPACT_MAX_ITEMS)
|
|
171
207
|
.map((gap) => compactText(gap, COMPACT_TEXT_BYTES)!);
|
|
172
208
|
const context: CompactTaskContext = {
|
|
173
|
-
objective:
|
|
174
|
-
|
|
209
|
+
objective: view
|
|
210
|
+
? (compactText(view.task.intent.data.objective, COMPACT_TEXT_BYTES) ?? "")
|
|
211
|
+
: "no task on this branch yet",
|
|
212
|
+
status: view?.task.status ?? null,
|
|
175
213
|
decisions,
|
|
176
214
|
methods,
|
|
177
215
|
gaps,
|
|
178
|
-
nextAction: compactText(view
|
|
216
|
+
nextAction: compactText(view?.task.progress.nextAction ?? null, COMPACT_TEXT_BYTES),
|
|
179
217
|
};
|
|
180
218
|
let encoded = JSON.stringify(context);
|
|
181
219
|
while (
|
package/src/core/task-engine.ts
CHANGED
|
@@ -68,6 +68,7 @@ import { defaultLockTimeout } from "./store-lock";
|
|
|
68
68
|
import {
|
|
69
69
|
compactTaskContext,
|
|
70
70
|
exportDigest,
|
|
71
|
+
type InheritedDecisions,
|
|
71
72
|
reconcileResume as reconcileResumeContext,
|
|
72
73
|
type ResumeReconciliation,
|
|
73
74
|
} from "./task-context";
|
|
@@ -389,6 +390,18 @@ const TASK_SCOPED_ACTIONS: ReadonlySet<string> = new Set([
|
|
|
389
390
|
"worker.cancel",
|
|
390
391
|
"state.export",
|
|
391
392
|
]);
|
|
393
|
+
/** Worker actions that name their worker: they resolve to that worker's task. */
|
|
394
|
+
const WORKER_ADDRESSED_ACTIONS: ReadonlySet<string> = new Set(["worker.cancel", "worker.report"]);
|
|
395
|
+
/**
|
|
396
|
+
* The exact CLI command that runs worker `request` (cancel or report, with a
|
|
397
|
+
* valid workerId) on `taskId`, so a retry never depends on the branch.
|
|
398
|
+
*/
|
|
399
|
+
const workerCommand = (taskId: string, request: unknown): string => {
|
|
400
|
+
const { action, workerId, reason, report } = request as Record<string, unknown>;
|
|
401
|
+
const rest = JSON.stringify({ reason, report });
|
|
402
|
+
const payload = rest === "{}" ? "" : ` --payload '${rest.replace(/'/gu, "'\\''")}'`;
|
|
403
|
+
return `workit worker ${String(action)} ${String(workerId)} --task ${taskId}${payload}`;
|
|
404
|
+
};
|
|
392
405
|
/** Recording actions that create the implicit task on first use. */
|
|
393
406
|
const CREATING_ACTIONS: ReadonlySet<string> = new Set([
|
|
394
407
|
"task.revise",
|
|
@@ -711,6 +724,24 @@ export class WorkitCore {
|
|
|
711
724
|
if (!TASK_SCOPED_ACTIONS.has(action)) return success(null, null, request);
|
|
712
725
|
const root = this.contextRootError();
|
|
713
726
|
if (!root.ok) return root;
|
|
727
|
+
// A worker belongs to the task it was assigned on, whatever branch is
|
|
728
|
+
// checked out now (#264): worker.cancel/report follow the named worker,
|
|
729
|
+
// a helper's own calls follow its worker id.
|
|
730
|
+
const addressed = WORKER_ADDRESSED_ACTIONS.has(action);
|
|
731
|
+
// A missing or malformed id is invalid_input (from taskForWorker).
|
|
732
|
+
const workerId: unknown = addressed
|
|
733
|
+
? (value as { workerId?: unknown }).workerId
|
|
734
|
+
: (this.context.workerId ?? null);
|
|
735
|
+
if (workerId !== null) {
|
|
736
|
+
const owner = this.store.taskForWorker(workerId);
|
|
737
|
+
if (!owner.ok) return owner;
|
|
738
|
+
if (owner.data) return success(null, null, { ...request, taskId: owner.data });
|
|
739
|
+
if (addressed)
|
|
740
|
+
return failure(
|
|
741
|
+
"not_found",
|
|
742
|
+
`worker ${workerId as string} is on no open task; find its task with \`workit task status --all\`, then run \`${workerCommand("<task-id>", request)}\``,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
714
745
|
const create = CREATING_ACTIONS.has(action) && (this.context.workerId ?? null) === null;
|
|
715
746
|
const found = this.store.implicitTask({
|
|
716
747
|
provenance: provenance(this.context),
|
|
@@ -1605,7 +1636,16 @@ export class WorkitCore {
|
|
|
1605
1636
|
const resolved = this.implicitTask("worker", request);
|
|
1606
1637
|
if (!resolved.ok) return resolved;
|
|
1607
1638
|
const value = resolved.data;
|
|
1608
|
-
|
|
1639
|
+
const result = this.retryOmittedRevisions(value, () => this.workerOnce(value));
|
|
1640
|
+
// A cancel/report whose task was resolved from the worker id names the
|
|
1641
|
+
// exact command for that task (every host and the CLI see the same text).
|
|
1642
|
+
const { action, taskId } = (value ?? {}) as { action?: unknown; taskId?: unknown };
|
|
1643
|
+
return result.ok ||
|
|
1644
|
+
(request as { taskId?: unknown } | null)?.taskId !== undefined ||
|
|
1645
|
+
typeof taskId !== "string" ||
|
|
1646
|
+
!WORKER_ADDRESSED_ACTIONS.has(`worker.${String(action)}`)
|
|
1647
|
+
? result
|
|
1648
|
+
: { ...result, error: `${result.error}; run \`${workerCommand(taskId, value)}\`` };
|
|
1609
1649
|
}
|
|
1610
1650
|
|
|
1611
1651
|
private workerOnce(request: unknown): Result<Entry<Worker>> {
|
|
@@ -1892,9 +1932,31 @@ export class WorkitCore {
|
|
|
1892
1932
|
* judged against the last recorded candidate, and live staleness is
|
|
1893
1933
|
* detected by evidence, close, resume, and full inspection, which capture.
|
|
1894
1934
|
*/
|
|
1895
|
-
compactContext(
|
|
1935
|
+
compactContext(
|
|
1936
|
+
taskId: string | null,
|
|
1937
|
+
freshness?: Freshness,
|
|
1938
|
+
/** The base branch's task (TaskStore.originTaskEntry), whose decisions are inherited. */
|
|
1939
|
+
origin: { origin: string; taskId: string } | null = null,
|
|
1940
|
+
): Result<string> {
|
|
1896
1941
|
const root = this.contextRootError();
|
|
1897
1942
|
if (!root.ok) return root;
|
|
1943
|
+
const base = origin && this.store.readStoreTask(origin.taskId);
|
|
1944
|
+
const inherited: InheritedDecisions | null =
|
|
1945
|
+
base?.ok && base.data
|
|
1946
|
+
? {
|
|
1947
|
+
origin: origin!.origin,
|
|
1948
|
+
taskId: base.data.id,
|
|
1949
|
+
// A revoked decision no longer applies to anything cut from the base.
|
|
1950
|
+
decisions: base.data.decisions.filter((entry) => entry.data.revoked === null),
|
|
1951
|
+
}
|
|
1952
|
+
: null;
|
|
1953
|
+
// No task of its own yet: only what the branch inherits.
|
|
1954
|
+
if (taskId === null)
|
|
1955
|
+
return success(
|
|
1956
|
+
null,
|
|
1957
|
+
null,
|
|
1958
|
+
inherited?.decisions.length ? compactTaskContext(null, inherited) : "",
|
|
1959
|
+
);
|
|
1898
1960
|
const task = this.store.readTask(taskId);
|
|
1899
1961
|
if (!task.ok) return task;
|
|
1900
1962
|
const helper = this.helperTaskGuard(task.data, true);
|
|
@@ -1902,7 +1964,7 @@ export class WorkitCore {
|
|
|
1902
1964
|
// Per-turn path: the cheap worktree signal, never the object-writing tree hash.
|
|
1903
1965
|
const view = this.view(task.data, false, freshness ?? signalFreshness(this.store.root));
|
|
1904
1966
|
if (!view.ok) return view;
|
|
1905
|
-
return success(view.revision, view.workspaceRevision, compactTaskContext(view.data));
|
|
1967
|
+
return success(view.revision, view.workspaceRevision, compactTaskContext(view.data, inherited));
|
|
1906
1968
|
}
|
|
1907
1969
|
|
|
1908
1970
|
private view(
|
package/src/core/task-store.ts
CHANGED
|
@@ -314,6 +314,70 @@ const isNewerVersion = (candidate: string, current: string): boolean => {
|
|
|
314
314
|
};
|
|
315
315
|
const validId = (value: string): boolean =>
|
|
316
316
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
|
|
317
|
+
|
|
318
|
+
const readText = (file: string): string => {
|
|
319
|
+
try {
|
|
320
|
+
return fs.readFileSync(file, "utf8");
|
|
321
|
+
} catch {
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** `<old> <new> <ident> <unix seconds> <tz>\t<message>`: new sha, seconds, message. */
|
|
327
|
+
const REFLOG_LINE = /^[0-9a-f]+ ([0-9a-f]+) .* (\d+) [+-]\d{4}\t(.*)$/u;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The branch `branch` was cut from, read from git's reflogs (no git process).
|
|
331
|
+
* Only the branch log's first line counts, and only when it is the creation
|
|
332
|
+
* entry "branch: Created from <start>": an expired, rewritten or missing log
|
|
333
|
+
* says nothing. A branch created from HEAD takes
|
|
334
|
+
* the worktree HEAD log's checkout made by that creation (same new sha, same
|
|
335
|
+
* or next second) onto the branch or one of its earlier names (`aliases`, it
|
|
336
|
+
* may have been renamed since). A remote start (`origin/develop`) is
|
|
337
|
+
* returned as written.
|
|
338
|
+
*/
|
|
339
|
+
const branchStart = (
|
|
340
|
+
location: StoreLocation,
|
|
341
|
+
branch: string,
|
|
342
|
+
aliases: readonly string[],
|
|
343
|
+
): string | null => {
|
|
344
|
+
const first = readText(
|
|
345
|
+
path.join(path.dirname(location.dir), "logs", "refs", "heads", branch),
|
|
346
|
+
).split("\n", 1)[0];
|
|
347
|
+
// A "Created from" message only ever starts a log (git deletes the log
|
|
348
|
+
// with the branch), so the first line alone decides.
|
|
349
|
+
const created = REFLOG_LINE.exec(first);
|
|
350
|
+
const start = created && /^branch: Created from (\S+)$/u.exec(created[3])?.[1];
|
|
351
|
+
if (!created || !start) return null;
|
|
352
|
+
if (start !== "HEAD") return start;
|
|
353
|
+
if (!location.git) return null;
|
|
354
|
+
for (const line of readText(path.join(location.git.gitDir, "logs", "HEAD")).split("\n")) {
|
|
355
|
+
const entry = REFLOG_LINE.exec(line);
|
|
356
|
+
const moved = entry && /^checkout: moving from (\S+) to (\S+)$/u.exec(entry[3]);
|
|
357
|
+
const seconds = entry ? Number(entry[2]) - Number(created[2]) : -1;
|
|
358
|
+
if (
|
|
359
|
+
moved &&
|
|
360
|
+
entry[1] === created[1] &&
|
|
361
|
+
seconds >= 0 &&
|
|
362
|
+
seconds <= 1 &&
|
|
363
|
+
aliases.includes(moved[2])
|
|
364
|
+
)
|
|
365
|
+
return moved[1];
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Whether `ref` (`refs/remotes/origin/develop`) exists, loose or packed. */
|
|
371
|
+
const refExists = (common: string, ref: string): boolean =>
|
|
372
|
+
fs.existsSync(path.join(common, ref)) ||
|
|
373
|
+
readText(path.join(common, "packed-refs")).includes(` ${ref}\n`);
|
|
374
|
+
|
|
375
|
+
/** Resolved bases per checkout and branch, reused while the reflogs they came
|
|
376
|
+
* from are unchanged (one entry per branch a process looked at). */
|
|
377
|
+
const origins = new Map<
|
|
378
|
+
string,
|
|
379
|
+
{ signature: string; name: string | null; renames: ReturnType<typeof branchRenames> }
|
|
380
|
+
>();
|
|
317
381
|
export const sameDirectoryIdentity = (left: string, right: string): boolean => {
|
|
318
382
|
if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
|
|
319
383
|
const normalizedLeft = path.resolve(left);
|
|
@@ -658,6 +722,94 @@ export class TaskStore {
|
|
|
658
722
|
return success(null, null, { key: key.data, bound: bound.data });
|
|
659
723
|
}
|
|
660
724
|
|
|
725
|
+
/**
|
|
726
|
+
* The open task of this checkout that worker `workerId` is assigned to,
|
|
727
|
+
* whichever branch it is bound to: worker commands follow the worker, not
|
|
728
|
+
* the branch checked out now (#264). Null when no open task has it.
|
|
729
|
+
*/
|
|
730
|
+
taskForWorker(workerId: unknown): Result<Id | null> {
|
|
731
|
+
if (typeof workerId !== "string" || !validId(workerId))
|
|
732
|
+
return failure(
|
|
733
|
+
"invalid_input",
|
|
734
|
+
"needs a worker id: `workit worker cancel|report <workerId>`",
|
|
735
|
+
);
|
|
736
|
+
const listed = this.listTaskIndex();
|
|
737
|
+
if (!listed.ok) return listed;
|
|
738
|
+
for (const entry of listed.data) {
|
|
739
|
+
if (entry.status === "closed") continue;
|
|
740
|
+
const loaded = this.load(entry.id);
|
|
741
|
+
if (!loaded.ok) return loaded;
|
|
742
|
+
if (loaded.data?.record.workers.some((worker) => worker.id === workerId))
|
|
743
|
+
return success(null, null, entry.id);
|
|
744
|
+
}
|
|
745
|
+
return success(null, null, null);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The open task whose decisions the current branch inherits: the task of
|
|
750
|
+
* the branch it was cut from (`origin/develop` counts as `develop`), in any
|
|
751
|
+
* checkout of this repository, labelled with its origin ("branch develop").
|
|
752
|
+
* Null off a branch, or when git's reflogs do not name a base.
|
|
753
|
+
*/
|
|
754
|
+
originTaskEntry(): Result<{ origin: string; entry: TaskIndexEntry } | null> {
|
|
755
|
+
const key = this.currentKey();
|
|
756
|
+
const location = this.location();
|
|
757
|
+
const branch = key.ok ? key.data.branch : null;
|
|
758
|
+
if (!branch || !location.ok) return success(null, null, null);
|
|
759
|
+
const common = path.dirname(location.data.dir);
|
|
760
|
+
const heads = path.join(common, "logs", "refs", "heads");
|
|
761
|
+
const headLog = location.data.git ? path.join(location.data.git.gitDir, "logs", "HEAD") : "";
|
|
762
|
+
// Per-turn path: reading a long HEAD log and every branch log costs tens
|
|
763
|
+
// of ms, so the resolution is reused until one of these files changes.
|
|
764
|
+
const signatures = [
|
|
765
|
+
path.join(heads, branch),
|
|
766
|
+
headLog,
|
|
767
|
+
heads,
|
|
768
|
+
path.join(common, "packed-refs"),
|
|
769
|
+
].map((file) => fileSignature(file) ?? "-");
|
|
770
|
+
const slot = `${location.data.dir}\0${headLog}\0${branch}`;
|
|
771
|
+
let resolved = origins.get(slot);
|
|
772
|
+
if (resolved?.signature !== signatures.join("|")) {
|
|
773
|
+
const renames = branchRenames(location.data);
|
|
774
|
+
// The branch's earlier names, newest first: a rename keeps its log.
|
|
775
|
+
const aliases = [branch];
|
|
776
|
+
for (const rename of renames.toReversed())
|
|
777
|
+
if (rename.to === aliases.at(-1)) aliases.push(rename.from);
|
|
778
|
+
const start = branchStart(location.data, branch, aliases);
|
|
779
|
+
const slash = start?.indexOf("/") ?? -1;
|
|
780
|
+
// `origin/develop` is the remote's `develop`: strip the remote only for
|
|
781
|
+
// a remote-tracking ref or a configured remote, never for a local
|
|
782
|
+
// branch or tag that has a slash in its name.
|
|
783
|
+
const remote =
|
|
784
|
+
slash > 0 &&
|
|
785
|
+
!refExists(common, `refs/heads/${start}`) &&
|
|
786
|
+
(refExists(common, `refs/remotes/${start}`) ||
|
|
787
|
+
readText(path.join(common, "config")).includes(`[remote "${start!.slice(0, slash)}"]`));
|
|
788
|
+
const name = start && (remote ? start.slice(slash + 1) : start);
|
|
789
|
+
// Logs only grow (their size is in the signature), so no racy check.
|
|
790
|
+
resolved = {
|
|
791
|
+
signature: signatures.join("|"),
|
|
792
|
+
name: name && !aliases.includes(name) ? name : null,
|
|
793
|
+
renames,
|
|
794
|
+
};
|
|
795
|
+
origins.set(slot, resolved);
|
|
796
|
+
}
|
|
797
|
+
const entry = resolved.name ? this.openTasksForKey(resolved.name, resolved.renames) : null;
|
|
798
|
+
if (entry && !entry.ok) return entry;
|
|
799
|
+
return success(
|
|
800
|
+
null,
|
|
801
|
+
null,
|
|
802
|
+
entry?.data[0] ? { origin: `branch ${resolved.name}`, entry: entry.data[0] } : null,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The record of an indexed task (`taskId` from the index) in any checkout of
|
|
807
|
+
* this store: read-only, no binding check. */
|
|
808
|
+
readStoreTask(taskId: Id): Result<TaskRecord | null> {
|
|
809
|
+
const loaded = this.load(taskId);
|
|
810
|
+
return loaded.ok ? success(null, null, loaded.data?.record ?? null) : loaded;
|
|
811
|
+
}
|
|
812
|
+
|
|
661
813
|
// -------------------------------------------------------------------------
|
|
662
814
|
// writes
|
|
663
815
|
|
|
@@ -1442,11 +1594,14 @@ export class TaskStore {
|
|
|
1442
1594
|
* task follows its branch through `git branch -m`: it counts for the new
|
|
1443
1595
|
* name, and a branch created later under the old name starts fresh.
|
|
1444
1596
|
*/
|
|
1445
|
-
private openTasksForKey(
|
|
1597
|
+
private openTasksForKey(
|
|
1598
|
+
key: string,
|
|
1599
|
+
known?: ReturnType<typeof branchRenames>,
|
|
1600
|
+
): Result<TaskIndexEntry[]> {
|
|
1446
1601
|
const all = this.listStoreIndex();
|
|
1447
1602
|
if (!all.ok) return all;
|
|
1448
|
-
const location = this.location();
|
|
1449
|
-
const renames = location
|
|
1603
|
+
const location = known ? null : this.location();
|
|
1604
|
+
const renames = known ?? (location?.ok ? branchRenames(location.data) : []);
|
|
1450
1605
|
return success(
|
|
1451
1606
|
null,
|
|
1452
1607
|
null,
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// The prepare-commit-msg hook that gives a plain `git commit` the
|
|
2
|
+
// `Workit-Session:` trailer `workit git commit` adds (D18), for agents that
|
|
3
|
+
// cannot run `workit git` (a Claude Code worktree-isolated subagent).
|
|
4
|
+
// `workit doctor` reports it; `workit doctor --fix` installs it, in a Workit
|
|
5
|
+
// workspace only.
|
|
6
|
+
//
|
|
7
|
+
// - One source of truth: the trailer logic is SESSION_TRAILER_SCRIPT, written
|
|
8
|
+
// to `<git-common-dir>/workit/session-trailer.sh` (Workit-owned, never a hook
|
|
9
|
+
// slot, never the working tree). The hook Workit installs, and every line a
|
|
10
|
+
// user is told to add to a hook Workit does not own, is the same one-liner
|
|
11
|
+
// that runs it with the hook's arguments.
|
|
12
|
+
// - Slot: prepare-commit-msg, not commit-msg. Git runs it for -m/-F, amend,
|
|
13
|
+
// merge and squash, even with --no-verify, and before commit-msg, so a
|
|
14
|
+
// commitlint there sees the trailer. commit-msg stays the user's.
|
|
15
|
+
// - Where: `git rev-parse --git-path hooks`, so core.hooksPath is honoured.
|
|
16
|
+
// Workit writes only an empty slot or its own hook. Another tool's hook in
|
|
17
|
+
// the slot (husky, lefthook, a hand-written one) is never renamed, wrapped or
|
|
18
|
+
// edited: the state is `blocked` and `manual` names the line to add there;
|
|
19
|
+
// once that hook (or husky script, or lefthook config) runs the helper, the
|
|
20
|
+
// state is `manual`.
|
|
21
|
+
// A hooks directory outside the repository (a global core.hooksPath), or one
|
|
22
|
+
// in the working tree that git does not ignore, gets no hook either.
|
|
23
|
+
// - The session comes from the same variables as host-session.ts: WORKIT_SESSION_ID
|
|
24
|
+
// (unless WORKIT_HOST names another host than the shell's own), else
|
|
25
|
+
// OPENCODE_SESSION_ID, PI_SESSION_ID or CODEX_THREAD_ID. The message is left
|
|
26
|
+
// unchanged with no session or an unsafe id; for a plain editor commit (no
|
|
27
|
+
// message source: the trailer would sit in the buffer, and a user who empties
|
|
28
|
+
// it would commit a trailer-only message instead of aborting); for a
|
|
29
|
+
// template (git aborts an unedited one); for a comment-only message; and in a
|
|
30
|
+
// rebase or cherry-pick replaying commits that keep their own trailers.
|
|
31
|
+
// - `--if-exists addIfDifferent`: the same session's trailer is never repeated;
|
|
32
|
+
// another session that amends adds its own, as `workit git commit --amend` does.
|
|
33
|
+
import { spawnSync } from "node:child_process";
|
|
34
|
+
import {
|
|
35
|
+
chmodSync,
|
|
36
|
+
existsSync,
|
|
37
|
+
lstatSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
renameSync,
|
|
41
|
+
rmSync,
|
|
42
|
+
statSync,
|
|
43
|
+
writeFileSync,
|
|
44
|
+
} from "node:fs";
|
|
45
|
+
import path from "node:path";
|
|
46
|
+
import { GIT_TIMEOUTS } from "./rev";
|
|
47
|
+
|
|
48
|
+
export const SESSION_HOOK_NAME = "prepare-commit-msg";
|
|
49
|
+
export const SESSION_HOOK_MARKER = "# workit-session-trailer-hook";
|
|
50
|
+
/** The helper under the git common dir that holds the trailer logic. */
|
|
51
|
+
const HELPER = ["workit", "session-trailer.sh"] as const;
|
|
52
|
+
/** Left by an unreleased build that chained commit-msg; reported, never used. */
|
|
53
|
+
const LEGACY_CHAINED = "commit-msg.workit-chained";
|
|
54
|
+
|
|
55
|
+
// LF only: Git for Windows runs hooks with its own sh, which reads LF scripts.
|
|
56
|
+
export const SESSION_TRAILER_SCRIPT = `#!/bin/sh
|
|
57
|
+
# Workit (https://github.com/BrainerVirus/workit) wrote this file with
|
|
58
|
+
# \`workit doctor --fix\`. A prepare-commit-msg hook runs it with its own
|
|
59
|
+
# arguments to add a Workit-Session trailer to commits made in an agent session.
|
|
60
|
+
# Not a hook itself; delete it together with the line that runs it.
|
|
61
|
+
# lefthook passes a missing argument as its literal placeholder.
|
|
62
|
+
case "\${2-}" in "" | "{2}" | template) exit 0 ;; esac
|
|
63
|
+
gitdir=$(git rev-parse --git-dir) || exit 0
|
|
64
|
+
if [ -d "$gitdir/rebase-merge" ] || [ -d "$gitdir/rebase-apply" ] || [ -f "$gitdir/CHERRY_PICK_HEAD" ]; then
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
trim() { printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'; }
|
|
68
|
+
inner=$(trim "\${OPENCODE_SESSION_ID-}"); inner_host=opencode
|
|
69
|
+
if [ -z "$inner" ]; then inner=$(trim "\${PI_SESSION_ID-}"); inner_host=pi; fi
|
|
70
|
+
if [ -z "$inner" ]; then inner=$(trim "\${CODEX_THREAD_ID-}"); inner_host=codex; fi
|
|
71
|
+
[ -n "$inner" ] || inner_host=
|
|
72
|
+
declared=$(trim "\${WORKIT_HOST-}")
|
|
73
|
+
family=$(printf '%s' "$declared" | sed 's/_cli$//;s/_desktop$//')
|
|
74
|
+
session=$inner
|
|
75
|
+
if [ "\${WORKIT_SESSION_ID+set}" = set ]; then
|
|
76
|
+
if [ -z "$inner" ] || [ -z "$declared" ] || [ "$family" = "$inner_host" ]; then
|
|
77
|
+
session=$(trim "$WORKIT_SESSION_ID")
|
|
78
|
+
fi
|
|
79
|
+
fi
|
|
80
|
+
[ -n "$session" ] || exit 0
|
|
81
|
+
[ \${#session} -le 128 ] || exit 0
|
|
82
|
+
case "$session" in *[!A-Za-z0-9_.:@/+-]*) exit 0 ;; esac
|
|
83
|
+
add_trailer() {
|
|
84
|
+
[ -f "$1" ] && [ -r "$1" ] || return 0
|
|
85
|
+
body=$(sed '/^# -\\{24\\} >8 -\\{24\\}$/,$d' "$1" | git stripspace --strip-comments)
|
|
86
|
+
[ -n "$body" ] || return 0
|
|
87
|
+
# A merge message has no final newline; interpret-trailers would glue the
|
|
88
|
+
# trailer to the subject line.
|
|
89
|
+
[ -z "$(tail -c 1 "$1")" ] || printf '\\n' >> "$1"
|
|
90
|
+
git -c trailer.separators=: -c trailer.where=end interpret-trailers --in-place \\
|
|
91
|
+
--no-divider --if-exists addIfDifferent --trailer "Workit-Session: $session" "$1" || return 0
|
|
92
|
+
}
|
|
93
|
+
# $1 is the message file. lefthook cannot quote a path it pastes in, so its
|
|
94
|
+
# job passes '' and the file is found here: git hands the hook COMMIT_EDITMSG,
|
|
95
|
+
# except \`git merge\`, which hands it MERGE_MSG. With source "merge" both are
|
|
96
|
+
# updated: the one git did not hand over is stale or removed after the commit.
|
|
97
|
+
case "\${1-}" in "" | "{1}") ;; *) add_trailer "$1"; exit 0 ;; esac
|
|
98
|
+
add_trailer "$(git rev-parse --git-path COMMIT_EDITMSG)"
|
|
99
|
+
[ "$2" != merge ] || add_trailer "$(git rev-parse --git-path MERGE_MSG)"
|
|
100
|
+
exit 0
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The line that runs the helper from a shell prepare-commit-msg hook, with the
|
|
105
|
+
* hook's own arguments. Hooks run at the top of the work tree, where the
|
|
106
|
+
* relative common dir resolves. A clone without the helper (a teammate who
|
|
107
|
+
* never ran `--fix`, while the line sits in a committed husky hook) runs
|
|
108
|
+
* nothing and prints nothing.
|
|
109
|
+
*/
|
|
110
|
+
export const manualTrailerLine = (): string =>
|
|
111
|
+
`h="$(git rev-parse --git-common-dir 2>/dev/null)/${HELPER.join("/")}"; [ ! -f "$h" ] || sh "$h" "$@" || true`;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The same for a lefthook `run:`, with no double quote in it: on Windows
|
|
115
|
+
* lefthook starts `"sh" -c "<run>"` as one raw command line, so a `"` in the
|
|
116
|
+
* run ends that argument early. `set -f` and an empty IFS keep the unquoted
|
|
117
|
+
* `$h` one word (a path with spaces) and unglobbed. lefthook pastes `{1}` in
|
|
118
|
+
* raw, and from a linked worktree it is an absolute path that may hold a
|
|
119
|
+
* space or a quote, which no quoting survives; so the job passes '' and the
|
|
120
|
+
* helper finds the message file itself. `{2}` and `{3}` are a source word and
|
|
121
|
+
* a sha, safe unquoted.
|
|
122
|
+
*/
|
|
123
|
+
export const lefthookTrailerRun = (): string =>
|
|
124
|
+
`set -f; IFS=; h=$(git rev-parse --git-common-dir 2>/dev/null)/${HELPER.join("/")}; [ ! -f $h ] || sh $h '' {2} {3} || true`;
|
|
125
|
+
|
|
126
|
+
/** What marks a hook or config that already runs the helper. */
|
|
127
|
+
const HELPER_REF = HELPER.join("/");
|
|
128
|
+
|
|
129
|
+
export const SESSION_HOOK_SCRIPT = `#!/bin/sh
|
|
130
|
+
${SESSION_HOOK_MARKER} v4
|
|
131
|
+
# Workit (https://github.com/BrainerVirus/workit) installed this hook with
|
|
132
|
+
# \`workit doctor --fix\`: it runs Workit's session-trailer helper, which adds a
|
|
133
|
+
# Workit-Session trailer to commits made in an agent session. To remove it,
|
|
134
|
+
# delete this file.
|
|
135
|
+
${manualTrailerLine()}
|
|
136
|
+
`;
|
|
137
|
+
|
|
138
|
+
export type SessionHookState =
|
|
139
|
+
/** Not inside a git work tree, or git is unavailable. */
|
|
140
|
+
| "not_git"
|
|
141
|
+
/** The current Workit hook and helper are installed. */
|
|
142
|
+
| "installed"
|
|
143
|
+
/** An older Workit hook, or a missing or older helper; --fix rewrites them. */
|
|
144
|
+
| "outdated"
|
|
145
|
+
/** The slot is empty; --fix installs the hook. */
|
|
146
|
+
| "missing"
|
|
147
|
+
/** --fix will not write the hook; `reason` says why and `manual` what to do instead. */
|
|
148
|
+
| "blocked"
|
|
149
|
+
/** Another tool's hook or config already runs the helper (`via`, `manualTarget`). */
|
|
150
|
+
| "manual";
|
|
151
|
+
|
|
152
|
+
/** Who owns the slot when it is blocked by another hook. */
|
|
153
|
+
export type HookOwner = "husky" | "lefthook" | "other";
|
|
154
|
+
|
|
155
|
+
export type SessionHookStatus = {
|
|
156
|
+
state: SessionHookState;
|
|
157
|
+
hooksDir: string | null;
|
|
158
|
+
hookPath: string | null;
|
|
159
|
+
/** The helper the hook and the manual line run. */
|
|
160
|
+
helperPath: string | null;
|
|
161
|
+
/** Whether the helper on disk is the current script. */
|
|
162
|
+
helperCurrent: boolean;
|
|
163
|
+
owner?: HookOwner;
|
|
164
|
+
reason?: string;
|
|
165
|
+
/** The manual fix when --fix cannot install the hook. */
|
|
166
|
+
manual?: string;
|
|
167
|
+
/** Where the manual line goes (or already is): a husky script, a lefthook config, a hook. */
|
|
168
|
+
manualTarget?: string;
|
|
169
|
+
/** For `manual`: who runs the helper. */
|
|
170
|
+
via?: "husky" | "lefthook" | "existing hook";
|
|
171
|
+
/** Files left by the unreleased commit-msg design, with how to recover. */
|
|
172
|
+
leftover?: string;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const run = (cwd: string, args: string[], env: NodeJS.ProcessEnv) => {
|
|
176
|
+
const r = spawnSync("git", args, {
|
|
177
|
+
cwd,
|
|
178
|
+
encoding: "utf8",
|
|
179
|
+
timeout: GIT_TIMEOUTS.local,
|
|
180
|
+
windowsHide: true,
|
|
181
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
182
|
+
env: { ...env, GIT_TERMINAL_PROMPT: "0" },
|
|
183
|
+
});
|
|
184
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").trim() };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const inside = (child: string, parent: string): boolean => {
|
|
188
|
+
const rel = path.relative(path.resolve(parent), path.resolve(child));
|
|
189
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const readText = (file: string): string | null => {
|
|
193
|
+
try {
|
|
194
|
+
return readFileSync(file, "utf8");
|
|
195
|
+
} catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const exists = (file: string): boolean => {
|
|
201
|
+
try {
|
|
202
|
+
lstatSync(file);
|
|
203
|
+
return true;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const LEFTHOOK_CONFIGS = [
|
|
210
|
+
"lefthook.yml",
|
|
211
|
+
"lefthook.yaml",
|
|
212
|
+
".lefthook.yml",
|
|
213
|
+
".lefthook.yaml",
|
|
214
|
+
"lefthook.json",
|
|
215
|
+
"lefthook.toml",
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
const lefthookConfig = (top: string): string | null =>
|
|
219
|
+
LEFTHOOK_CONFIGS.map((name) => path.join(top, name)).find((file) => existsSync(file)) ?? null;
|
|
220
|
+
|
|
221
|
+
// Whether a hook, script or config really runs the helper, not just mentions
|
|
222
|
+
// it. Shell text: a non-comment line invoking it, either directly
|
|
223
|
+
// (`sh …/workit/session-trailer.sh`) or as `sh "$h"` after an `h=` line
|
|
224
|
+
// naming it. Control flow (an `exit` before the line, an `if false`) is out
|
|
225
|
+
// of scope: this reads lines, it does not run them.
|
|
226
|
+
const isComment = (line: string) => /^\s*#/.test(line);
|
|
227
|
+
// `sh` in command position: line start or after `;`, `&`, `|`, `(`, `!`,
|
|
228
|
+
// `then`, `do` or `else` (so `echo sh $h` does not count).
|
|
229
|
+
const COMMAND_SH = String.raw`(?:^|[;&|(!]|\b(?:then|do|else)\s)\s*sh\s+`;
|
|
230
|
+
const DIRECT_CALL = new RegExp(
|
|
231
|
+
String.raw`${COMMAND_SH}(?:"[^"]*|\S*)${HELPER_REF.replaceAll(".", String.raw`\.`)}`,
|
|
232
|
+
);
|
|
233
|
+
const VAR_CALL = new RegExp(String.raw`${COMMAND_SH}"?\$h\b`);
|
|
234
|
+
const runsHelperText = (text: string): boolean => {
|
|
235
|
+
const lines = text.split(/\r?\n/).filter((line) => !isComment(line));
|
|
236
|
+
const assigned = lines.some((line) => /(?:^|[\s;])h=/.test(line) && line.includes(HELPER_REF));
|
|
237
|
+
return lines.some((line) => DIRECT_CALL.test(line) || (assigned && VAR_CALL.test(line)));
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const LEFTHOOK_YAML = [
|
|
241
|
+
"lefthook.yml",
|
|
242
|
+
"lefthook.yaml",
|
|
243
|
+
".lefthook.yml",
|
|
244
|
+
".lefthook.yaml",
|
|
245
|
+
"lefthook-local.yml",
|
|
246
|
+
"lefthook-local.yaml",
|
|
247
|
+
".lefthook-local.yml",
|
|
248
|
+
".lefthook-local.yaml",
|
|
249
|
+
];
|
|
250
|
+
const indentOf = (line: string) => line.length - line.trimStart().length;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A small indentation-aware read of a lefthook YAML config (no YAML library
|
|
254
|
+
* in deps): a `run:` value under the top-level `prepare-commit-msg:` key, on
|
|
255
|
+
* a non-comment line (or its `|`/`>` block), that runs the helper.
|
|
256
|
+
* lefthook.json/.toml are not read, so they never count as manual.
|
|
257
|
+
*/
|
|
258
|
+
const lefthookRunsHelper = (text: string): boolean => {
|
|
259
|
+
const lines = text.split(/\r?\n/);
|
|
260
|
+
const start = lines.findIndex((line) => /^prepare-commit-msg:\s*(?:#.*)?$/.test(line));
|
|
261
|
+
if (start < 0) return false;
|
|
262
|
+
const block: string[] = [];
|
|
263
|
+
for (const line of lines.slice(start + 1)) {
|
|
264
|
+
if (line.trim() && !isComment(line) && indentOf(line) === 0) break;
|
|
265
|
+
block.push(line);
|
|
266
|
+
}
|
|
267
|
+
return block.some((line, i) => {
|
|
268
|
+
if (isComment(line)) return false;
|
|
269
|
+
const runKey = /^(\s*)(?:-\s+)?run:\s*(.*)$/.exec(line);
|
|
270
|
+
if (!runKey) return false;
|
|
271
|
+
let value = runKey[2] ?? "";
|
|
272
|
+
if (/^[|>][-+]?\s*$/.test(value)) {
|
|
273
|
+
const body: string[] = [];
|
|
274
|
+
for (const next of block.slice(i + 1)) {
|
|
275
|
+
if (next.trim() && indentOf(next) <= indentOf(line)) break;
|
|
276
|
+
body.push(next);
|
|
277
|
+
}
|
|
278
|
+
value = body.join("\n");
|
|
279
|
+
}
|
|
280
|
+
return runsHelperText(value);
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const isExecutable = (file: string): boolean => {
|
|
285
|
+
if (process.platform === "win32") return true;
|
|
286
|
+
try {
|
|
287
|
+
return (statSync(file).mode & 0o111) !== 0;
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/** Whether the manual line is really in place for this owner. */
|
|
294
|
+
const manualInPlace = (input: ManualInput, target: string): boolean => {
|
|
295
|
+
if (input.owner === "lefthook")
|
|
296
|
+
return LEFTHOOK_YAML.some((name) => {
|
|
297
|
+
const text = readText(path.join(input.top, name));
|
|
298
|
+
return text !== null && lefthookRunsHelper(text);
|
|
299
|
+
});
|
|
300
|
+
const text = readText(target);
|
|
301
|
+
if (text === null || !runsHelperText(text)) return false;
|
|
302
|
+
// husky v9's `h` runs the user script with `sh -e`, so it needs no +x; any
|
|
303
|
+
// other target is a hook git runs itself, which git skips unless executable.
|
|
304
|
+
return (input.owner === "husky" && target !== input.hookPath) || isExecutable(target);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
/** husky v9 stubs source `h` beside them; v4-v8 hooks source `_/husky.sh`. */
|
|
308
|
+
const isHuskyV9 = (text: string, hooksDir: string): boolean =>
|
|
309
|
+
/\$\(dirname (?:-- )?"\$0"\)\/h"/.test(text) && existsSync(path.join(hooksDir, "h"));
|
|
310
|
+
|
|
311
|
+
const ownerOf = (text: string, hooksDir: string, top: string): HookOwner => {
|
|
312
|
+
if (text.includes("husky.sh") || isHuskyV9(text, hooksDir)) return "husky";
|
|
313
|
+
if (/lefthook/i.test(text) || lefthookConfig(top)) return "lefthook";
|
|
314
|
+
return "other";
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
type ManualInput = {
|
|
318
|
+
owner: HookOwner;
|
|
319
|
+
text: string;
|
|
320
|
+
hooksDir: string;
|
|
321
|
+
hookPath: string;
|
|
322
|
+
top: string;
|
|
323
|
+
/** Workit's earlier hook, moved aside by lefthook. */
|
|
324
|
+
displaced: string | null;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
/** The file the manual line goes in for this owner. */
|
|
328
|
+
const manualTargetOf = ({ owner, text, hooksDir, hookPath, top }: ManualInput): string => {
|
|
329
|
+
// husky v9 points core.hooksPath at `.husky/_` and runs `.husky/<hook>`
|
|
330
|
+
// from the stub; husky v4-v8 hooks are the user's files themselves.
|
|
331
|
+
if (owner === "husky" && isHuskyV9(text, hooksDir))
|
|
332
|
+
return path.join(path.dirname(hooksDir), SESSION_HOOK_NAME);
|
|
333
|
+
if (owner === "lefthook") return lefthookConfig(top) ?? path.join(top, "lefthook.yml");
|
|
334
|
+
return hookPath;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const manualFor = (input: ManualInput): string => {
|
|
338
|
+
const { owner, displaced } = input;
|
|
339
|
+
const target = manualTargetOf(input);
|
|
340
|
+
const stale = displaced ? `; delete the stale ${displaced} (nothing runs it)` : "";
|
|
341
|
+
if (owner === "husky")
|
|
342
|
+
return `add this line to ${target} (create it if missing; husky runs it)${stale}: ${manualTrailerLine()}`;
|
|
343
|
+
if (owner === "lefthook")
|
|
344
|
+
return [
|
|
345
|
+
`merge this job into ${target} (add it to an existing \`${SESSION_HOOK_NAME}:\` \`jobs:\` list rather than a second \`${SESSION_HOOK_NAME}:\` key), then run \`lefthook install\`${stale}:`,
|
|
346
|
+
`${SESSION_HOOK_NAME}:`,
|
|
347
|
+
" jobs:",
|
|
348
|
+
" - name: workit-session",
|
|
349
|
+
` run: ${lefthookTrailerRun()}`,
|
|
350
|
+
].join("\n");
|
|
351
|
+
return `add this line to ${target} (a new file needs \`#!/bin/sh\` as its first line and \`chmod +x\`)${stale}: ${manualTrailerLine()}`;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
const leftoverOf = (hooksDir: string): string | undefined => {
|
|
355
|
+
const chained = path.join(hooksDir, LEGACY_CHAINED);
|
|
356
|
+
const commitMsg = path.join(hooksDir, "commit-msg");
|
|
357
|
+
const oldOurs = readText(commitMsg)?.includes(SESSION_HOOK_MARKER) ?? false;
|
|
358
|
+
if (exists(chained))
|
|
359
|
+
return oldOurs
|
|
360
|
+
? `${chained} was left by an earlier Workit build: move it back to ${commitMsg} (replacing Workit's old hook there)`
|
|
361
|
+
: `${chained} was left by an earlier Workit build and nothing runs it: if it is your original commit-msg hook, move it back to ${commitMsg}; otherwise delete it`;
|
|
362
|
+
if (oldOurs)
|
|
363
|
+
return `${commitMsg} is a hook from an earlier Workit build; delete it (the trailer now comes from ${SESSION_HOOK_NAME})`;
|
|
364
|
+
return undefined;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
/** Where the prepare-commit-msg hook of the repository at `cwd` lives, and what it is. */
|
|
368
|
+
export function inspectSessionHook(
|
|
369
|
+
cwd: string,
|
|
370
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
371
|
+
): SessionHookStatus {
|
|
372
|
+
const none = { hooksDir: null, hookPath: null, helperPath: null, helperCurrent: false };
|
|
373
|
+
if (!existsSync(cwd)) return { state: "not_git", ...none };
|
|
374
|
+
const top = run(cwd, ["rev-parse", "--show-toplevel"], env);
|
|
375
|
+
if (!top.ok || !top.out) return { state: "not_git", ...none };
|
|
376
|
+
const paths = run(
|
|
377
|
+
cwd,
|
|
378
|
+
["rev-parse", "--path-format=absolute", "--git-common-dir", "--git-path", "hooks"],
|
|
379
|
+
env,
|
|
380
|
+
);
|
|
381
|
+
const [commonDir, hooksDir] = paths.out.split(/\r?\n/);
|
|
382
|
+
if (!paths.ok || !commonDir || !hooksDir) return { state: "not_git", ...none };
|
|
383
|
+
const hookPath = path.join(hooksDir, SESSION_HOOK_NAME);
|
|
384
|
+
const helperPath = path.join(commonDir, ...HELPER);
|
|
385
|
+
const helperCurrent = readText(helperPath) === SESSION_TRAILER_SCRIPT;
|
|
386
|
+
const leftover = leftoverOf(hooksDir);
|
|
387
|
+
const base = { hooksDir, hookPath, helperPath, helperCurrent, ...(leftover ? { leftover } : {}) };
|
|
388
|
+
const blocked = (reason: string, owner: HookOwner, text = ""): SessionHookStatus => {
|
|
389
|
+
const old = `${hookPath}.old`;
|
|
390
|
+
const displaced = readText(old)?.includes(SESSION_HOOK_MARKER) ? old : null;
|
|
391
|
+
const input = { owner, text, hooksDir, hookPath, top: top.out, displaced };
|
|
392
|
+
const manualTarget = manualTargetOf(input);
|
|
393
|
+
const owned = text ? { owner } : {};
|
|
394
|
+
// The user already added the line where it belongs.
|
|
395
|
+
if (manualInPlace(input, manualTarget))
|
|
396
|
+
return {
|
|
397
|
+
state: "manual",
|
|
398
|
+
...base,
|
|
399
|
+
...owned,
|
|
400
|
+
manualTarget,
|
|
401
|
+
via: owner === "other" ? "existing hook" : owner,
|
|
402
|
+
};
|
|
403
|
+
return {
|
|
404
|
+
state: "blocked",
|
|
405
|
+
...base,
|
|
406
|
+
...owned,
|
|
407
|
+
reason: displaced ? `${reason} (Workit's earlier hook was moved to ${old})` : reason,
|
|
408
|
+
manual: manualFor(input),
|
|
409
|
+
manualTarget,
|
|
410
|
+
};
|
|
411
|
+
};
|
|
412
|
+
if (!inside(hooksDir, commonDir) && !inside(hooksDir, top.out))
|
|
413
|
+
return blocked(
|
|
414
|
+
`core.hooksPath ${hooksDir} is outside the repository; a hook there runs in every repository that uses it`,
|
|
415
|
+
"other",
|
|
416
|
+
);
|
|
417
|
+
const text = exists(hookPath) ? (readText(hookPath) ?? "") : null;
|
|
418
|
+
if (text !== null) {
|
|
419
|
+
if (text === SESSION_HOOK_SCRIPT)
|
|
420
|
+
return { state: helperCurrent ? "installed" : "outdated", ...base };
|
|
421
|
+
if (text.includes(SESSION_HOOK_MARKER)) return { state: "outdated", ...base };
|
|
422
|
+
const owner = ownerOf(text, hooksDir, top.out);
|
|
423
|
+
return blocked(
|
|
424
|
+
`${hookPath} belongs to ${owner === "other" ? "another hook" : owner}; Workit never replaces it`,
|
|
425
|
+
owner,
|
|
426
|
+
text,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
if (!inside(hooksDir, commonDir)) {
|
|
430
|
+
// A hooks directory in the working tree (a committed .githooks): a new
|
|
431
|
+
// file there must be ignored, or it shows up in git status.
|
|
432
|
+
const rel = path.relative(top.out, hookPath).split(path.sep).join("/");
|
|
433
|
+
if (!run(top.out, ["check-ignore", "-q", "--", rel], env).ok)
|
|
434
|
+
return blocked(
|
|
435
|
+
`${hooksDir} is in the working tree and not ignored; the hook would show up as a new file`,
|
|
436
|
+
"other",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return { state: "missing", ...base };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Write `content` through a staged file renamed into place. The finally only
|
|
444
|
+
* matters when the write or chmod throws after the staged file exists; a
|
|
445
|
+
* rename failure cannot be provoked through inspect (an unreadable or
|
|
446
|
+
* directory slot is reported blocked first), so that path is not tested.
|
|
447
|
+
*/
|
|
448
|
+
const writeExecutable = (file: string, content: string) => {
|
|
449
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
450
|
+
const staged = `${file}.workit-new`;
|
|
451
|
+
try {
|
|
452
|
+
writeFileSync(staged, content, { mode: 0o755 });
|
|
453
|
+
chmodSync(staged, 0o755);
|
|
454
|
+
renameSync(staged, file);
|
|
455
|
+
} finally {
|
|
456
|
+
rmSync(staged, { force: true });
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
export type SessionHookInstall = {
|
|
461
|
+
/** What --fix did to the hook slot: wrote the hook, rewrote an older one, or nothing. */
|
|
462
|
+
action: "installed" | "updated" | "unchanged" | "skipped";
|
|
463
|
+
/** Whether the helper was (re)written. */
|
|
464
|
+
helperWritten: boolean;
|
|
465
|
+
status: SessionHookStatus;
|
|
466
|
+
detail: string;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Install (or refresh) the helper and the hook for the repository at `cwd`.
|
|
471
|
+
* The caller decides that `cwd` is a Workit workspace. The helper is written
|
|
472
|
+
* even when the slot is blocked, so the manual line works; the slot is written
|
|
473
|
+
* only when empty or holding Workit's own hook.
|
|
474
|
+
*/
|
|
475
|
+
export function installSessionHook(
|
|
476
|
+
cwd: string,
|
|
477
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
478
|
+
): SessionHookInstall {
|
|
479
|
+
const status = inspectSessionHook(cwd, env);
|
|
480
|
+
if (status.state === "not_git")
|
|
481
|
+
return {
|
|
482
|
+
action: "skipped",
|
|
483
|
+
helperWritten: false,
|
|
484
|
+
status,
|
|
485
|
+
detail: "not inside a git work tree",
|
|
486
|
+
};
|
|
487
|
+
const helperWritten = !status.helperCurrent;
|
|
488
|
+
if (helperWritten) writeExecutable(status.helperPath!, SESSION_TRAILER_SCRIPT);
|
|
489
|
+
const helper = helperWritten ? `; wrote ${status.helperPath}` : "";
|
|
490
|
+
if (status.state === "installed")
|
|
491
|
+
return {
|
|
492
|
+
action: "unchanged",
|
|
493
|
+
helperWritten,
|
|
494
|
+
status,
|
|
495
|
+
detail: `hook already at ${status.hookPath}`,
|
|
496
|
+
};
|
|
497
|
+
if (status.state === "manual")
|
|
498
|
+
return {
|
|
499
|
+
action: "unchanged",
|
|
500
|
+
helperWritten,
|
|
501
|
+
status: inspectSessionHook(cwd, env),
|
|
502
|
+
detail: `${status.manualTarget} already runs the helper (via ${status.via})${helper}`,
|
|
503
|
+
};
|
|
504
|
+
if (status.state === "blocked")
|
|
505
|
+
return {
|
|
506
|
+
action: "skipped",
|
|
507
|
+
helperWritten,
|
|
508
|
+
status: inspectSessionHook(cwd, env),
|
|
509
|
+
detail: `${status.reason}${helper}`,
|
|
510
|
+
};
|
|
511
|
+
const fresh = status.state === "missing";
|
|
512
|
+
const slotCurrent = readText(status.hookPath!) === SESSION_HOOK_SCRIPT;
|
|
513
|
+
if (!slotCurrent) writeExecutable(status.hookPath!, SESSION_HOOK_SCRIPT);
|
|
514
|
+
const verb = fresh ? "installed" : "updated";
|
|
515
|
+
return {
|
|
516
|
+
action: verb,
|
|
517
|
+
helperWritten,
|
|
518
|
+
status: inspectSessionHook(cwd, env),
|
|
519
|
+
detail: `${verb} the Workit ${SESSION_HOOK_NAME} hook at ${status.hookPath}${helper}`,
|
|
520
|
+
};
|
|
521
|
+
}
|
package/src/hooks/context.ts
CHANGED
|
@@ -74,6 +74,13 @@ export const currentTaskEntry = (
|
|
|
74
74
|
* CLI (`workit_cli`, which creates implicit tasks) are tooling, not
|
|
75
75
|
* conversations.
|
|
76
76
|
*/
|
|
77
|
+
/** Whether a host conversation other than `session` is bound to the task. */
|
|
78
|
+
const otherConversation = (entry: TaskIndexEntry, session: SessionHandle): boolean =>
|
|
79
|
+
entry.sessions.some(
|
|
80
|
+
(item) =>
|
|
81
|
+
item.host !== "workit_cli" && !(item.host === session.host && item.handle === session.handle),
|
|
82
|
+
);
|
|
83
|
+
|
|
77
84
|
const implicitTaskEntry = (
|
|
78
85
|
store: TaskStore,
|
|
79
86
|
entries: TaskIndexEntry[],
|
|
@@ -81,13 +88,7 @@ const implicitTaskEntry = (
|
|
|
81
88
|
): TaskIndexEntry | null => {
|
|
82
89
|
const open = entries.filter(
|
|
83
90
|
(entry) =>
|
|
84
|
-
entry.key !== null &&
|
|
85
|
-
entry.status !== "closed" &&
|
|
86
|
-
!entry.sessions.some(
|
|
87
|
-
(item) =>
|
|
88
|
-
item.host !== "workit_cli" &&
|
|
89
|
-
!(item.host === session.host && item.handle === session.handle),
|
|
90
|
-
),
|
|
91
|
+
entry.key !== null && entry.status !== "closed" && !otherConversation(entry, session),
|
|
91
92
|
);
|
|
92
93
|
if (open.length === 0) return null;
|
|
93
94
|
const key = store.currentKey();
|
|
@@ -174,23 +175,37 @@ export function sessionCompactContext(
|
|
|
174
175
|
const entry =
|
|
175
176
|
currentTaskEntry(listed.data, session, selection) ??
|
|
176
177
|
implicitTaskEntry(store, listed.data, session);
|
|
177
|
-
return
|
|
178
|
+
return entryCompactContext(store, entry, session, context);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
function entryCompactContext(
|
|
181
182
|
store: TaskStore,
|
|
182
|
-
entry: TaskIndexEntry,
|
|
183
|
+
entry: TaskIndexEntry | null,
|
|
183
184
|
session: SessionHandle,
|
|
184
185
|
context: OperationContext,
|
|
185
186
|
): string | null {
|
|
187
|
+
// The task of the branch this one was cut from: its decisions stay in view
|
|
188
|
+
// (labelled with their origin) instead of vanishing on a switch. Another
|
|
189
|
+
// conversation's task is never injected.
|
|
190
|
+
const found = store.originTaskEntry();
|
|
191
|
+
const origin =
|
|
192
|
+
found.ok &&
|
|
193
|
+
found.data &&
|
|
194
|
+
found.data.entry.id !== entry?.id &&
|
|
195
|
+
!otherConversation(found.data.entry, session)
|
|
196
|
+
? found.data
|
|
197
|
+
: null;
|
|
198
|
+
if (!entry && !origin) return null;
|
|
186
199
|
const workspace = store.readWorkspace();
|
|
187
|
-
if (!workspace.ok || !workspace.data) return null;
|
|
200
|
+
if (!workspace.ok || (entry && !workspace.data)) return null;
|
|
188
201
|
const slot = `${store.root}\0${session.host}\0${session.handle}`;
|
|
189
202
|
const key = canonicalJson({
|
|
190
|
-
task: entry
|
|
191
|
-
revision: entry
|
|
192
|
-
file: entry
|
|
193
|
-
|
|
203
|
+
task: entry?.id ?? null,
|
|
204
|
+
revision: entry?.revision ?? null,
|
|
205
|
+
file: entry?.file ?? null,
|
|
206
|
+
// The origin task's log signature changes with every write to it.
|
|
207
|
+
origin: origin && origin.entry.file,
|
|
208
|
+
workspace: workspace.data?.revision ?? null,
|
|
194
209
|
caller: context.caller,
|
|
195
210
|
workerId: context.workerId ?? null,
|
|
196
211
|
capabilities: context.capabilities,
|
|
@@ -205,18 +220,22 @@ function entryCompactContext(
|
|
|
205
220
|
(hit.signal === null || hit.signal === recentSignal(store.root))
|
|
206
221
|
)
|
|
207
222
|
return hit.value;
|
|
208
|
-
const task = store.readTask(entry.id);
|
|
209
|
-
if (!task.ok) return null;
|
|
223
|
+
const task = entry ? store.readTask(entry.id) : null;
|
|
224
|
+
if (task && !task.ok) return null;
|
|
210
225
|
const freshness: Freshness = { mode: "signal", current: () => recentSignal(store.root) };
|
|
211
|
-
const observed = task
|
|
212
|
-
const files = task
|
|
226
|
+
const observed = task?.data.evidence.some(isObservedCheck) ?? false;
|
|
227
|
+
const files = (task?.data.decisions ?? []).flatMap((decision) =>
|
|
213
228
|
decision.data.binding.contentRefs.flatMap((ref) =>
|
|
214
229
|
ref.kind === "file" ? [path.resolve(store.root, ref.path)] : [],
|
|
215
230
|
),
|
|
216
231
|
);
|
|
217
232
|
const observedFiles = filesKey(files);
|
|
218
|
-
const compact = new WorkitCore(store, context).compactContext(
|
|
219
|
-
|
|
233
|
+
const compact = new WorkitCore(store, context).compactContext(
|
|
234
|
+
entry?.id ?? null,
|
|
235
|
+
freshness,
|
|
236
|
+
origin && { origin: origin.origin, taskId: origin.entry.id },
|
|
237
|
+
);
|
|
238
|
+
if (!compact.ok || !compact.data) return null;
|
|
220
239
|
cache.delete(slot);
|
|
221
240
|
cache.set(slot, {
|
|
222
241
|
key,
|
|
@@ -264,9 +283,12 @@ export const sessionContextText = (
|
|
|
264
283
|
const entry =
|
|
265
284
|
currentTaskEntry(listed.data, session, descriptor.context.task) ??
|
|
266
285
|
implicitTaskEntry(store, listed.data, session);
|
|
267
|
-
const text =
|
|
268
|
-
|
|
269
|
-
|
|
286
|
+
const text = entryCompactContext(
|
|
287
|
+
store,
|
|
288
|
+
entry,
|
|
289
|
+
session,
|
|
290
|
+
hookOperationContext(input, descriptor),
|
|
291
|
+
);
|
|
270
292
|
if (text) compact = `\n<workit-task-context>${text}</workit-task-context>`;
|
|
271
293
|
if (options.offer) offer = unfinishedTaskOffer(listed.data, session, entry?.id ?? null);
|
|
272
294
|
} catch {
|