sentinelayer-cli 0.33.0 → 0.34.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/README.md CHANGED
@@ -712,6 +712,16 @@ Architecture references: local stdio runtime and incident response live in
712
712
  [docs/mcp-hosted-connector.md](docs/mcp-hosted-connector.md); Senti room
713
713
  coordination behavior lives in [docs/sessions.md](docs/sessions.md).
714
714
 
715
+ Worked MCP examples:
716
+
717
+ ```bash
718
+ sl mcp list --json
719
+ sl mcp registry init-session --force
720
+ sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json
721
+ sl mcp server run --path .
722
+ sl mcp registry init-cli --json
723
+ ```
724
+
715
725
  ### MCP Live Entry Point Map
716
726
 
717
727
  - Local stdio server: `sl mcp server run --path .`, configured from
@@ -820,7 +830,7 @@ gh secret set SENTINELAYER_TOKEN --repo <owner/repo>
820
830
  gh secret list --repo <owner/repo>
821
831
  ```
822
832
 
823
- 3. For manual setup details: `https://sentinelayer.com/docs/getting-started/install-workflow`
833
+ 3. For manual setup details: `https://github.com/mrrCarter/create-sentinelayer#manual-fallback-if-auto-injection-is-skipped`
824
834
 
825
835
  4. BYOK mode (no Sentinelayer token):
826
836
  - keep generated `docs/spec.md`, `docs/build-guide.md`, `prompts/execution-prompt.md`, and `tasks/todo.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinelayer-cli",
3
- "version": "0.33.0",
3
+ "version": "0.34.1",
4
4
  "description": "Scaffold Sentinelayer spec/prompt/guide artifacts with secure browser auth and token bootstrap.",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -31,6 +31,8 @@ import {
31
31
  import { requestHostedMcpAccessToken } from "../mcp/token-service.js";
32
32
  import { buildSentinelayerCliRegistryTemplate } from "../mcp/cli-registry.js";
33
33
  import { runMcpStdioServer } from "../mcp/session-stdio-server.js";
34
+ import { runMcpDoctorProbes } from "../mcp/doctor.js";
35
+ import { resolveActiveAuthSession } from "../auth/service.js";
34
36
  import { resolveOutputRoot } from "../config/service.js";
35
37
 
36
38
  function shouldEmitJson(options, command) {
@@ -66,8 +68,52 @@ function formatApiError(error) {
66
68
  return `${error.message} [${error.code}] status=${error.status}${requestId}`;
67
69
  }
68
70
 
71
+ export async function resolveMcpDoctorApiBaseUrl({
72
+ explicitApiUrl = "",
73
+ cwd = process.cwd(),
74
+ env = process.env,
75
+ resolveActiveAuthSessionImpl = resolveActiveAuthSession,
76
+ } = {}) {
77
+ const directApiUrl = String(explicitApiUrl || "").trim();
78
+ if (directApiUrl) {
79
+ return directApiUrl;
80
+ }
81
+
82
+ // Probes are unauthenticated, but the API base URL can live in the CLI auth
83
+ // config. Read it without token rotation so doctor remains side-effect-free.
84
+ try {
85
+ const session = await resolveActiveAuthSessionImpl({
86
+ cwd,
87
+ env,
88
+ autoRotate: false,
89
+ });
90
+ return String((session && session.apiUrl) || "").trim();
91
+ } catch {
92
+ return "";
93
+ }
94
+ }
95
+
69
96
  export function registerMcpCommand(program) {
70
- const mcp = program.command("mcp").description("Manage Sentinelayer MCP registry schemas and adapters");
97
+ const mcp = program
98
+ .command("mcp")
99
+ .description("Manage local MCP registries, stdio servers, and guarded CLI bridge metadata")
100
+ .addHelpText(
101
+ "after",
102
+ `
103
+ Examples:
104
+ sl mcp list --json
105
+ sl mcp registry init-session --force
106
+ sl mcp server init --id sentinelayer-session --registry-file .sentinelayer/mcp/tool-registry.session-tools.json
107
+ sl mcp server run --path .
108
+ sl mcp registry init-cli --json
109
+
110
+ Notes:
111
+ Local stdio MCP works for clients that can spawn the CLI process.
112
+ Hosted Claude/ChatGPT connectors require the separate HTTPS/OAuth hosted connector contract.
113
+ Generated CLI bridge tools are metadata only until a bridge host enforces auth, approval, and runtime policy.
114
+
115
+ Docs: https://github.com/mrrCarter/create-sentinelayer/blob/main/docs/mcp.md`,
116
+ );
71
117
 
72
118
  mcp
73
119
  .command("list")
@@ -184,6 +230,65 @@ export function registerMcpCommand(program) {
184
230
  console.log(pc.gray("Secret value hidden in text output; use --json when you need the bearer value."));
185
231
  });
186
232
 
233
+ mcp
234
+ .command("doctor")
235
+ .description(
236
+ "Diagnose hosted MCP auth: probe the OAuth discovery chain (PRM, AS metadata, JWKS) and verify unauthenticated /mcp is rejected"
237
+ )
238
+ .option(
239
+ "--api-url <url>",
240
+ "Sentinelayer API base URL to probe (default: resolved from your CLI auth session)"
241
+ )
242
+ .option("--timeout-ms <ms>", "Per-probe request timeout in milliseconds", String(DEFAULT_REQUEST_TIMEOUT_MS))
243
+ .option("--json", "Emit machine-readable output")
244
+ .action(async (options, command) => {
245
+ const emitJson = shouldEmitJson(options, command);
246
+ const parsedTimeout = Number(options.timeoutMs);
247
+ const timeoutMs =
248
+ Number.isFinite(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : DEFAULT_REQUEST_TIMEOUT_MS;
249
+
250
+ const apiBaseUrl = await resolveMcpDoctorApiBaseUrl({
251
+ explicitApiUrl: options.apiUrl,
252
+ cwd: process.cwd(),
253
+ env: process.env,
254
+ });
255
+ if (!apiBaseUrl) {
256
+ throw new Error(
257
+ "Could not resolve the Sentinelayer API URL. Pass --api-url <url>, or run `sl auth login` first."
258
+ );
259
+ }
260
+
261
+ const result = await runMcpDoctorProbes({ apiBaseUrl, timeoutMs });
262
+
263
+ if (emitJson) {
264
+ console.log(JSON.stringify({ command: "mcp doctor", ...result }, null, 2));
265
+ } else {
266
+ console.log(pc.bold(`MCP auth doctor — ${result.apiBaseUrl}`));
267
+ for (const probe of result.probes) {
268
+ const mark =
269
+ probe.verdict === "PASS"
270
+ ? pc.green("PASS")
271
+ : probe.verdict === "WARN"
272
+ ? pc.yellow("WARN")
273
+ : pc.red("FAIL");
274
+ console.log(` [${mark}] ${probe.label} (HTTP ${probe.status})`);
275
+ console.log(pc.gray(` ${probe.url}`));
276
+ if (probe.detail) {
277
+ console.log(pc.gray(` ${probe.detail}`));
278
+ }
279
+ }
280
+ console.log(
281
+ result.ok
282
+ ? pc.green("All critical MCP auth probes passed.")
283
+ : pc.red("One or more MCP auth probes FAILED — remote agents may not authenticate correctly.")
284
+ );
285
+ }
286
+
287
+ if (!result.ok) {
288
+ process.exitCode = 1;
289
+ }
290
+ });
291
+
187
292
  const schema = mcp.command("schema").description("Inspect or materialize MCP tool-registry schema");
188
293
  schema
189
294
  .command("show")
@@ -371,7 +476,7 @@ export function registerMcpCommand(program) {
371
476
 
372
477
  registry
373
478
  .command("init-cli")
374
- .description("Write a generated MCP registry for every SentinelLayer CLI leaf command")
479
+ .description("Write guarded sl.* CLI bridge metadata for bridge-capable MCP hosts")
375
480
  .option("--path <path>", "Destination file path override")
376
481
  .option("--output-dir <path>", "Optional artifact output root override")
377
482
  .option("--force", "Overwrite destination file if it already exists")
@@ -399,7 +504,8 @@ export function registerMcpCommand(program) {
399
504
  }
400
505
  console.log(pc.green(`Wrote SentinelLayer CLI MCP registry template: ${writtenPath}`));
401
506
  console.log(pc.gray(`${template.tools.length} CLI leaf commands exposed as bridge tools.`));
402
- console.log(pc.gray("Execution requires a bridge-capable MCP host; local stdio run still exposes session tools."));
507
+ console.log(pc.gray("Execution requires a bridge-capable MCP host with auth, approval, and runtime policy."));
508
+ console.log(pc.gray("Sensitive token, export, and identity-mutation commands are runtime-blocked."));
403
509
  });
404
510
 
405
511
  registry
@@ -755,6 +755,14 @@ function formatHumanAskLine(event = {}) {
755
755
  return `${sequenceLabel} ${humanAskAgentId(event)}${ts ? ` ${ts}` : ""}${suffix}`;
756
756
  }
757
757
 
758
+ function formatRemoteHydrateAppendWarning(result = {}) {
759
+ const count = Number(result?.appendFailureCount || 0);
760
+ if (!Number.isFinite(count) || count <= 0) return "";
761
+ const reason = normalizeString(result.appendFailureReason) || "local_append_failed";
762
+ const noun = count === 1 ? "event" : "events";
763
+ return `Remote hydrate could not persist ${count} ${noun} locally (${reason}); affected cursors were not advanced.`;
764
+ }
765
+
758
766
  function eventTimestampMs(event = {}) {
759
767
  for (const key of ["ts", "timestamp", "createdAt", "at"]) {
760
768
  const epoch = Date.parse(normalizeString(event?.[key]));
@@ -5481,6 +5489,10 @@ export function registerSessionCommand(program) {
5481
5489
  ),
5482
5490
  );
5483
5491
  }
5492
+ const appendWarning = formatRemoteHydrateAppendWarning(hydration);
5493
+ if (appendWarning) {
5494
+ console.log(pc.yellow(appendWarning));
5495
+ }
5484
5496
  } else {
5485
5497
  console.log(
5486
5498
  pc.yellow(
@@ -5847,6 +5859,8 @@ export function registerSessionCommand(program) {
5847
5859
  eventsCursor: result.eventsCursor,
5848
5860
  materializedLocalSession: result.materializedLocalSession,
5849
5861
  localAppendComplete: result.localAppendComplete,
5862
+ appendFailureCount: result.appendFailureCount,
5863
+ appendFailureReason: result.appendFailureReason,
5850
5864
  access: access || undefined,
5851
5865
  };
5852
5866
  if (shouldEmitJson(options, command)) {
@@ -5857,6 +5871,10 @@ export function registerSessionCommand(program) {
5857
5871
  console.log(
5858
5872
  `Hydrated session ${normalizedSessionId}: relayed=${result.relayed} dropped=${result.dropped}.`,
5859
5873
  );
5874
+ const appendWarning = formatRemoteHydrateAppendWarning(result);
5875
+ if (appendWarning) {
5876
+ console.log(pc.yellow(appendWarning));
5877
+ }
5860
5878
  if (access && !access.accessible) {
5861
5879
  if (access.reason === "session_not_found") {
5862
5880
  console.log(
package/src/legacy-cli.js CHANGED
@@ -292,7 +292,13 @@ function printUsage() {
292
292
  console.log(" sl auth login && npx create-sentinelayer my-app && cd my-app");
293
293
  console.log(" # Then hand docs/spec.md to your coding agent");
294
294
  console.log("");
295
- console.log("Docs: https://sentinelayer.com/docs");
295
+ console.log("Examples:");
296
+ console.log(" sl session read <id> --remote --agent <agent> --tail 20");
297
+ console.log(" sl session react <id> ack --target-sequence <seq> --agent <agent>");
298
+ console.log(" sl mcp registry init-session --force && sl mcp server run --path .");
299
+ console.log(" sl mcp registry init-cli --json");
300
+ console.log("");
301
+ console.log("Docs: https://github.com/mrrCarter/create-sentinelayer#readme");
296
302
  }
297
303
 
298
304
  function normalizeInterviewInput(
@@ -0,0 +1,247 @@
1
+ /**
2
+ * `sl mcp doctor` probe logic.
3
+ *
4
+ * Diagnoses whether the hosted Sentinelayer MCP server is correctly set up for
5
+ * remote-agent (ChatGPT / Claude) OAuth authentication. All probes are
6
+ * UNAUTHENTICATED on purpose: they read public OAuth discovery documents and
7
+ * confirm the resource server REJECTS an unauthenticated call. No bearer token
8
+ * is sent and no token is minted, so the command is side-effect-free and safe
9
+ * to run before `sl auth login`.
10
+ *
11
+ * Checks (RFCs map to the WARDEN hosted-MCP-auth build-spec §2/§4):
12
+ * 1. Protected Resource Metadata GET /.well-known/oauth-protected-resource (RFC 9728)
13
+ * 2. Authorization Server Metadata GET /.well-known/oauth-authorization-server (RFC 8414)
14
+ * 3. JSON Web Key Set GET /.well-known/jwks.json
15
+ * 4. Enforcement POST /mcp with no Authorization -> must 401 + WWW-Authenticate
16
+ */
17
+
18
+ export const DEFAULT_DOCTOR_TIMEOUT_MS = 10_000;
19
+
20
+ const PASS = "PASS";
21
+ const WARN = "WARN";
22
+ const FAIL = "FAIL";
23
+
24
+ function joinUrl(base, suffix) {
25
+ const normalizedBase = String(base || "").replace(/\/+$/, "");
26
+ const normalizedSuffix = String(suffix || "").replace(/^\/+/, "");
27
+ return `${normalizedBase}/${normalizedSuffix}`;
28
+ }
29
+
30
+ async function probeRequest(fetchImpl, url, { method = "GET", headers, body, timeoutMs } = {}) {
31
+ const controller = new AbortController();
32
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
33
+ try {
34
+ const response = await fetchImpl(url, { method, headers, body, signal: controller.signal });
35
+ const text = await response.text().catch(() => "");
36
+ let json = null;
37
+ if (text) {
38
+ try {
39
+ json = JSON.parse(text);
40
+ } catch {
41
+ json = null;
42
+ }
43
+ }
44
+ return { reached: true, status: response.status, headers: response.headers, json };
45
+ } catch (error) {
46
+ const aborted = Boolean(error) && error.name === "AbortError";
47
+ return {
48
+ reached: false,
49
+ status: 0,
50
+ detail: aborted ? `timed out after ${timeoutMs}ms` : String((error && error.message) || error),
51
+ };
52
+ } finally {
53
+ clearTimeout(timer);
54
+ }
55
+ }
56
+
57
+ function headerValue(headers, name) {
58
+ if (headers && typeof headers.get === "function") {
59
+ return String(headers.get(name) || "");
60
+ }
61
+ if (headers && typeof headers === "object") {
62
+ return String(headers[name] || headers[name.toLowerCase()] || "");
63
+ }
64
+ return "";
65
+ }
66
+
67
+ /**
68
+ * Run the hosted MCP auth discovery + enforcement probes.
69
+ *
70
+ * @param {{ apiBaseUrl: string, timeoutMs?: number, fetchImpl?: typeof fetch }} params
71
+ * @returns {Promise<{ apiBaseUrl: string, ok: boolean, probes: Array<{id:string,label:string,url:string,status:number,verdict:string,detail:string}> }>}
72
+ */
73
+ export async function runMcpDoctorProbes({
74
+ apiBaseUrl,
75
+ timeoutMs = DEFAULT_DOCTOR_TIMEOUT_MS,
76
+ fetchImpl = fetch,
77
+ } = {}) {
78
+ const base = String(apiBaseUrl || "").trim().replace(/\/+$/, "");
79
+ if (!base) {
80
+ throw new Error("apiBaseUrl is required to run MCP doctor probes.");
81
+ }
82
+
83
+ const probes = [];
84
+ // Whether PRM advertised an authorization server changes how severe an
85
+ // AS-metadata 503 is: advertised pointer + 503 is a broken chain (FAIL),
86
+ // while omitted pointer + 503 is a consistent fail-closed state (WARN).
87
+ let prmAdvertisesAuthServer = false;
88
+
89
+ // 1. Protected Resource Metadata (RFC 9728)
90
+ {
91
+ const url = joinUrl(base, "/.well-known/oauth-protected-resource");
92
+ const result = await probeRequest(fetchImpl, url, { timeoutMs });
93
+ let verdict = FAIL;
94
+ let detail;
95
+ if (!result.reached) {
96
+ detail = result.detail;
97
+ } else if (result.status === 200 && result.json && typeof result.json.resource === "string" && result.json.resource) {
98
+ verdict = PASS;
99
+ const count = Array.isArray(result.json.authorization_servers)
100
+ ? result.json.authorization_servers.length
101
+ : 0;
102
+ prmAdvertisesAuthServer = count > 0;
103
+ detail = `resource=${result.json.resource}; authorization_servers=${count}`;
104
+ } else if (result.status === 200) {
105
+ verdict = WARN;
106
+ detail = "200 but missing the required 'resource' field";
107
+ } else {
108
+ detail = `expected 200, got HTTP ${result.status}`;
109
+ }
110
+ probes.push({
111
+ id: "protected_resource_metadata",
112
+ label: "Protected Resource Metadata (RFC 9728)",
113
+ url,
114
+ status: result.status,
115
+ verdict,
116
+ detail,
117
+ });
118
+ }
119
+
120
+ // 2. Authorization Server Metadata (RFC 8414)
121
+ {
122
+ const url = joinUrl(base, "/.well-known/oauth-authorization-server");
123
+ const result = await probeRequest(fetchImpl, url, { timeoutMs });
124
+ let verdict = FAIL;
125
+ let detail;
126
+ if (!result.reached) {
127
+ detail = result.detail;
128
+ } else if (result.status === 200) {
129
+ verdict = PASS;
130
+ detail =
131
+ result.json && result.json.token_endpoint
132
+ ? `token_endpoint=${result.json.token_endpoint}`
133
+ : "advertised";
134
+ } else if (result.status === 503) {
135
+ if (prmAdvertisesAuthServer) {
136
+ // Broken discovery chain: PRM points clients at an authorization server
137
+ // that then returns 503. Clients WILL follow the pointer and fail.
138
+ verdict = FAIL;
139
+ detail =
140
+ "PRM advertises authorization_servers but AS metadata is 503 — remote clients will follow the advertised pointer to an unconfigured authorization server (broken discovery chain)";
141
+ } else {
142
+ // Consistent fail-closed: no AS advertised, AS metadata not configured.
143
+ verdict = WARN;
144
+ detail =
145
+ "503 unconfigured and PRM does not advertise an authorization server (fail-closed, consistent) — set MCP_OAUTH_AUTHORIZATION_ENDPOINT + MCP_OAUTH_TOKEN_ENDPOINT to enable remote auto-discovery";
146
+ }
147
+ } else {
148
+ detail = `expected 200, got HTTP ${result.status}`;
149
+ }
150
+ probes.push({
151
+ id: "authorization_server_metadata",
152
+ label: "Authorization Server Metadata (RFC 8414)",
153
+ url,
154
+ status: result.status,
155
+ verdict,
156
+ detail,
157
+ });
158
+ }
159
+
160
+ // 3. JSON Web Key Set
161
+ {
162
+ const url = joinUrl(base, "/.well-known/jwks.json");
163
+ const result = await probeRequest(fetchImpl, url, { timeoutMs });
164
+ let verdict = FAIL;
165
+ let detail;
166
+ if (!result.reached) {
167
+ detail = result.detail;
168
+ } else if (result.status === 200 && result.json && Array.isArray(result.json.keys) && result.json.keys.length > 0) {
169
+ const keys = result.json.keys;
170
+ // A PUBLIC JWKS must only ever publish ASYMMETRIC public keys. A symmetric
171
+ // key (kty "oct" / HS* alg) here IS the shared HMAC signing secret — anyone
172
+ // who can read it can forge MCP access tokens. Treat that as a hard failure.
173
+ const symmetricKeys = keys.filter((key) => {
174
+ const kty = String((key && key.kty) || "").toLowerCase();
175
+ const alg = String((key && key.alg) || "").toUpperCase();
176
+ return kty === "oct" || alg.startsWith("HS");
177
+ });
178
+ if (symmetricKeys.length > 0) {
179
+ verdict = FAIL;
180
+ detail = `CRITICAL: ${symmetricKeys.length} symmetric key(s) (kty=oct / HS*) published in the public JWKS — that is the HMAC signing secret and lets anyone forge MCP access tokens`;
181
+ } else {
182
+ verdict = PASS;
183
+ const algs = [
184
+ ...new Set(keys.map((key) => String((key && key.alg) || (key && key.kty) || "?"))),
185
+ ].join(", ");
186
+ detail = `${keys.length} asymmetric signing key(s) published (${algs})`;
187
+ }
188
+ } else if (result.status === 200) {
189
+ verdict = WARN;
190
+ detail = "200 but no signing keys published (expected for HS256/non-prod; production must publish an RS256 JWKS)";
191
+ } else {
192
+ detail = `expected 200, got HTTP ${result.status}`;
193
+ }
194
+ probes.push({
195
+ id: "jwks",
196
+ label: "JSON Web Key Set",
197
+ url,
198
+ status: result.status,
199
+ verdict,
200
+ detail,
201
+ });
202
+ }
203
+
204
+ // 4. Enforcement: an unauthenticated /mcp call must be rejected with a
205
+ // 401 + WWW-Authenticate challenge that points back at the PRM document.
206
+ {
207
+ const url = joinUrl(base, "/mcp");
208
+ const result = await probeRequest(fetchImpl, url, {
209
+ method: "POST",
210
+ headers: { "Content-Type": "application/json" },
211
+ body: JSON.stringify({ jsonrpc: "2.0", id: "mcp-doctor", method: "ping" }),
212
+ timeoutMs,
213
+ });
214
+ let verdict = FAIL;
215
+ let detail;
216
+ if (!result.reached) {
217
+ detail = result.detail;
218
+ } else if (result.status === 401) {
219
+ const challenge = headerValue(result.headers, "www-authenticate");
220
+ if (challenge && /resource_metadata=/i.test(challenge)) {
221
+ verdict = PASS;
222
+ detail = "401 + WWW-Authenticate challenge advertises the protected-resource metadata";
223
+ } else if (challenge) {
224
+ verdict = WARN;
225
+ detail = "401 but the WWW-Authenticate challenge omits resource_metadata (clients cannot auto-discover the AS)";
226
+ } else {
227
+ verdict = WARN;
228
+ detail = "401 but no WWW-Authenticate header (clients cannot auto-discover the AS)";
229
+ }
230
+ } else if (result.status === 200) {
231
+ detail = "CRITICAL: unauthenticated /mcp returned 200 — the resource server is NOT enforcing authentication";
232
+ } else {
233
+ detail = `expected 401, got HTTP ${result.status}`;
234
+ }
235
+ probes.push({
236
+ id: "mcp_enforcement",
237
+ label: "Unauthenticated /mcp is rejected",
238
+ url,
239
+ status: result.status,
240
+ verdict,
241
+ detail,
242
+ });
243
+ }
244
+
245
+ const ok = probes.every((probe) => probe.verdict !== FAIL);
246
+ return { apiBaseUrl: base, ok, probes };
247
+ }
@@ -498,7 +498,7 @@ export function buildSecretSetupInstructions(
498
498
  `- gh secret set ${normalizedSecret} --repo ${resolvedRepoSlug}`,
499
499
  `- Verify secret visibility: gh secret list --repo ${resolvedRepoSlug}`,
500
500
  "- Commit and push .github/workflows/omar-gate.yml",
501
- "- For manual setup: https://sentinelayer.com/docs/getting-started/install-workflow",
501
+ "- For manual setup: https://github.com/mrrCarter/create-sentinelayer#manual-fallback-if-auto-injection-is-skipped",
502
502
  ];
503
503
  }
504
504
 
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import fsp from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import process from "node:process";
@@ -72,6 +73,25 @@ function normalizePositiveInteger(value, fallbackValue) {
72
73
  return Math.max(1, Math.floor(normalized));
73
74
  }
74
75
 
76
+ function buildWorkPlanSourceContext(targetPath = process.cwd(), { sourceReason = "caller_target_path" } = {}) {
77
+ const workspacePath = path.resolve(String(targetPath || "."));
78
+ const workspaceLabel = normalizeString(path.basename(workspacePath)) || ".";
79
+ const workspaceFingerprint = createHash("sha256")
80
+ .update(workspacePath)
81
+ .digest("hex")
82
+ .slice(0, 8);
83
+ const sourceLabel = `${WORK_PLAN_RELATIVE_PATH} from ${workspaceLabel}#${workspaceFingerprint}`;
84
+ return {
85
+ workspacePath,
86
+ filePath: path.join(workspacePath, WORK_PLAN_RELATIVE_PATH),
87
+ path: WORK_PLAN_RELATIVE_PATH,
88
+ workspaceLabel,
89
+ workspaceFingerprint,
90
+ sourceLabel,
91
+ sourceReason: normalizeString(sourceReason),
92
+ };
93
+ }
94
+
75
95
  function toEpoch(value, fallbackIso = new Date().toISOString()) {
76
96
  return Date.parse(normalizeIsoTimestamp(value, fallbackIso)) || 0;
77
97
  }
@@ -407,9 +427,13 @@ function emptyTaskLedgerSummary() {
407
427
  };
408
428
  }
409
429
 
410
- function emptyWorkPlanSummary() {
430
+ function emptyWorkPlanSummary(sourceContext = {}) {
411
431
  return {
412
- path: WORK_PLAN_RELATIVE_PATH,
432
+ path: normalizeString(sourceContext.path) || WORK_PLAN_RELATIVE_PATH,
433
+ workspaceLabel: normalizeString(sourceContext.workspaceLabel),
434
+ workspaceFingerprint: normalizeString(sourceContext.workspaceFingerprint),
435
+ sourceLabel: normalizeString(sourceContext.sourceLabel),
436
+ sourceReason: normalizeString(sourceContext.sourceReason),
413
437
  exists: false,
414
438
  truncated: false,
415
439
  detailSuppressed: false,
@@ -434,8 +458,11 @@ function shortWorkPlanText(value) {
434
458
  return `${text.slice(0, 97)}...`;
435
459
  }
436
460
 
437
- function summarizeWorkPlanMarkdown(raw = "", { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false } = {}) {
438
- const summary = emptyWorkPlanSummary();
461
+ function summarizeWorkPlanMarkdown(
462
+ raw = "",
463
+ { limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT, truncated = false, sourceContext = {} } = {}
464
+ ) {
465
+ const summary = emptyWorkPlanSummary(sourceContext);
439
466
  summary.exists = true;
440
467
  summary.truncated = Boolean(truncated);
441
468
  if (summary.truncated) {
@@ -487,15 +514,19 @@ function summarizeWorkPlanMarkdown(raw = "", { limit = DEFAULT_WORK_PLAN_SUMMARY
487
514
  return summary;
488
515
  }
489
516
 
490
- async function readWorkPlanSummary({ targetPath = process.cwd(), limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT } = {}) {
491
- const filePath = path.join(path.resolve(String(targetPath || ".")), WORK_PLAN_RELATIVE_PATH);
517
+ async function readWorkPlanSummary({
518
+ targetPath = process.cwd(),
519
+ limit = DEFAULT_WORK_PLAN_SUMMARY_LIMIT,
520
+ sourceReason = "caller_target_path",
521
+ } = {}) {
522
+ const sourceContext = buildWorkPlanSourceContext(targetPath, { sourceReason });
492
523
  try {
493
- const stats = await fsp.stat(filePath);
524
+ const stats = await fsp.stat(sourceContext.filePath);
494
525
  let source = "";
495
526
  let truncated = false;
496
527
  if (stats.size > MAX_WORK_PLAN_BYTES) {
497
528
  truncated = true;
498
- const handle = await fsp.open(filePath, "r");
529
+ const handle = await fsp.open(sourceContext.filePath, "r");
499
530
  try {
500
531
  const buffer = Buffer.alloc(MAX_WORK_PLAN_BYTES);
501
532
  const position = Math.max(0, stats.size - MAX_WORK_PLAN_BYTES);
@@ -505,14 +536,18 @@ async function readWorkPlanSummary({ targetPath = process.cwd(), limit = DEFAULT
505
536
  await handle.close();
506
537
  }
507
538
  } else {
508
- source = await fsp.readFile(filePath, "utf-8");
539
+ source = await fsp.readFile(sourceContext.filePath, "utf-8");
509
540
  }
510
- return summarizeWorkPlanMarkdown(source, { limit, truncated });
541
+ return summarizeWorkPlanMarkdown(source, {
542
+ limit,
543
+ truncated,
544
+ sourceContext,
545
+ });
511
546
  } catch (error) {
512
547
  if (error && typeof error === "object" && error.code === "ENOENT") {
513
- return emptyWorkPlanSummary();
548
+ return emptyWorkPlanSummary(sourceContext);
514
549
  }
515
- return emptyWorkPlanSummary();
550
+ return emptyWorkPlanSummary(sourceContext);
516
551
  }
517
552
  }
518
553
 
@@ -733,9 +768,24 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
733
768
  }
734
769
  const open = Number(workPlan.open || 0);
735
770
  const completed = Number(workPlan.completed || 0);
736
- const pathText = normalizeString(workPlan.path) || WORK_PLAN_RELATIVE_PATH;
771
+ const sourceText =
772
+ normalizeString(workPlan.sourceLabel) ||
773
+ (() => {
774
+ const pathText = normalizeString(workPlan.path) || WORK_PLAN_RELATIVE_PATH;
775
+ const workspaceLabel = normalizeString(workPlan.workspaceLabel);
776
+ const workspaceFingerprint = normalizeString(workPlan.workspaceFingerprint);
777
+ if (!workspaceLabel) {
778
+ return pathText;
779
+ }
780
+ const workspaceText = workspaceFingerprint
781
+ ? `${workspaceLabel}#${workspaceFingerprint}`
782
+ : workspaceLabel;
783
+ return `${pathText} from ${workspaceText}`;
784
+ })();
785
+ const sourceReason = normalizeString(workPlan.sourceReason);
786
+ const sourceReasonText = sourceReason ? ` (${sourceReason})` : "";
737
787
  if (workPlan.detailSuppressed || workPlan.truncated) {
738
- return `Plan: ${open} open / ${completed} done in recent ${pathText} window. Current/next items suppressed because the plan file is large.`;
788
+ return `Plan: ${open} open / ${completed} done in recent ${sourceText} window${sourceReasonText}. Current/next items suppressed because the plan file is large.`;
739
789
  }
740
790
  const currentSection = normalizeString(workPlan.currentSection);
741
791
  const currentText = currentSection ? ` Current: ${currentSection}.` : "";
@@ -752,7 +802,7 @@ function buildWorkPlanText(workPlan = emptyWorkPlanSummary()) {
752
802
  .join("; ")}.`
753
803
  : "";
754
804
  const truncatedText = workPlan.truncated ? " Recent window only." : "";
755
- return `Plan: ${open} open / ${completed} done in ${pathText}.${currentText}${nextText}${truncatedText}`;
805
+ return `Plan: ${open} open / ${completed} done in ${sourceText}${sourceReasonText}.${currentText}${nextText}${truncatedText}`;
756
806
  }
757
807
 
758
808
  function roundCurrency(value) {
@@ -911,6 +961,12 @@ export async function buildSessionRecap(
911
961
  } catch {
912
962
  sessionMetadata = null;
913
963
  }
964
+ const sessionWorkspaceTargetPath = path.resolve(
965
+ normalizeString(sessionMetadata?.targetPath) || normalizedTargetPath,
966
+ );
967
+ const workPlanSourceReason = normalizeString(sessionMetadata?.targetPath)
968
+ ? "session_metadata_target_path"
969
+ : "caller_target_path";
914
970
  const sortedEvents = sortEventsByConversationTime(dedupeSessionEvents(allEvents), normalizedNow);
915
971
  const usageSummary = normalizeUsageSummary(sortedEvents);
916
972
  const events = sortedEvents.slice(-normalizedMaxEvents);
@@ -955,7 +1011,8 @@ export async function buildSessionRecap(
955
1011
  targetPath: normalizedTargetPath,
956
1012
  });
957
1013
  const workPlan = await readWorkPlanSummary({
958
- targetPath: normalizedTargetPath,
1014
+ targetPath: sessionWorkspaceTargetPath,
1015
+ sourceReason: workPlanSourceReason,
959
1016
  });
960
1017
  const snippets = summarizeRecentActivity(actorEvents, {
961
1018
  forAgentId: normalizedForAgentId,
@@ -38,6 +38,7 @@ import { isSessionControlEvent } from "./control-events.js";
38
38
  const EVENTS_CURSOR_SUFFIX = "events";
39
39
  const DEFAULT_EVENT_PAGE_LIMIT = 200;
40
40
  const DEFAULT_MAX_EVENT_PAGES = 25;
41
+ const MAX_APPEND_FAILURE_REASON_LENGTH = 160;
41
42
 
42
43
  function indexRelayEventIdentityKeys(indexByKey, eventList = [], event = {}, index = -1) {
43
44
  if (!indexByKey || !Array.isArray(eventList) || index < 0) return;
@@ -163,6 +164,28 @@ function normalizePositiveInteger(value, fallbackValue) {
163
164
  return Math.floor(normalized);
164
165
  }
165
166
 
167
+ function localAppendFailureReason(error) {
168
+ const message = error?.message || String(error || "");
169
+ let reason = String(message).trim();
170
+ if (!reason) return "local_append_failed";
171
+ reason = reason.replace(
172
+ /(["'])(?:[A-Za-z]:\\|\/(?:Users|home|tmp|var|private|mnt|workspace)\/).*?\1/g,
173
+ "$1[path]$1",
174
+ );
175
+ reason = reason.replace(/[A-Za-z]:\\[^,;\r\n)]+/g, "[path]");
176
+ reason = reason.replace(/(^|[\s"'(])\/(?:Users|home|tmp|var|private|mnt|workspace)\/[^\s,;)"']+/g, "$1[path]");
177
+ reason = reason.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
178
+ reason = reason.replace(
179
+ /\b([A-Za-z0-9_-]*(?:api[_-]?key|authorization|password|secret|token)[A-Za-z0-9_-]*)\s*[:=]\s*(?:"[^"]+"|'[^']+'|[^\s,;]+)/gi,
180
+ "$1=[REDACTED]",
181
+ );
182
+ reason = reason.replace(/\b[A-Za-z0-9._~+/=-]{32,}\b/g, "[REDACTED]");
183
+ if (reason.length > MAX_APPEND_FAILURE_REASON_LENGTH) {
184
+ reason = `${reason.slice(0, MAX_APPEND_FAILURE_REASON_LENGTH - 3)}...`;
185
+ }
186
+ return reason || "local_append_failed";
187
+ }
188
+
166
189
  async function pollSessionEventPages({
167
190
  sessionId,
168
191
  targetPath,
@@ -436,6 +459,8 @@ export async function hydrateSessionFromRemote({
436
459
  mergeExisting: eventsNeedingMerge.has(event),
437
460
  }));
438
461
  const failedRelayKeys = new Set();
462
+ let appendFailureCount = 0;
463
+ let appendFailureReason = "";
439
464
  for (const { event, mergeExisting } of appendEvents) {
440
465
  try {
441
466
  const persisted = await _append(sessionId, event, {
@@ -453,7 +478,11 @@ export async function hydrateSessionFromRemote({
453
478
  persistedEvent,
454
479
  existingRelayEvents.length - 1,
455
480
  );
456
- } catch {
481
+ } catch (error) {
482
+ appendFailureCount += 1;
483
+ if (!appendFailureReason) {
484
+ appendFailureReason = localAppendFailureReason(error);
485
+ }
457
486
  addSessionEventIdentityKeys(failedRelayKeys, event);
458
487
  // Append errors are observable via the stream but should not
459
488
  // abort the rest of the batch — partial relay is still progress.
@@ -492,6 +521,8 @@ export async function hydrateSessionFromRemote({
492
521
  eventsBackfillReason: eventsResult?.complete === false ? eventsResult?.reason || "" : "",
493
522
  materializedLocalSession,
494
523
  localAppendComplete: humanCursorSafe && eventsCursorSafe,
524
+ appendFailureCount,
525
+ appendFailureReason,
495
526
  remoteStatus: remoteStatus || null,
496
527
  };
497
528
  }
@@ -407,6 +407,7 @@ export function isSessionCacheExpired(metadata, nowIso = new Date().toISOString(
407
407
  function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString()) {
408
408
  return {
409
409
  sessionId: metadata.sessionId,
410
+ targetPath: metadata.targetPath,
410
411
  sessionDir: paths.sessionDir,
411
412
  metadataPath: paths.metadataPath,
412
413
  streamPath: paths.streamPath,