@korso/shepherd 0.11.2 → 0.11.4

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.
@@ -139,6 +139,15 @@ var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
139
139
  function sessionMailboxPath(dir, serverPid) {
140
140
  return join3(dir, `agent-${serverPid}.jsonl`);
141
141
  }
142
+ var HOOK_CHAIN_REACH = { codex: 8 };
143
+ var DEFAULT_HOOK_CHAIN_REACH = 3;
144
+ var MAX_HOOK_CHAIN_REACH = Math.max(
145
+ DEFAULT_HOOK_CHAIN_REACH,
146
+ ...Object.values(HOOK_CHAIN_REACH)
147
+ );
148
+ function hookChainReach(client) {
149
+ return (client === void 0 ? void 0 : HOOK_CHAIN_REACH[client]) ?? DEFAULT_HOOK_CHAIN_REACH;
150
+ }
142
151
  function normalizeCwd(cwd) {
143
152
  let normalized = resolve3(cwd);
144
153
  if (process.platform === "win32") normalized = normalized.toLowerCase();
@@ -146,7 +155,7 @@ function normalizeCwd(cwd) {
146
155
  }
147
156
  function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()) {
148
157
  try {
149
- const chain = hookChain.slice(0, 3);
158
+ const chain = hookChain.slice(0, MAX_HOOK_CHAIN_REACH);
150
159
  const wantedCwd = hookCwd === null ? null : normalizeCwd(hookCwd);
151
160
  const candidates = [];
152
161
  for (const name of readdirSync(dir)) {
@@ -180,7 +189,7 @@ function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH
180
189
  }
181
190
  if (!Array.isArray(meta.chain) || typeof meta.cwd !== "string") continue;
182
191
  const i = chain.findIndex((pid) => meta.chain.includes(pid));
183
- if (i === -1) continue;
192
+ if (i === -1 || i >= hookChainReach(meta.client)) continue;
184
193
  const j = meta.chain.indexOf(chain[i]);
185
194
  if (i >= 2 && (j > 2 || wantedCwd === null || meta.cwd !== wantedCwd))
186
195
  continue;
@@ -280,6 +289,51 @@ function mergeAnnouncements(...lists) {
280
289
  return [...byId.values()].sort((x, y) => x.id - y.id);
281
290
  }
282
291
 
292
+ // src/codexHookMigration.ts
293
+ import { z as z2 } from "zod";
294
+
295
+ // src/codexHookInstall.ts
296
+ import { parse, TomlDate } from "smol-toml";
297
+
298
+ // src/codexHookFs.ts
299
+ import { z } from "zod";
300
+ var lockSchema = z.object({
301
+ pid: z.number().int().positive(),
302
+ createdAt: z.string(),
303
+ owner: z.string().min(1).optional()
304
+ });
305
+
306
+ // src/codexHookMigration.ts
307
+ var migrationOutcomeSchema = z2.enum([
308
+ "migrated",
309
+ "already-canonical",
310
+ "user-removed",
311
+ "ambiguous",
312
+ "opted-out",
313
+ "unsupported-shape"
314
+ ]);
315
+ var recordSchema = z2.object({
316
+ status: z2.string(),
317
+ at: z2.string(),
318
+ migrationVersion: z2.number().int().nonnegative().optional(),
319
+ migrationOutcome: migrationOutcomeSchema.optional()
320
+ }).passthrough();
321
+
322
+ // src/version.ts
323
+ import { createRequire } from "node:module";
324
+ var PACKAGE_VERSION = (() => {
325
+ try {
326
+ const req = createRequire(import.meta.url);
327
+ const pkg = req("../package.json");
328
+ return pkg.version ?? "0.0.0";
329
+ } catch {
330
+ return "0.0.0";
331
+ }
332
+ })();
333
+
334
+ // src/hookInstall.ts
335
+ var HOOK_COMMAND = `npx -y --package=@korso/shepherd@${PACKAGE_VERSION} shepherd-inbox-hook`;
336
+
283
337
  // src/instructions.ts
284
338
  function sanitizeWorkspace(workspace) {
285
339
  return workspace.replace(/\s+/g, " ").slice(0, 64);
package/dist/inboxHook.js CHANGED
@@ -141,6 +141,15 @@ var MAILBOX_FRESH_MS = 15 * 60 * 1e3;
141
141
  function sessionMailboxPath(dir, serverPid) {
142
142
  return join3(dir, `agent-${serverPid}.jsonl`);
143
143
  }
144
+ var HOOK_CHAIN_REACH = { codex: 8 };
145
+ var DEFAULT_HOOK_CHAIN_REACH = 3;
146
+ var MAX_HOOK_CHAIN_REACH = Math.max(
147
+ DEFAULT_HOOK_CHAIN_REACH,
148
+ ...Object.values(HOOK_CHAIN_REACH)
149
+ );
150
+ function hookChainReach(client) {
151
+ return (client === void 0 ? void 0 : HOOK_CHAIN_REACH[client]) ?? DEFAULT_HOOK_CHAIN_REACH;
152
+ }
144
153
  function normalizeCwd(cwd) {
145
154
  let normalized = resolve3(cwd);
146
155
  if (process.platform === "win32") normalized = normalized.toLowerCase();
@@ -163,7 +172,7 @@ function hasFreshSessionMeta(dir, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()
163
172
  }
164
173
  function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH_MS, nowMs = Date.now()) {
165
174
  try {
166
- const chain = hookChain.slice(0, 3);
175
+ const chain = hookChain.slice(0, MAX_HOOK_CHAIN_REACH);
167
176
  const wantedCwd = hookCwd === null ? null : normalizeCwd(hookCwd);
168
177
  const candidates = [];
169
178
  for (const name of readdirSync(dir)) {
@@ -197,7 +206,7 @@ function selectSessionMailboxes(dir, hookChain, hookCwd, staleMs = MAILBOX_FRESH
197
206
  }
198
207
  if (!Array.isArray(meta.chain) || typeof meta.cwd !== "string") continue;
199
208
  const i = chain.findIndex((pid) => meta.chain.includes(pid));
200
- if (i === -1) continue;
209
+ if (i === -1 || i >= hookChainReach(meta.client)) continue;
201
210
  const j = meta.chain.indexOf(chain[i]);
202
211
  if (i >= 2 && (j > 2 || wantedCwd === null || meta.cwd !== wantedCwd))
203
212
  continue;
package/dist/index.js CHANGED
@@ -358,6 +358,7 @@ var ChangeReportEntry = z2.object({
358
358
  message: z2.string().max(4096).nullable(),
359
359
  paths: z2.array(z2.string().min(1).max(1024)).min(1).max(500)
360
360
  });
361
+ var CHANGE_REPORT_MAX_BYTES = 48 * 1024;
361
362
  var ChangeReport = z2.object({
362
363
  branch: z2.string().max(512),
363
364
  baseBranch: z2.string().max(512),
@@ -745,20 +746,52 @@ var EntitlementsStatusResponse = z2.object({
745
746
  reposUsed: z2.number().int()
746
747
  })
747
748
  });
749
+ var AnalyticsRange = z2.enum(["24h", "7d", "30d", "90d"]);
750
+ var AnalyticsBucket = z2.enum(["hour", "day"]);
751
+ var PeriodMetric = z2.object({
752
+ current: z2.number().int().nonnegative(),
753
+ previous: z2.number().int().nonnegative(),
754
+ changePct: z2.number().nullable()
755
+ });
756
+ var DurationPercentiles = z2.object({
757
+ p50: z2.number().nonnegative().nullable(),
758
+ p95: z2.number().nonnegative().nullable()
759
+ });
748
760
  var TrendPoint = z2.object({
749
- // `YYYY-MM-DD` (UTC day).
750
761
  date: z2.string(),
751
762
  count: z2.number()
752
763
  });
764
+ var TrendSeries = z2.object({
765
+ current: z2.array(TrendPoint),
766
+ previous: z2.array(TrendPoint)
767
+ });
753
768
  var TopWorkspace = z2.object({
754
769
  name: z2.string(),
755
770
  slug: z2.string(),
756
- members: z2.number(),
757
- agents: z2.number(),
758
- liveSessions: z2.number()
771
+ members: z2.number().int().nonnegative(),
772
+ agents: z2.number().int().nonnegative(),
773
+ liveSessions: z2.number().int().nonnegative(),
774
+ // Distinct agents with any session activity inside the window.
775
+ activeAgents: z2.number().int().nonnegative(),
776
+ sessions: z2.number().int().nonnegative(),
777
+ commits: z2.number().int().nonnegative(),
778
+ claimsReleased: z2.number().int().nonnegative(),
779
+ // Median released-claim duration (created_at -> released_at), seconds.
780
+ medianClaimSeconds: z2.number().nonnegative().nullable(),
781
+ // ISO timestamp of the most recent observed activity, or null if none.
782
+ lastActivityAt: IsoTimestamp.nullable()
759
783
  });
760
784
  var ShepherdAnalyticsResponse = z2.object({
761
785
  generatedAt: IsoTimestamp,
786
+ // Echo of the (validated) requested window plus the bucket granularity and
787
+ // the exact half-open window [windowStart, windowEnd) the hub computed
788
+ // against — clients label charts from these instead of re-deriving time math.
789
+ range: AnalyticsRange,
790
+ bucket: AnalyticsBucket,
791
+ windowStart: IsoTimestamp,
792
+ windowEnd: IsoTimestamp,
793
+ // Current-state totals: whole-platform counts as of `generatedAt`,
794
+ // independent of the requested range.
762
795
  totals: z2.object({
763
796
  accounts: z2.number(),
764
797
  workspaces: z2.number(),
@@ -778,12 +811,30 @@ var ShepherdAnalyticsResponse = z2.object({
778
811
  avgMembersPerWorkspace: z2.number(),
779
812
  largestWorkspace: z2.number()
780
813
  }),
814
+ // Range-scoped KPIs, each with its aligned previous-period comparison.
815
+ period: z2.object({
816
+ activeWorkspaces: PeriodMetric,
817
+ newAccounts: PeriodMetric,
818
+ newSessions: PeriodMetric,
819
+ commits: PeriodMetric,
820
+ claimsReleased: PeriodMetric
821
+ }),
822
+ // Observed timing diagnostics over the current window: session span is
823
+ // created_at -> last_heartbeat_at; claim duration is created_at ->
824
+ // released_at (released claims only).
825
+ timing: z2.object({
826
+ sessionSpanSeconds: DurationPercentiles,
827
+ claimDurationSeconds: DurationPercentiles
828
+ }),
781
829
  feedbackByType: z2.array(z2.object({ type: z2.string(), count: z2.number() })),
830
+ // Bucketed activity series (hourly for 24h, daily otherwise), each carrying
831
+ // its aligned previous-period twin for chart overlays.
782
832
  trends: z2.object({
783
- newAccounts: z2.array(TrendPoint),
784
- newWorkspaces: z2.array(TrendPoint),
785
- newSessions: z2.array(TrendPoint),
786
- commits: z2.array(TrendPoint)
833
+ newAccounts: TrendSeries,
834
+ newWorkspaces: TrendSeries,
835
+ newSessions: TrendSeries,
836
+ commits: TrendSeries,
837
+ claimsReleased: TrendSeries
787
838
  }),
788
839
  topWorkspaces: z2.array(TopWorkspace)
789
840
  });
@@ -1167,13 +1218,30 @@ async function buildChangeReport(cwd, config) {
1167
1218
  });
1168
1219
  }
1169
1220
  }
1170
- return {
1221
+ return fitToBudget({
1171
1222
  branch: branch ?? "HEAD",
1172
1223
  baseBranch: base ?? UNRESOLVED_BASE,
1173
1224
  head: head ?? "",
1174
1225
  truncated,
1175
1226
  entries
1176
- };
1227
+ });
1228
+ }
1229
+ function serializedBytes(report) {
1230
+ return Buffer.byteLength(JSON.stringify(report));
1231
+ }
1232
+ function fitToBudget(report) {
1233
+ if (serializedBytes(report) <= CHANGE_REPORT_MAX_BYTES) return report;
1234
+ report.truncated = true;
1235
+ while (report.entries.length > 0 && report.entries[report.entries.length - 1].kind === "committed" && serializedBytes(report) > CHANGE_REPORT_MAX_BYTES) {
1236
+ report.entries.pop();
1237
+ }
1238
+ const dirty = report.entries[0];
1239
+ if (dirty?.kind === "uncommitted") {
1240
+ while (dirty.paths.length > 1 && serializedBytes(report) > CHANGE_REPORT_MAX_BYTES) {
1241
+ dirty.paths.length = Math.ceil(dirty.paths.length / 2);
1242
+ }
1243
+ }
1244
+ return report;
1177
1245
  }
1178
1246
 
1179
1247
  // src/inbox.ts
@@ -1208,6 +1276,12 @@ function sessionMailboxPath(dir, serverPid) {
1208
1276
  function sessionMetaPath(dir, serverPid) {
1209
1277
  return join3(dir, `agent-${serverPid}.json`);
1210
1278
  }
1279
+ var HOOK_CHAIN_REACH = { codex: 8 };
1280
+ var DEFAULT_HOOK_CHAIN_REACH = 3;
1281
+ var MAX_HOOK_CHAIN_REACH = Math.max(
1282
+ DEFAULT_HOOK_CHAIN_REACH,
1283
+ ...Object.values(HOOK_CHAIN_REACH)
1284
+ );
1211
1285
  function normalizeCwd(cwd) {
1212
1286
  let normalized = resolve3(cwd);
1213
1287
  if (process.platform === "win32") normalized = normalized.toLowerCase();
@@ -1220,7 +1294,12 @@ function writeMailboxMeta(dir, serverPid, meta) {
1220
1294
  const tmp = `${dest}.tmp`;
1221
1295
  writeFileSync3(
1222
1296
  tmp,
1223
- JSON.stringify({ v: 1, cwd: normalizeCwd(meta.cwd), chain: meta.chain })
1297
+ JSON.stringify({
1298
+ v: 1,
1299
+ cwd: normalizeCwd(meta.cwd),
1300
+ chain: meta.chain,
1301
+ ...meta.client === void 0 ? {} : { client: meta.client }
1302
+ })
1224
1303
  );
1225
1304
  renameSync(tmp, dest);
1226
1305
  } catch {
@@ -2403,131 +2482,6 @@ function createHeartbeat({
2403
2482
  return { start, stop };
2404
2483
  }
2405
2484
 
2406
- // src/instructions.ts
2407
- function sanitizeWorkspace(workspace) {
2408
- return workspace.replace(/\s+/g, " ").slice(0, 64);
2409
- }
2410
- function buildInstructions(state, workspace) {
2411
- switch (state) {
2412
- case "linked":
2413
- return `${INTRO} This repository is linked to the \`${workspace ? sanitizeWorkspace(workspace) : "team"}\` workspace, so coordination is active.
2414
-
2415
- ${PROCEDURE}`;
2416
- case "declined":
2417
- return "Shepherd (team coordination) is connected, but the user declined coordination for this repository. Do not call Shepherd tools or bring up coordination here. If the user asks to start coordinating this repo, call `link`.";
2418
- case "unanswered":
2419
- return `${INTRO}
2420
-
2421
- ${FIRST_RUN_ASK}`;
2422
- }
2423
- }
2424
- var INTRO = "You are connected to Shepherd, the shared coordination hub for a team of agents (human and AI) working in the same repositories.";
2425
- var FIRST_RUN_ASK = `This repository isn't linked to a Shepherd workspace yet, so coordination is dormant. Shepherd normally asks the user directly (a popup) when file edits are detected \u2014 you don't need to raise it yourself.
2426
-
2427
- If the user asks you to set up coordination \u2014 or you're about to change files and no popup or Shepherd message has settled the question \u2014 ask at most once: call \`link\` with no argument. It auto-links when the user belongs to exactly one workspace, or lists the choices; ask the user which workspace, then call \`link\` again with their answer. If they say no, call \`decline\` so they're never asked again. Once linked, the tool results will guide the coordination procedure.`;
2428
- var PROCEDURE = `Follow this procedure on every session, proactively and without being asked:
2429
-
2430
- 1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
2431
-
2432
- 2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
2433
-
2434
- 3. Re-call \`work\` only when you move to a DIFFERENT area not covered by a live claim. (\`work\` and \`sync\` also renew your existing claims.)
2435
-
2436
- 4. Call \`announce\` whenever you discover something another agent needs \u2014 a shared decision, a gotcha, an API change, a finding. If the landscape shows a specific agent working in the affected area, direct it to them by passing their name as \`target\`; otherwise broadcast. A human teammate's name (or \`admin\`) as \`target\` reaches them on the dashboard \u2014 reply to a human's message that way, directed to its sender, never in your own chat. Awareness only, not task assignment.
2437
-
2438
- 5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
2439
-
2440
- Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or thinking that produces no file. The moment you're going to WRITE something, source or doc, claim it first. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
2441
-
2442
- Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
2443
-
2444
- // src/processTree.ts
2445
- import { execFile as execFile2 } from "node:child_process";
2446
- import { promisify } from "node:util";
2447
- var execFileAsync = promisify(execFile2);
2448
- function pidChainFromMap(startPid, parentOf, maxDepth = 32) {
2449
- const chain = [];
2450
- const seen = /* @__PURE__ */ new Set();
2451
- let pid = startPid;
2452
- while (chain.length < maxDepth && pid > 0 && !seen.has(pid)) {
2453
- chain.push(pid);
2454
- seen.add(pid);
2455
- const parent = parentOf.get(pid);
2456
- if (parent === void 0) break;
2457
- pid = parent;
2458
- }
2459
- return chain;
2460
- }
2461
- function quickChain() {
2462
- return [process.pid, process.ppid];
2463
- }
2464
- function parseWmicProcessList(text) {
2465
- const map = /* @__PURE__ */ new Map();
2466
- const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
2467
- if (lines.length === 0) return map;
2468
- const header = lines[0].trimStart();
2469
- let pidFirst;
2470
- if (header.startsWith("ParentProcessId")) pidFirst = false;
2471
- else if (header.startsWith("ProcessId")) pidFirst = true;
2472
- else return map;
2473
- for (const line of lines.slice(1)) {
2474
- const nums = line.trim().split(/\s+/).map(Number);
2475
- if (nums.length !== 2 || nums.some((n) => !Number.isInteger(n))) continue;
2476
- const [a, b] = nums;
2477
- const [pid, ppid] = pidFirst ? [a, b] : [b, a];
2478
- map.set(pid, ppid);
2479
- }
2480
- return map;
2481
- }
2482
- function parsePidPpidLines(text) {
2483
- const map = /* @__PURE__ */ new Map();
2484
- for (const line of text.split(/\r?\n/)) {
2485
- const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
2486
- if (m) map.set(Number(m[1]), Number(m[2]));
2487
- }
2488
- return map;
2489
- }
2490
- async function snapshotParentMap() {
2491
- if (process.platform === "win32") {
2492
- try {
2493
- const { stdout: stdout3 } = await execFileAsync(
2494
- "wmic",
2495
- ["process", "get", "ProcessId,ParentProcessId"],
2496
- { windowsHide: true, timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
2497
- );
2498
- const map = parseWmicProcessList(stdout3);
2499
- if (map.size > 0) return map;
2500
- } catch {
2501
- }
2502
- const { stdout: stdout2 } = await execFileAsync(
2503
- "powershell.exe",
2504
- [
2505
- "-NoProfile",
2506
- "-NonInteractive",
2507
- "-Command",
2508
- 'Get-CimInstance -Query "SELECT ProcessId,ParentProcessId FROM Win32_Process" | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
2509
- ],
2510
- { windowsHide: true, timeout: 15e3, maxBuffer: 8 * 1024 * 1024 }
2511
- );
2512
- return parsePidPpidLines(stdout2);
2513
- }
2514
- const { stdout } = await execFileAsync(
2515
- "ps",
2516
- ["-eo", "pid=,ppid="],
2517
- { timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
2518
- );
2519
- return parsePidPpidLines(stdout);
2520
- }
2521
- async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
2522
- try {
2523
- const map = await snapshot();
2524
- const chain = pidChainFromMap(process.pid, map, maxDepth);
2525
- return chain.length >= 2 ? chain : quickChain();
2526
- } catch {
2527
- return quickChain();
2528
- }
2529
- }
2530
-
2531
2485
  // src/hookInstall.ts
2532
2486
  import {
2533
2487
  readFileSync as readFileSync8,
@@ -2633,11 +2587,25 @@ function planCodexConfig(source, command) {
2633
2587
  const candidate = installCandidate(source, config, command);
2634
2588
  return candidate === null ? { kind: "skip", outcome: "unsupported-shape" } : { kind: "install", candidate };
2635
2589
  }
2636
- function appendMissingCodexHandlers(source, command) {
2590
+ function hasCanonicalHandler(source, event, hookMarker) {
2591
+ const headers = new RegExp("^\\[\\[hooks\\." + event + "\\]\\]$", "gm");
2592
+ const boundary = new RegExp("^\\[(?!\\[hooks\\." + event + "\\.)", "m");
2593
+ const nested = new RegExp("^\\[\\[hooks\\." + event + "\\.hooks\\]\\]$", "m");
2594
+ let header;
2595
+ while ((header = headers.exec(source)) !== null) {
2596
+ const rest = source.slice(header.index + header[0].length);
2597
+ const end = rest.search(boundary);
2598
+ const group = end === -1 ? rest : rest.slice(0, end);
2599
+ if (group.includes(hookMarker) && nested.test(group)) return true;
2600
+ }
2601
+ return false;
2602
+ }
2603
+ function appendMissingCodexHandlers(source, command, hookMarker) {
2637
2604
  const handlers = [
2638
- canonicalHandlerBlock("SessionStart", command),
2639
- canonicalHandlerBlock("PreToolUse", command, "*")
2640
- ].filter((handler) => !source.includes(handler));
2605
+ ["UserPromptSubmit", void 0],
2606
+ ["SessionStart", void 0],
2607
+ ["PreToolUse", "*"]
2608
+ ].filter(([event]) => !hasCanonicalHandler(source, event, hookMarker)).map(([event, matcher]) => canonicalHandlerBlock(event, command, matcher));
2641
2609
  const candidate = handlers.length === 0 ? source : source + (source.endsWith("\n") ? "" : "\n") + handlers.join("");
2642
2610
  return parseConfig2(candidate) === null ? null : candidate;
2643
2611
  }
@@ -2880,7 +2848,7 @@ function ensureMigrationBackup(backupFile, source) {
2880
2848
  }
2881
2849
 
2882
2850
  // src/codexHookMigration.ts
2883
- var MIGRATION_VERSION = 2;
2851
+ var MIGRATION_VERSION = 3;
2884
2852
  var migrationOutcomeSchema = z5.enum([
2885
2853
  "migrated",
2886
2854
  "already-canonical",
@@ -2900,8 +2868,12 @@ function migrationPaths(homeDir) {
2900
2868
  return {
2901
2869
  hooksDir,
2902
2870
  recordFile: join6(hooksDir, "codex.json"),
2903
- lockFile: join6(hooksDir, "codex-migration-v2.lock"),
2904
- backupFile: join6(hooksDir, "backups", "codex-config-before-v2.toml"),
2871
+ lockFile: join6(hooksDir, `codex-migration-v${MIGRATION_VERSION}.lock`),
2872
+ backupFile: join6(
2873
+ hooksDir,
2874
+ "backups",
2875
+ `codex-config-before-v${MIGRATION_VERSION}.toml`
2876
+ ),
2905
2877
  configFile: join6(homeDir, ".codex", "config.toml")
2906
2878
  };
2907
2879
  }
@@ -2981,8 +2953,8 @@ function exactOwnedLegacyBlock(source, hooksDir) {
2981
2953
  return exact.length === 1 ? exact[0] : void 0;
2982
2954
  }
2983
2955
  function migrateLegacy(context, state, sourceBytes, source) {
2984
- const { paths, command, log } = context;
2985
- const candidate = appendMissingCodexHandlers(source, command);
2956
+ const { paths, command, hookMarker, log } = context;
2957
+ const candidate = appendMissingCodexHandlers(source, command, hookMarker);
2986
2958
  if (candidate === null) {
2987
2959
  advanceRecord(paths.recordFile, state, "skipped", "unsupported-shape");
2988
2960
  return "skipped";
@@ -3288,6 +3260,158 @@ function installPi(homeDir, extensionSource, log) {
3288
3260
  return "installed";
3289
3261
  }
3290
3262
 
3263
+ // src/instructions.ts
3264
+ function sanitizeWorkspace(workspace) {
3265
+ return workspace.replace(/\s+/g, " ").slice(0, 64);
3266
+ }
3267
+ function buildInstructions(state, workspace) {
3268
+ switch (state) {
3269
+ case "linked":
3270
+ return `${INTRO} This repository is linked to the \`${workspace ? sanitizeWorkspace(workspace) : "team"}\` workspace, so coordination is active.
3271
+
3272
+ ${PROCEDURE}`;
3273
+ case "declined":
3274
+ return "Shepherd (team coordination) is connected, but the user declined coordination for this repository. Do not call Shepherd tools or bring up coordination here. If the user asks to start coordinating this repo, call `link`.";
3275
+ case "unanswered":
3276
+ return `${INTRO}
3277
+
3278
+ ${FIRST_RUN_ASK}`;
3279
+ }
3280
+ }
3281
+ var INTRO = "You are connected to Shepherd, the shared coordination hub for a team of agents (human and AI) working in the same repositories.";
3282
+ var FIRST_RUN_ASK = `This repository isn't linked to a Shepherd workspace yet, so coordination is dormant. Shepherd normally asks the user directly (a popup) when file edits are detected \u2014 you don't need to raise it yourself.
3283
+
3284
+ If the user asks you to set up coordination \u2014 or you're about to change files and no popup or Shepherd message has settled the question \u2014 ask at most once: call \`link\` with no argument. It auto-links when the user belongs to exactly one workspace, or lists the choices; ask the user which workspace, then call \`link\` again with their answer. If they say no, call \`decline\` so they're never asked again. Once linked, the tool results will guide the coordination procedure.`;
3285
+ var PROCEDURE = `Follow this procedure on every session, proactively and without being asked:
3286
+
3287
+ 1. Before you start producing or changing files in an AREA of the codebase, call \`work\` ONCE. This includes authoring a plan or design doc: claim the doc's path (e.g. ["docs/plans/auth.md"], or the directory you'll write into) BEFORE you write it \u2014 a plan you're about to author counts as a unit of work, not exploration. Pass a one-line \`intent\` and the \`pathGlobs\` covering the files you expect to touch. Scope the globs as specifically as you reasonably can \u2014 tight enough to avoid colliding with unrelated work, broad enough to cover the task (e.g. ["src/auth/**"], not ["src/**"] and not a single file). Hold that one claim across all your edits in that area; do NOT re-claim per file. If it reports a conflict, coordinate or pick different work \u2014 never silently collide.
3288
+
3289
+ 2. Call \`done\` when that unit of work is complete, using its \`workItemId\`, so teammates see the files freed.
3290
+
3291
+ 3. Re-call \`work\` only when you move to a DIFFERENT area not covered by a live claim. (\`work\` and \`sync\` also renew your existing claims.)
3292
+
3293
+ 4. Call \`announce\` whenever you discover something another agent needs \u2014 a shared decision, a gotcha, an API change, a finding. If the landscape shows a specific agent working in the affected area, direct it to them by passing their name as \`target\`; otherwise broadcast. A human teammate's name (or \`admin\`) as \`target\` reaches them on the dashboard \u2014 reply to a human's message that way, directed to its sender, never in your own chat. Awareness only, not task assignment.
3294
+
3295
+ 5. Call \`sync\` when you resume, start a new task, or before large changes, to refresh who is doing what.
3296
+
3297
+ Skip \`work\` entirely for read-only exploration \u2014 reading, searching, or thinking that produces no file. The moment you're going to WRITE something, source or doc, claim it first. These tools are advisory and degrade gracefully if the hub is unreachable \u2014 never block your real work on them.
3298
+
3299
+ Commit work-in-progress as you go rather than sitting on a large dirty tree: committed work becomes a precise, presence-independent signal to teammates (with line-level detail and automatic resolution once it lands), whereas uncommitted edits are only a best-effort, decaying hint.`;
3300
+ function clientInjectsInstructions(client) {
3301
+ return client === "claude";
3302
+ }
3303
+ function stageCoordinationBriefing({
3304
+ clientName,
3305
+ linkState,
3306
+ workspace,
3307
+ append
3308
+ }) {
3309
+ if (linkState !== "linked") return false;
3310
+ if (clientInjectsInstructions(detectClient(clientName))) return false;
3311
+ append([coordinationBriefing(workspace)]);
3312
+ return true;
3313
+ }
3314
+ function coordinationBriefing(workspace) {
3315
+ const safeWorkspace = sanitizeWorkspace(workspace ?? "team");
3316
+ return {
3317
+ // Negative, timestamp-derived: the mailbox dedupes by id and the hub's ids
3318
+ // are positive, so a locally-minted id can never collide with a real one.
3319
+ id: -Date.now(),
3320
+ fromAgentName: "shepherd",
3321
+ fromHuman: "shepherd",
3322
+ targetAgentName: null,
3323
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3324
+ body: `Shepherd coordination is ACTIVE for this repository (workspace \`${safeWorkspace}\`), and your client does not surface Shepherd's standing instructions \u2014 so they arrive here. Procedure from now on, proactively and without being asked: call \`work\` (a one-line intent plus the \`pathGlobs\` you expect to touch) BEFORE you start changing files in an area \u2014 a plan or design doc you are about to author counts \u2014 and hold that ONE claim across every edit in that area; call \`done\` with its \`workItemId\` when the unit of work is complete; call \`announce\` whenever you find something teammates need; call \`sync\` when you resume or switch tasks. Skip \`work\` for read-only exploration that produces no file. If \`work\` reports a conflict, coordinate or pick different work \u2014 never silently collide. These tools are advisory: never block real work on them.`
3325
+ };
3326
+ }
3327
+
3328
+ // src/processTree.ts
3329
+ import { execFile as execFile2 } from "node:child_process";
3330
+ import { promisify } from "node:util";
3331
+ var execFileAsync = promisify(execFile2);
3332
+ function pidChainFromMap(startPid, parentOf, maxDepth = 32) {
3333
+ const chain = [];
3334
+ const seen = /* @__PURE__ */ new Set();
3335
+ let pid = startPid;
3336
+ while (chain.length < maxDepth && pid > 0 && !seen.has(pid)) {
3337
+ chain.push(pid);
3338
+ seen.add(pid);
3339
+ const parent = parentOf.get(pid);
3340
+ if (parent === void 0) break;
3341
+ pid = parent;
3342
+ }
3343
+ return chain;
3344
+ }
3345
+ function quickChain() {
3346
+ return [process.pid, process.ppid];
3347
+ }
3348
+ function parseWmicProcessList(text) {
3349
+ const map = /* @__PURE__ */ new Map();
3350
+ const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
3351
+ if (lines.length === 0) return map;
3352
+ const header = lines[0].trimStart();
3353
+ let pidFirst;
3354
+ if (header.startsWith("ParentProcessId")) pidFirst = false;
3355
+ else if (header.startsWith("ProcessId")) pidFirst = true;
3356
+ else return map;
3357
+ for (const line of lines.slice(1)) {
3358
+ const nums = line.trim().split(/\s+/).map(Number);
3359
+ if (nums.length !== 2 || nums.some((n) => !Number.isInteger(n))) continue;
3360
+ const [a, b] = nums;
3361
+ const [pid, ppid] = pidFirst ? [a, b] : [b, a];
3362
+ map.set(pid, ppid);
3363
+ }
3364
+ return map;
3365
+ }
3366
+ function parsePidPpidLines(text) {
3367
+ const map = /* @__PURE__ */ new Map();
3368
+ for (const line of text.split(/\r?\n/)) {
3369
+ const m = /^\s*(\d+)\s+(\d+)\s*$/.exec(line);
3370
+ if (m) map.set(Number(m[1]), Number(m[2]));
3371
+ }
3372
+ return map;
3373
+ }
3374
+ async function snapshotParentMap() {
3375
+ if (process.platform === "win32") {
3376
+ try {
3377
+ const { stdout: stdout3 } = await execFileAsync(
3378
+ "wmic",
3379
+ ["process", "get", "ProcessId,ParentProcessId"],
3380
+ { windowsHide: true, timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
3381
+ );
3382
+ const map = parseWmicProcessList(stdout3);
3383
+ if (map.size > 0) return map;
3384
+ } catch {
3385
+ }
3386
+ const { stdout: stdout2 } = await execFileAsync(
3387
+ "powershell.exe",
3388
+ [
3389
+ "-NoProfile",
3390
+ "-NonInteractive",
3391
+ "-Command",
3392
+ 'Get-CimInstance -Query "SELECT ProcessId,ParentProcessId FROM Win32_Process" | ForEach-Object { "$($_.ProcessId) $($_.ParentProcessId)" }'
3393
+ ],
3394
+ { windowsHide: true, timeout: 15e3, maxBuffer: 8 * 1024 * 1024 }
3395
+ );
3396
+ return parsePidPpidLines(stdout2);
3397
+ }
3398
+ const { stdout } = await execFileAsync(
3399
+ "ps",
3400
+ ["-eo", "pid=,ppid="],
3401
+ { timeout: 1e4, maxBuffer: 8 * 1024 * 1024 }
3402
+ );
3403
+ return parsePidPpidLines(stdout);
3404
+ }
3405
+ async function ancestorChain(maxDepth = 32, snapshot = snapshotParentMap) {
3406
+ try {
3407
+ const map = await snapshot();
3408
+ const chain = pidChainFromMap(process.pid, map, maxDepth);
3409
+ return chain.length >= 2 ? chain : quickChain();
3410
+ } catch {
3411
+ return quickChain();
3412
+ }
3413
+ }
3414
+
3291
3415
  // src/index.ts
3292
3416
  async function main() {
3293
3417
  const config = loadConfig();
@@ -3300,10 +3424,12 @@ async function main() {
3300
3424
  const inboxFile = sessionMailboxPath(inboxDir, process.pid);
3301
3425
  const launchCwd = process.cwd();
3302
3426
  let serverChain = quickChain();
3427
+ let serverClient;
3303
3428
  const liveness = {
3304
3429
  refresh: () => writeMailboxMeta(inboxDir, process.pid, {
3305
3430
  cwd: launchCwd,
3306
- chain: serverChain
3431
+ chain: serverChain,
3432
+ client: serverClient
3307
3433
  }),
3308
3434
  remove: () => removeMailboxMeta(inboxDir, process.pid)
3309
3435
  };
@@ -3343,10 +3469,19 @@ async function main() {
3343
3469
  });
3344
3470
  const transport = new StdioServerTransport();
3345
3471
  server.server.oninitialized = () => {
3472
+ const clientName = server.server.getClientVersion()?.name;
3346
3473
  void autoInstallHooks({
3347
- clientName: server.server.getClientVersion()?.name,
3474
+ clientName,
3348
3475
  disabled: config.SHEPHERD_NO_AUTO_HOOKS
3349
3476
  });
3477
+ serverClient = detectClient(clientName);
3478
+ liveness.refresh();
3479
+ stageCoordinationBriefing({
3480
+ clientName,
3481
+ linkState: context.linkState,
3482
+ workspace: context.workspace,
3483
+ append: (announcements) => appendAnnouncements(inboxFile, announcements)
3484
+ });
3350
3485
  };
3351
3486
  let shuttingDown = false;
3352
3487
  const shutdown = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@korso/shepherd",
3
- "version": "0.11.2",
3
+ "version": "0.11.4",
4
4
  "description": "Shepherd MCP server — gives any MCP-capable agent (Claude Code, Codex, etc.) advisory cross-session coordination tools (work/done/announce/sync, plus link/unlink/decline) backed by the shared Shepherd hub. Joins the workspace automatically and ships standing instructions so the agent self-coordinates.",
5
5
  "homepage": "https://github.com/Korso-AI/shepherd#readme",
6
6
  "bugs": {