@brainervirus/workit-core 1.2.1 → 1.3.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/skills/workit-babysit/SKILL.md +20 -26
  3. package/skills/workit-behavioral-tdd/SKILL.md +0 -5
  4. package/skills/workit-blast-radius/SKILL.md +0 -5
  5. package/skills/workit-challenge/SKILL.md +30 -47
  6. package/skills/workit-debug/SKILL.md +5 -27
  7. package/skills/workit-deslop/SKILL.md +0 -5
  8. package/skills/workit-diagram/SKILL.md +0 -5
  9. package/skills/workit-green-run/SKILL.md +0 -5
  10. package/skills/workit-handoff/SKILL.md +2 -8
  11. package/skills/workit-implement/SKILL.md +14 -20
  12. package/skills/workit-mockup/SKILL.md +0 -5
  13. package/skills/workit-plan/SKILL.md +30 -63
  14. package/skills/workit-review/SKILL.md +0 -5
  15. package/skills/workit-steer/SKILL.md +27 -27
  16. package/src/core/authority.ts +6 -1
  17. package/src/core/auto-approval.ts +34 -14
  18. package/src/core/branch.ts +293 -42
  19. package/src/core/config-conversion.ts +4 -4
  20. package/src/core/config-guard.ts +1 -7
  21. package/src/core/config.ts +1 -1
  22. package/src/core/cutover.ts +1207 -140
  23. package/src/core/doctor.ts +155 -82
  24. package/src/core/external-action-effects.ts +886 -172
  25. package/src/core/external-action.ts +222 -64
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +65 -52
  29. package/src/core/policy-resolver.ts +13 -5
  30. package/src/core/ports/pr-create.ts +23 -15
  31. package/src/core/pr-create.ts +374 -84
  32. package/src/core/registration.ts +0 -15
  33. package/src/core/repo-tools.ts +0 -1
  34. package/src/core/route-intent.ts +34 -82
  35. package/src/core/safe-write.ts +66 -1
  36. package/src/core/setup-state.ts +7 -0
  37. package/src/core/setup.ts +175 -82
  38. package/src/core/task-contract.ts +66 -6
  39. package/src/core/task-engine.ts +47 -2
  40. package/src/core/task-store.ts +159 -1
  41. package/src/core/tracker-issues.ts +55 -118
  42. package/src/core/vcs-config.ts +178 -187
  43. package/src/core/workspaces.ts +554 -46
  44. package/src/core.ts +3 -2
  45. package/src/core/ports/vcs-token-create-urls.ts +0 -4
@@ -588,6 +588,8 @@ export const decisionSchema = z
588
588
  state: z.enum(["reserved", "consumed", "uncertain"]),
589
589
  at: utc,
590
590
  actionRef: refSchema,
591
+ /** Historical identifier only; never interpreted as native authority. */
592
+ historicalActionId: text.optional(),
591
593
  })
592
594
  .strict()
593
595
  .nullable(),
@@ -809,7 +811,10 @@ export const taskSummarySchema = z
809
811
  })
810
812
  .strict();
811
813
  export type TaskSummary = z.infer<typeof taskSummarySchema>;
812
- export const taskListItemSchema = taskSummarySchema.omit({ policy: true, requirements: true });
814
+ export const taskListItemSchema = taskSummarySchema
815
+ .omit({ policy: true, requirements: true })
816
+ .extend({ source: provenanceSchema.pick({ host: true, kind: true }) })
817
+ .strict();
813
818
  export type TaskListItem = z.infer<typeof taskListItemSchema>;
814
819
  export const exportBundleSchema = z
815
820
  .object({
@@ -844,6 +849,7 @@ const taskOperations = {
844
849
  list: operation({
845
850
  action: z.literal("list"),
846
851
  status: z.enum(["open", "closed", "all"]).optional(),
852
+ query: z.string().trim().min(1).max(200).optional(),
847
853
  limit: z.number().int().min(1).max(50).optional(),
848
854
  }),
849
855
  inspect: operation({
@@ -1134,8 +1140,61 @@ export const OPERATION_SCHEMA_DEPTH = 1;
1134
1140
  /** Provider nesting limit every advertised Workit schema must stay within. */
1135
1141
  export const OPERATION_SCHEMA_MAX_DEPTH = 10;
1136
1142
 
1137
- export const canonicalFieldsDescription = (fields: string[]): string =>
1138
- `Canonical object fields: ${fields.join(", ")}. Workit validates the complete nested value.`;
1143
+ const schemaShapeDescription = (value: unknown, depth = 0): string => {
1144
+ if (!value || typeof value !== "object") return "value";
1145
+ const schema = value as Record<string, unknown>;
1146
+ if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
1147
+ const options = (Array.isArray(schema.oneOf) ? schema.oneOf : schema.anyOf) as unknown[];
1148
+ return `(${options.map((option) => schemaShapeDescription(option, depth)).join("|")})`;
1149
+ }
1150
+ if (schema.const !== undefined) return `=${JSON.stringify(schema.const)}`;
1151
+ if (Array.isArray(schema.enum)) return `[${schema.enum.map(String).join("|")}]`;
1152
+ const type = Array.isArray(schema.type) ? schema.type.join("/") : schema.type;
1153
+ if (type === "array") return `arr<${schemaShapeDescription(schema.items, depth)}>`;
1154
+ if (type === "object" && schema.properties && typeof schema.properties === "object") {
1155
+ if (depth >= 2) return "obj";
1156
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
1157
+ const properties = schema.properties as Record<string, unknown>;
1158
+ const fields = Object.entries(properties).map(
1159
+ ([name, child]) =>
1160
+ `${name}${required.has(name) ? "!" : ""}:${schemaShapeDescription(child, depth + 1)}`,
1161
+ );
1162
+ return `obj{${fields.join(",")}}`;
1163
+ }
1164
+ if (typeof type !== "string") return "?";
1165
+ return type
1166
+ .split("/")
1167
+ .map((part) => ({ string: "str", boolean: "bool", number: "num", null: "null" })[part] ?? part)
1168
+ .join("|");
1169
+ };
1170
+
1171
+ export const canonicalFieldsDescription = (
1172
+ value: string[] | Record<string, unknown> | z.ZodType,
1173
+ ): string => {
1174
+ if (Array.isArray(value))
1175
+ return `Canonical object fields: ${value.join(", ")}. Workit validates the complete nested value.`;
1176
+ const isZodType = (schema: Record<string, unknown> | z.ZodType): schema is z.ZodType =>
1177
+ "_zod" in schema && typeof schema._zod === "object";
1178
+ const schema = isZodType(value)
1179
+ ? (z.toJSONSchema(value, { target: "draft-2020-12" }) as Record<string, unknown>)
1180
+ : value;
1181
+ if (!schema.properties || typeof schema.properties !== "object") {
1182
+ const variants = Array.isArray(schema.oneOf)
1183
+ ? schema.oneOf
1184
+ : Array.isArray(schema.anyOf)
1185
+ ? schema.anyOf
1186
+ : null;
1187
+ return variants
1188
+ ? `Canonical shapes: ${variants.map((variant) => schemaShapeDescription(variant)).join(" | ")}. Full nested value is validated.`
1189
+ : "Canonical nested value; Workit validates the complete value.";
1190
+ }
1191
+ const properties = schema.properties as Record<string, unknown>;
1192
+ const required = new Set(Array.isArray(schema.required) ? schema.required : []);
1193
+ const fields = Object.entries(properties).map(
1194
+ ([name, child]) => `${name}${required.has(name) ? "!" : ""}:${schemaShapeDescription(child)}`,
1195
+ );
1196
+ return `Fields (! required): ${fields.join(", ")}. Full nested value is validated.`;
1197
+ };
1139
1198
 
1140
1199
  /**
1141
1200
  * Provider-safe projection of an operation JSON schema. Objects deeper than
@@ -1172,11 +1231,12 @@ export function boundedOperationJsonSchema(
1172
1231
  if (typeof node !== "object" || node === null) return node;
1173
1232
  const record = node as Record<string, unknown>;
1174
1233
  if (record.properties && typeof record.properties === "object") {
1175
- const fields = Object.keys(record.properties as Record<string, unknown>);
1234
+ const properties = record.properties as Record<string, unknown>;
1235
+ const fields = Object.keys(properties);
1176
1236
  if (currentDepth >= maxDepth || fields.length === 0) {
1177
1237
  const description = stringifiedObjects
1178
- ? `${canonicalFieldsDescription(fields)} A JSON-encoded string is also accepted.`
1179
- : canonicalFieldsDescription(fields);
1238
+ ? `${canonicalFieldsDescription(record)} A JSON-encoded string is also accepted.`
1239
+ : canonicalFieldsDescription(record);
1180
1240
  return tolerate({ type: "object", description });
1181
1241
  }
1182
1242
  return tolerate({
@@ -512,6 +512,21 @@ const portableTask = (task: TaskRecord): TaskRecord => {
512
512
  };
513
513
  for (const entry of clone.assessments)
514
514
  demote(entry.data.facts, entry.data.signals, entry.data.consequences);
515
+ const sourceDecisions = new Map(task.decisions.map((entry) => [entry.id, entry]));
516
+ for (const entry of clone.decisions) {
517
+ const consumption = sourceDecisions.get(entry.id)?.data.consumption;
518
+ if (!consumption) continue;
519
+ entry.data.consumption = {
520
+ ...consumption,
521
+ state: consumption.state === "reserved" ? "uncertain" : consumption.state,
522
+ actionRef: { kind: "record", collection: "decisions", id: entry.id },
523
+ ...(consumption.historicalActionId
524
+ ? { historicalActionId: consumption.historicalActionId }
525
+ : consumption.actionRef.kind === "host"
526
+ ? { historicalActionId: consumption.actionRef.handle }
527
+ : {}),
528
+ };
529
+ }
515
530
  // Candidate metadata can contain environment-derived paths and digests. The destination
516
531
  // must recapture its own candidate instead of receiving source checkout material.
517
532
  clone.candidates = [];
@@ -649,7 +664,13 @@ const importedTask = (
649
664
  taskId: "",
650
665
  workspaceId: destinationWorkspaceId,
651
666
  },
652
- consumption: null,
667
+ consumption: data.consumption
668
+ ? {
669
+ ...data.consumption,
670
+ state: data.consumption.state === "reserved" ? "uncertain" : data.consumption.state,
671
+ actionRef: { kind: "record", collection: "decisions", id: entry.id },
672
+ }
673
+ : null,
653
674
  },
654
675
  };
655
676
  }),
@@ -873,7 +894,7 @@ export class WorkitCore {
873
894
  if (!parsed.ok) return parsed as Result<never>;
874
895
  const input = parsed.data as any;
875
896
  if ((this.context.workerId ?? null) !== null) {
876
- if (input.action !== "inspect")
897
+ if (input.action !== "inspect" && input.action !== "list")
877
898
  return failure("permission_denied", "helpers cannot control task lifecycle or scope");
878
899
  }
879
900
  switch (input.action) {
@@ -897,6 +918,7 @@ export class WorkitCore {
897
918
  if (!listed.ok) return listed as Result<never>;
898
919
  const status = input.status ?? "open";
899
920
  const limit = input.limit ?? 20;
921
+ const query = input.query?.toLowerCase().split(/\s+/);
900
922
  const selected = listed.data
901
923
  .filter((task) =>
902
924
  status === "all"
@@ -905,6 +927,25 @@ export class WorkitCore {
905
927
  ? task.status === "closed"
906
928
  : task.status !== "closed",
907
929
  )
930
+ .filter(
931
+ (task) =>
932
+ !query ||
933
+ query.every((term: string) =>
934
+ JSON.stringify({
935
+ id: task.id,
936
+ workspaceId: task.workspaceId,
937
+ origin: task.origin,
938
+ createdAt: task.createdAt,
939
+ updatedAt: task.updatedAt,
940
+ source: task.intent.provenance,
941
+ objective: task.intent.data.objective,
942
+ progress: task.progress,
943
+ decisions: task.decisions.map(({ data }) => data),
944
+ })
945
+ .toLowerCase()
946
+ .includes(term),
947
+ ),
948
+ )
908
949
  .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
909
950
  .slice(0, limit);
910
951
  if (selected.length === 0) return success(null, null, []);
@@ -923,6 +964,10 @@ export class WorkitCore {
923
964
  closure: task.closure,
924
965
  progress: task.progress,
925
966
  writer: task.status === "closed" ? null : workspace.data!.writer,
967
+ source: {
968
+ host: task.intent.provenance.host,
969
+ kind: task.intent.provenance.kind,
970
+ },
926
971
  }));
927
972
  return success(null, null, tasks);
928
973
  }
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import * as fs from "node:fs";
3
4
  import { hostname } from "node:os";
4
5
  import path from "node:path";
@@ -76,6 +77,7 @@ export type MetadataLock = {
76
77
  processStart: string | null;
77
78
  host: string;
78
79
  nonce: string;
80
+ externalAction?: true;
79
81
  };
80
82
  export type ProcessEvidence = {
81
83
  state: "stopped" | "accounted_for";
@@ -100,6 +102,7 @@ const metadataLockSchema = z
100
102
  processStart: z.string().nullable(),
101
103
  host: z.string().min(1),
102
104
  nonce: z.string().min(1),
105
+ externalAction: z.literal(true).optional(),
103
106
  })
104
107
  .strict();
105
108
  export type RecoveryCandidate = {
@@ -170,7 +173,35 @@ const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
170
173
  const parsed = metadataLockSchema.safeParse(left);
171
174
  return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
172
175
  };
176
+ export const sameDirectoryIdentity = (left: string, right: string): boolean => {
177
+ if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
178
+ const normalizedLeft = path.resolve(left);
179
+ const normalizedRight = path.resolve(right);
180
+ try {
181
+ const a = fs.statSync(normalizedLeft, { bigint: true });
182
+ const b = fs.statSync(normalizedRight, { bigint: true });
183
+ if (!a.isDirectory() || !b.isDirectory()) return false;
184
+ if (
185
+ process.platform === "win32"
186
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
187
+ : normalizedLeft === normalizedRight
188
+ )
189
+ return true;
190
+ const canonicalLeft = fs.realpathSync(normalizedLeft);
191
+ const canonicalRight = fs.realpathSync(normalizedRight);
192
+ if (
193
+ process.platform === "win32"
194
+ ? canonicalLeft.toLowerCase() === canonicalRight.toLowerCase()
195
+ : canonicalLeft === canonicalRight
196
+ )
197
+ return true;
198
+ return a.dev === b.dev && a.ino !== 0n && a.ino === b.ino;
199
+ } catch {
200
+ return false;
201
+ }
202
+ };
173
203
  type LockSnapshot = { raw: string; data: MetadataLock };
204
+ const externalActionLockRoots = new AsyncLocalStorage<ReadonlySet<string>>();
174
205
 
175
206
  export class TaskStore {
176
207
  readonly root: string;
@@ -230,7 +261,11 @@ export class TaskStore {
230
261
  readWorkspace(): Result<WorkspaceRecord | null> {
231
262
  const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
232
263
  if (!item.exists) return success(null, null, null);
233
- if (item.result.ok && item.result.data.root !== this.root)
264
+ if (
265
+ item.result.ok &&
266
+ item.result.data.root !== this.root &&
267
+ !sameDirectoryIdentity(item.result.data.root, this.root)
268
+ )
234
269
  return failure("recovery_required", "workspace root binding is invalid", {
235
270
  path: this.workspacePath,
236
271
  });
@@ -355,6 +390,37 @@ export class TaskStore {
355
390
  return this.withLock<TaskRecord>(() => {
356
391
  const current = this.readWorkspace();
357
392
  if (!current.ok) return current;
393
+ const origin = input.task.origin;
394
+ if (current.data && origin) {
395
+ if (origin.workspaceId === current.data.id) {
396
+ const source = this.readTask(origin.taskId);
397
+ if (source.ok) return success(source.data.revision, current.data.revision, source.data);
398
+ if (source.code !== "not_found") return source as Result<never>;
399
+ return failure("not_found", "source task is not present in this shared workspace");
400
+ }
401
+ const tasks = this.listTasks();
402
+ if (!tasks.ok) return tasks as Result<never>;
403
+ const prior = tasks.data.filter(
404
+ (task) =>
405
+ task.origin?.workspaceId === origin.workspaceId &&
406
+ task.origin?.taskId === origin.taskId,
407
+ );
408
+ if (prior.length > 1)
409
+ return failure(
410
+ "recovery_required",
411
+ `source task has multiple imported mappings: ${prior.map((task) => task.id).join(", ")}`,
412
+ );
413
+ if (prior.length === 1) {
414
+ const existing = prior[0]!;
415
+ if (existing.origin!.exportDigest !== origin.exportDigest)
416
+ return failure(
417
+ "revision_conflict",
418
+ "source task export changed; reconcile the mapped destination task before importing",
419
+ { taskId: existing.id },
420
+ );
421
+ return success(existing.revision, current.data.revision, existing);
422
+ }
423
+ }
358
424
  if (
359
425
  current.data
360
426
  ? input.expectedWorkspaceRevision !== current.data.revision
@@ -822,6 +888,90 @@ export class TaskStore {
822
888
  };
823
889
  }
824
890
 
891
+ private externalActionLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
892
+ const options = this.metadataLockOptions();
893
+ return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
894
+ }
895
+
896
+ /** Hold the workspace metadata lock across a managed effect in this checkout. */
897
+ async withExternalActionLock<T>(
898
+ operation: () => Promise<Result<T>>,
899
+ reentrant = false,
900
+ ): Promise<Result<T>> {
901
+ const activeRoots = externalActionLockRoots.getStore();
902
+ if (
903
+ activeRoots &&
904
+ [...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root))
905
+ )
906
+ return operation();
907
+ try {
908
+ this.initializeMutationStorage();
909
+ } catch (error) {
910
+ return failure("storage_error", `unable to initialize store: ${String(error)}`, {
911
+ path: this.workitDir,
912
+ });
913
+ }
914
+ let handle: FileLockSyncHandle;
915
+ try {
916
+ handle = acquireFileLockSync(this.workspacePath, this.externalActionLockOptions());
917
+ } catch (error) {
918
+ return this.lockFailure(error);
919
+ }
920
+ let result: Result<T>;
921
+ try {
922
+ if (!handle.verifyStillHeld())
923
+ result = failure("recovery_required", "metadata lock was compromised", {
924
+ path: this.lockPath,
925
+ });
926
+ else {
927
+ try {
928
+ const run = () => operation();
929
+ result = await (reentrant
930
+ ? externalActionLockRoots.run(new Set([...(activeRoots ?? []), this.root]), run)
931
+ : run());
932
+ } catch {
933
+ result = failure("external_outcome_unknown", "external action outcome is unknown", {
934
+ outcome: "unknown",
935
+ });
936
+ }
937
+ try {
938
+ if (!handle.verifyStillHeld())
939
+ result = failure("external_outcome_unknown", "metadata lock was compromised", {
940
+ outcome: "unknown",
941
+ path: this.lockPath,
942
+ });
943
+ } catch {
944
+ result = failure("external_outcome_unknown", "metadata lock could not be verified", {
945
+ outcome: "unknown",
946
+ path: this.lockPath,
947
+ });
948
+ }
949
+ }
950
+ } catch {
951
+ result = failure("external_outcome_unknown", "external action lock operation failed", {
952
+ outcome: "unknown",
953
+ path: this.lockPath,
954
+ });
955
+ }
956
+ try {
957
+ handle.release();
958
+ } catch (error) {
959
+ const released = this.lockFailure(error);
960
+ const releaseError = released.ok ? "metadata lock release failed" : released.error;
961
+ return result.ok
962
+ ? failure(
963
+ "external_outcome_unknown",
964
+ "external action metadata lock release is uncertain",
965
+ {
966
+ outcome: "unknown",
967
+ path: this.lockPath,
968
+ },
969
+ )
970
+ : failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
971
+ }
972
+ return result;
973
+ }
974
+
825
975
  private recoveryLockOptions(
826
976
  observed: LockSnapshot | null,
827
977
  evidence: ProcessEvidence,
@@ -912,6 +1062,14 @@ export class TaskStore {
912
1062
  private lockFailure(error: unknown): Result<never> {
913
1063
  const value = error as { code?: unknown; message?: unknown };
914
1064
  const code = typeof value?.code === "string" ? value.code : "";
1065
+ if (code === "EEXIST" || code === "file_lock_timeout") {
1066
+ const lock = this.readLockSnapshot();
1067
+ if (lock.ok && lock.data?.data.externalAction)
1068
+ return failure("writer_conflict", "workspace is reserved by a managed external action", {
1069
+ outcome: "not_started",
1070
+ path: this.lockPath,
1071
+ });
1072
+ }
915
1073
  const recovery =
916
1074
  code === "EEXIST" ||
917
1075
  code === "file_lock_timeout" ||
@@ -1,12 +1,9 @@
1
- import fs from "node:fs";
2
1
  import { spawnSync } from "node:child_process";
3
- import { vcsConfig } from "./vcs-config";
4
- import { hostingCliAvailable, parseGhIssue } from "./pr-create";
2
+ import { hostingCliAvailable, parseGhIssue, pushRemote } from "./pr-create";
3
+ import { hostingApiHostMatches, vcsConfig } from "./vcs-config";
5
4
 
6
5
  // Read-only issue fetches for context.read (GitHub + GitLab), mirroring the
7
- // YouTrack triple shape. CLI-first: the native gh/glab CLI serves the read
8
- // when installed (per-directory identity comes free); token files are the
9
- // fallback and fail closed without one. No writes, no new secret files.
6
+ // YouTrack triple shape. Authenticated gh/glab serves the read in the target checkout.
10
7
 
11
8
  export type TrackerIssueBody = {
12
9
  id: string;
@@ -20,22 +17,6 @@ export type TrackerIssueFailure = {
20
17
  kind: "creds" | "request" | "input";
21
18
  };
22
19
 
23
- export type TrackerRequest = (
24
- url: string,
25
- init: { method: string; headers: Record<string, string> },
26
- ) => Promise<{ status: number; stdout: string; stderr: string }>;
27
-
28
- export const trackerRequest: TrackerRequest = async (url, init) => {
29
- try {
30
- const res = await fetch(url, { method: init.method, headers: init.headers });
31
- const text = await res.text();
32
- if (!res.ok) return { status: res.status, stdout: "", stderr: text.slice(0, 200) };
33
- return { status: 0, stdout: text, stderr: "" };
34
- } catch (err) {
35
- return { status: 1, stdout: "", stderr: err instanceof Error ? err.message : "network error" };
36
- }
37
- };
38
-
39
20
  /** Full repo path from an origin URL (scp-style, https, or bare); subgroups kept. */
40
21
  export const repoPathFromRemote = (remote: string): string | null => {
41
22
  let rest = (remote || "").trim().replace(/\/+$/, "");
@@ -57,17 +38,20 @@ export type TrackerCli = (
57
38
  ) => Promise<{ status: number; stdout: string; stderr: string }> | null;
58
39
 
59
40
  export type TrackerDeps = {
60
- creds?: (root: string) => TrackerCreds;
61
- request?: TrackerRequest;
62
41
  remote?: (root: string) => string | null;
63
42
  cli?: TrackerCli;
43
+ host?: string;
64
44
  };
65
45
 
66
46
  /** Native CLI runner (gh/glab) when installed; null when absent. */
67
47
  const defaultCli = (bin: string, available: boolean): TrackerCli | null => {
68
48
  if (!available) return null;
69
49
  return (args, root) => {
70
- const out = spawnSync(bin, args, { cwd: root, encoding: "utf8" });
50
+ const out = spawnSync(bin, args, {
51
+ cwd: root,
52
+ encoding: "utf8",
53
+ env: { ...process.env, PATH: process.env.PATH ?? "" },
54
+ });
71
55
  return Promise.resolve({
72
56
  status: out.status ?? 1,
73
57
  stdout: out.stdout ?? "",
@@ -78,55 +62,34 @@ const defaultCli = (bin: string, available: boolean): TrackerCli | null => {
78
62
 
79
63
  const tryCliTriple = async (
80
64
  cli: TrackerCli | null | undefined,
65
+ name: string,
81
66
  args: string[],
82
67
  root: string,
83
68
  id: string,
84
69
  idKeys: string[],
85
70
  bodyKeys: string[],
86
- ): Promise<TrackerIssueBody | null> => {
87
- if (!cli) return null;
71
+ ): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
72
+ if (!cli) return { error: `${name} CLI is unavailable`, kind: "creds" };
88
73
  const run = cli(args, root);
89
- if (!run) return null;
74
+ if (!run) return { error: `${name} CLI is unavailable`, kind: "creds" };
90
75
  const out = await run;
91
- if (out.status !== 0) return null;
76
+ if (out.status !== 0)
77
+ return { error: `${name} CLI is not authenticated or the issue is unavailable`, kind: "creds" };
92
78
  try {
93
- return parseTriple(JSON.parse(out.stdout) as Record<string, unknown>, id, idKeys, bodyKeys);
79
+ const triple = parseTriple(
80
+ JSON.parse(out.stdout) as Record<string, unknown>,
81
+ id,
82
+ idKeys,
83
+ bodyKeys,
84
+ );
85
+ return triple
86
+ ? { data: triple }
87
+ : { error: `unexpected ${name} issue response`, kind: "request" };
94
88
  } catch {
95
- return null;
89
+ return { error: `invalid ${name} issue response`, kind: "request" };
96
90
  }
97
91
  };
98
92
 
99
- const originRemote = (root: string): string | null => {
100
- const out = spawnSync("git", ["remote", "get-url", "origin"], { cwd: root, encoding: "utf8" });
101
- return out.status === 0 ? (out.stdout ?? "").trim() || null : null;
102
- };
103
-
104
- type TrackerCreds = { token: string; api: string } | { error: string };
105
-
106
- const trackerCreds = (root: string, provider: "github" | "gitlab"): TrackerCreds => {
107
- const cfg = vcsConfig("load", root) as Record<string, unknown>;
108
- if (!cfg || cfg.provider !== provider || typeof cfg.tokenPath !== "string" || !cfg.tokenPath)
109
- return { error: `unconfigured ${provider} provider` };
110
- let token = "";
111
- try {
112
- token = fs.readFileSync(cfg.tokenPath, "utf8").trim();
113
- } catch {
114
- return { error: `missing ${provider} token` };
115
- }
116
- if (!token) return { error: `empty ${provider} token file` };
117
- if (provider === "gitlab") {
118
- const gitlab = cfg.gitlab as Record<string, unknown> | undefined;
119
- const apiUrl = String(gitlab?.apiUrl ?? "https://gitlab.com/api/v4").replace(/\/+$/, "");
120
- return { token, api: apiUrl };
121
- }
122
- const github = cfg.github as Record<string, unknown> | undefined;
123
- const host = String(github?.host ?? "github.com").toLowerCase();
124
- return {
125
- token,
126
- api: host === "github.com" ? "https://api.github.com" : `https://${host}/api/v3`,
127
- };
128
- };
129
-
130
93
  const parseTriple = (
131
94
  parsed: Record<string, unknown>,
132
95
  id: string,
@@ -148,7 +111,7 @@ const parseTriple = (
148
111
  return { id: foundId, title, body: pick(bodyKeys), state };
149
112
  };
150
113
 
151
- /** Fetch a GitHub issue triple for context.read: gh CLI first, token fallback (fail-closed). */
114
+ /** Fetch a GitHub issue triple for context.read through the authenticated CLI. */
152
115
  export const fetchGitHubIssueBody = async (
153
116
  ref: string,
154
117
  root: string,
@@ -156,46 +119,30 @@ export const fetchGitHubIssueBody = async (
156
119
  ): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
157
120
  const id = parseGhIssue(ref);
158
121
  if (!/^\d+$/.test(id)) return { error: `invalid GitHub issue ref "${ref}"`, kind: "input" };
159
- const repo = repoPathFromRemote((deps.remote ?? originRemote)(root) ?? "");
122
+ const remote = (deps.remote ?? pushRemote)(root);
123
+ const repo = repoPathFromRemote(remote ?? "");
160
124
  if (!repo) return { error: "no origin remote to resolve the repository", kind: "input" };
161
- const viaCli = await tryCliTriple(
125
+ const cfg = vcsConfig("load", root);
126
+ const host = (
127
+ deps.host ??
128
+ (cfg.ok && cfg.provider === "github" ? cfg.github?.host : null) ??
129
+ "github.com"
130
+ ).toLowerCase();
131
+ if (!remote || !hostingApiHostMatches(remote, host))
132
+ return { error: "hosting API host does not match the push destination", kind: "input" };
133
+ const repoArg = host === "github.com" ? repo : `${host}/${repo}`;
134
+ return tryCliTriple(
162
135
  deps.cli ?? defaultCli("gh", hostingCliAvailable("github")),
163
- ["issue", "view", id, "--repo", repo, "--json", "number,title,body,state"],
136
+ "gh",
137
+ ["issue", "view", id, "--repo", repoArg, "--json", "number,title,body,state"],
164
138
  root,
165
139
  id,
166
140
  ["number"],
167
141
  ["body"],
168
142
  );
169
- if (viaCli) return { data: viaCli };
170
- const credentials = (deps.creds ?? ((r) => trackerCreds(r, "github")))(root);
171
- if ("error" in credentials) return { ...credentials, kind: "creds" as const };
172
- const out = await (deps.request ?? trackerRequest)(
173
- `${credentials.api}/repos/${repo}/issues/${id}`,
174
- {
175
- method: "GET",
176
- headers: {
177
- Authorization: `Bearer ${credentials.token}`,
178
- Accept: "application/vnd.github+json",
179
- },
180
- },
181
- );
182
- if (out.status !== 0)
183
- return { error: out.stderr || "GitHub issue fetch failed", kind: "request" };
184
- try {
185
- const triple = parseTriple(
186
- JSON.parse(out.stdout) as Record<string, unknown>,
187
- id,
188
- ["number"],
189
- ["body"],
190
- );
191
- if (!triple) return { error: "unexpected GitHub issue shape", kind: "request" };
192
- return { data: triple };
193
- } catch {
194
- return { error: "invalid GitHub issue response", kind: "request" };
195
- }
196
143
  };
197
144
 
198
- /** Fetch a GitLab issue triple for context.read: glab CLI first, token fallback (fail-closed). */
145
+ /** Fetch a GitLab issue triple for context.read through the authenticated CLI. */
199
146
  export const fetchGitLabIssueBody = async (
200
147
  ref: string,
201
148
  root: string,
@@ -203,35 +150,25 @@ export const fetchGitLabIssueBody = async (
203
150
  ): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
204
151
  const id = parseGhIssue(ref);
205
152
  if (!/^\d+$/.test(id)) return { error: `invalid GitLab issue ref "${ref}"`, kind: "input" };
206
- const repo = repoPathFromRemote((deps.remote ?? originRemote)(root) ?? "");
153
+ const remote = (deps.remote ?? pushRemote)(root);
154
+ const repo = repoPathFromRemote(remote ?? "");
207
155
  if (!repo) return { error: "no origin remote to resolve the project", kind: "input" };
208
- const viaCli = await tryCliTriple(
156
+ const cfg = vcsConfig("load", root);
157
+ const host = (
158
+ deps.host ??
159
+ (cfg.ok && cfg.provider === "gitlab" ? cfg.gitlab?.host : null) ??
160
+ "gitlab.com"
161
+ ).toLowerCase();
162
+ if (!remote || !hostingApiHostMatches(remote, host))
163
+ return { error: "hosting API host does not match the push destination", kind: "input" };
164
+ const repoArg = host === "gitlab.com" ? repo : `https://${host}/${repo}`;
165
+ return tryCliTriple(
209
166
  deps.cli ?? defaultCli("glab", hostingCliAvailable("gitlab")),
210
- ["issue", "view", id, "-R", repo, "-F", "json"],
167
+ "glab",
168
+ ["issue", "view", id, "-R", repoArg, "-F", "json"],
211
169
  root,
212
170
  id,
213
171
  ["iid"],
214
172
  ["description"],
215
173
  );
216
- if (viaCli) return { data: viaCli };
217
- const credentials = (deps.creds ?? ((r) => trackerCreds(r, "gitlab")))(root);
218
- if ("error" in credentials) return { ...credentials, kind: "creds" as const };
219
- const out = await (deps.request ?? trackerRequest)(
220
- `${credentials.api}/projects/${encodeURIComponent(repo)}/issues/${id}`,
221
- { method: "GET", headers: { "PRIVATE-TOKEN": credentials.token } },
222
- );
223
- if (out.status !== 0)
224
- return { error: out.stderr || "GitLab issue fetch failed", kind: "request" };
225
- try {
226
- const triple = parseTriple(
227
- JSON.parse(out.stdout) as Record<string, unknown>,
228
- id,
229
- ["iid"],
230
- ["description"],
231
- );
232
- if (!triple) return { error: "unexpected GitLab issue shape", kind: "request" };
233
- return { data: triple };
234
- } catch {
235
- return { error: "invalid GitLab issue response", kind: "request" };
236
- }
237
174
  };