@remembrance-ai/opencode-plugin 0.1.73 → 0.1.76

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.
@@ -5073,6 +5073,18 @@ function pluginLifecycleCheck(pluginHealth, issues) {
5073
5073
  "The native plugin startup and prompt lifecycle is active."
5074
5074
  );
5075
5075
  }
5076
+ if (pluginHealth.status === "pending") {
5077
+ const pending = Array.isArray(pluginHealth.pending_components) ? pluginHealth.pending_components.filter(
5078
+ (component) => component === "tool_observer" || component === "completion_hook"
5079
+ ) : [];
5080
+ const pendingSummary = pending.map(
5081
+ (component) => component === "tool_observer" ? "tool observation" : "completion"
5082
+ ).join(" and ");
5083
+ return info(
5084
+ "plugin_lifecycle",
5085
+ `The native plugin startup and prompt lifecycle is active; ${pendingSummary || "remaining lifecycle"} verification is pending until ${pending.length === 1 ? "that event has" : "those events have"} an opportunity to run.`
5086
+ );
5087
+ }
5076
5088
  if (stringValue(pluginHealth.surface) === "codex" && issues.includes("hook_trust_required")) {
5077
5089
  const hookTrust = record(pluginHealth.hook_trust);
5078
5090
  const allowedEvents = /* @__PURE__ */ new Set([
@@ -5099,7 +5111,7 @@ function pluginLifecycleCheck(pluginHealth, issues) {
5099
5111
  const codex = stringValue(pluginHealth.surface) === "codex";
5100
5112
  return warning(
5101
5113
  "plugin_lifecycle",
5102
- "The core native lifecycle is active; tool or completion events have not occurred yet.",
5114
+ "The core native lifecycle is active, but a tool-observer or completion event did not run after an observed opportunity.",
5103
5115
  "exercise_plugin_lifecycle",
5104
5116
  codex ? `Use one Remembrance tool, complete one turn, and rerun the doctor. If this warning remains, ${codexHookReviewAction("run_connection_doctor")}` : "Use one Remembrance tool, complete one turn, and rerun the doctor.",
5105
5117
  issues,
@@ -5180,6 +5192,9 @@ function issueCodes(value) {
5180
5192
  function pass(id, summary) {
5181
5193
  return { id, status: "pass", summary, remediation: null };
5182
5194
  }
5195
+ function info(id, summary) {
5196
+ return { id, status: "info", summary, remediation: null };
5197
+ }
5183
5198
  function notApplicable(id, summary) {
5184
5199
  return { id, status: "not_applicable", summary, remediation: null };
5185
5200
  }
@@ -5555,9 +5570,9 @@ function builtInPreferenceDefinition(key) {
5555
5570
 
5556
5571
  // ../core/src/agent-guidance.ts
5557
5572
  var REMEMBRANCE_QUERY_TOOL_DESCRIPTION = "Call before non-trivial service, API, tool, library, workflow, UI, review, test, security, or deployment work to find relevant Remembrance skills and resources. For short context-dependent follow-ups, infer the concrete task from the full conversation, preserve any plugin-supplied client_context.directive_id/runtime/trigger_reason, and query anyway. Compare each candidate's bounded why_matched evidence and applicability conditions before opening it; discard stated unlikely or corner-case mismatches. High matches should be fetched before custom work; possible and exploratory matches remain optional. Do not use for broad web search or one-off facts.";
5558
- var REMEMBRANCE_CONNECTION_STATUS_TOOL_DESCRIPTION = "Inspect the active Remembrance transport, credential source, and authenticated registry scope without exposing credentials. Call this before diagnosing authentication, checking environment variables, or making anonymous REST/browser probes. Local or bundled MCP can read REMEMBRANCE_API_KEY or ~/.config/remembrance/config.json; hosted MCP cannot read local files and authenticates from its request header. When a declared native lifecycle is degraded, local MCP may submit bounded, content-free health evidence unless REMEMBRANCE_HEALTH_REPORTING=0. Never infer the plugin's scope from REMEMBRANCE_API_KEY alone.";
5573
+ var REMEMBRANCE_CONNECTION_STATUS_TOOL_DESCRIPTION = "Inspect the active Remembrance transport, credential source, authenticated registry scope, and content-free recent contribution outcomes for the current installation or API key without exposing credentials or lesson content. Call this before diagnosing authentication, checking environment variables, or making anonymous REST/browser probes. Local or bundled MCP can read REMEMBRANCE_API_KEY or ~/.config/remembrance/config.json; hosted MCP cannot read local files and authenticates from its request header. When a declared native lifecycle is degraded, local MCP may submit bounded, content-free health evidence unless REMEMBRANCE_HEALTH_REPORTING=0. Never infer the plugin's scope from REMEMBRANCE_API_KEY alone.";
5559
5574
  var REMEMBRANCE_CONNECTION_DOCTOR_TOOL_DESCRIPTION = "Run a bounded, non-mutating Remembrance connection diagnostic. It verifies the active transport, a redacted registry destination, authenticated scope, query authorization, a real catalog read, and organization submission capability. Local MCP also checks shared-config permissions, destination consistency, native plugin lifecycle, and signing identity; hosted MCP marks local-only checks unavailable. It never submits content, creates query demand, opens a review item, or returns credentials, absolute paths, private registry URLs, prompts, or repository data.";
5560
- var REMEMBRANCE_MCP_SERVER_INSTRUCTIONS = "Remembrance is shared operational memory for agents. Before claiming that Remembrance is anonymous, unconfigured, partially active, or missing an organization key, call run_connection_doctor for the transport you will actually use. It performs a safe catalog read and returns exact bounded remediation without submitting content. Use get_connection_status only when you need the underlying transport and lifecycle fields. An unset REMEMBRANCE_API_KEY does not prove that a local plugin is anonymous because local hooks and bundled MCP may read ~/.config/remembrance/config.json. Conversely, hosted MCP cannot read that local file. Anonymous curl or browser probes describe only those requests, not plugin authentication. Never print or request the raw key. When the user explicitly names an authorized Remembrance skill or supplies a remembrance://skills/{slug} URI, resolve ambiguous names with list_skills using its normalized slug-prefix filter, then call invoke_skill with an exact returned slug. Never guess a slug. list_skills is catalog resolution, not relevance search; use query_skills when the user wants relevant candidates rather than a known selection. invoke_skill rechecks authorization and organization policy, resolves the current reviewed version, and returns the only full instruction body. Catalog listings and MCP resource reads are lightweight handles, not skill use. Do not run query_skills merely to rediscover that explicit selection, and do not submit query-fit feedback for it. After meaningful direct use, follow the returned task-outcome and post-use feedback instructions once. BEFORE non-trivial service/API/tool/library/workflow/UI/review/test/security/deployment work, call query_skills to reuse proven skills and resources instead of rediscovering them. A local repository change still qualifies when it involves a reusable workflow or lesson; skip only genuinely trivial throwaway edits and one-off facts. For short follow-ups such as 'fix these issues', 'continue', or 'try again', infer the concrete task, domain, and constraints from the full conversation and still call query_skills; do not wait for the current prompt to repeat trigger keywords. Treat match_tier as a decision aid, then inspect why_matched and applicability before opening a result. These fields show bounded matched terms/capabilities, satisfied and missed constraints, qualitative lexical/semantic evidence, declared scope, use conditions, and exclusions without exposing unstable raw ranking scores. Discard an unlikely or irrelevant corner-case result and report fit: poor; do not force its use. For a remaining high match, call get_skill or get_resource before custom work and pass the returned query_id/result_id so the surfaced-to-fetch funnel closes; possible and exploratory matches are optional. The response also includes approximate context tokens, verified uses, risk, tags, permissions, dependencies, and contraindications so you can weigh the detour safely. Honor query_skills.skill_access on every response. When policy is org_only, use only returned organization skills and never substitute bundled or live public skill references; if an organization key is configured and the query is unavailable, fail closed because the policy cannot be confirmed. AFTER query_skills, call submit_query_feedback once with one complete set of explicit good/partial/poor judgments using the returned query_id and result_id values; leave unrated results neutral, and remember that a poor query match is not the same as a globally bad skill. Reuse the same organization or anonymous auth scope as the query. AFTER you actually use a skill or resource, close the post-use loop with submit_feedback (useful true/false plus a one-line lesson and the originating query_id/result_id; it returns a ready submit_remembrance payload), then submit_remembrance if the lesson is reusable. If nothing fit and you built a new workflow, use propose_private_skill for an organization-private candidate. With an organization key, propose_skill_idea also remains organization-private. Never remove, suppress, or bypass that key to force a public submission; submit privately, then use the reviewed public-propagation flow when the organization chooses to share it. When delegating, pass the selected slug, exact version, query_id, and result_id to the subagent; it should invoke/fetch that result or run its own full-context query. The parent reports the terminal outcome unless the subagent creates its own invocation. Before finishing any reusable task, self-check for a missed query. If you catch your own mistake, the user catches one, CI/deploy fails, a security issue appears, or you fix a release/versioning miss, submit a failure_report remembrance even if no skill was used; raw MCP clients have no plugin Stop hook to remind you later. A host privacy or tenant-policy denial happens before Remembrance receives the request. Tell the user: 'Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.' Do not retry the blocked content through another transport or claim submission. Attach evidence (reproduction detail, artifact hashes, or an attestation); evidence-less public reports wait in unverified intake until corroborated. Redact secrets, private URLs, credentials, raw logs, and proprietary content; submit summaries and hashes, not raw traces.";
5575
+ var REMEMBRANCE_MCP_SERVER_INSTRUCTIONS = "Remembrance is shared operational memory for agents. Before claiming that Remembrance is anonymous, unconfigured, partially active, or missing an organization key, call run_connection_doctor for the transport you will actually use. It performs a safe catalog read and returns exact bounded remediation without submitting content. Use get_connection_status only when you need the underlying transport and lifecycle fields. An unset REMEMBRANCE_API_KEY does not prove that a local plugin is anonymous because local hooks and bundled MCP may read ~/.config/remembrance/config.json. Conversely, hosted MCP cannot read that local file. Anonymous curl or browser probes describe only those requests, not plugin authentication. Never print or request the raw key. When the user explicitly names an authorized Remembrance skill or supplies a remembrance://skills/{slug} URI, resolve ambiguous names with list_skills using its normalized slug-prefix filter, then call invoke_skill with an exact returned slug. Never guess a slug. list_skills is catalog resolution, not relevance search; use query_skills when the user wants relevant candidates rather than a known selection. invoke_skill rechecks authorization and organization policy, resolves the current reviewed version, and returns the only full instruction body. Catalog listings and MCP resource reads are lightweight handles, not skill use. Do not run query_skills merely to rediscover that explicit selection, and do not submit query-fit feedback for it. After meaningful direct use, follow the returned task-outcome and post-use feedback instructions once. BEFORE non-trivial service/API/tool/library/workflow/UI/review/test/security/deployment work, call query_skills to reuse proven skills and resources instead of rediscovering them. A local repository change still qualifies when it involves a reusable workflow or lesson; skip only genuinely trivial throwaway edits and one-off facts. For short follow-ups such as 'fix these issues', 'continue', or 'try again', infer the concrete task, domain, and constraints from the full conversation and still call query_skills; do not wait for the current prompt to repeat trigger keywords. Treat match_tier as a decision aid, then inspect why_matched and applicability before opening a result. These fields show bounded matched terms/capabilities, satisfied and missed constraints, qualitative lexical/semantic evidence, declared scope, use conditions, and exclusions without exposing unstable raw ranking scores. Discard an unlikely or irrelevant corner-case result and report fit: poor; do not force its use. For a remaining high match, call get_skill or get_resource before custom work and pass the returned query_id/result_id so the surfaced-to-fetch funnel closes; possible and exploratory matches are optional. The response also includes approximate context tokens, verified uses, risk, tags, permissions, dependencies, and contraindications so you can weigh the detour safely. Honor query_skills.skill_access on every response. When policy is org_only, use only returned organization skills and never substitute bundled or live public skill references; if an organization key is configured and the query is unavailable, fail closed because the policy cannot be confirmed. AFTER query_skills, call submit_query_feedback once with one complete set of explicit good/partial/poor judgments using the returned query_id and result_id values; leave unrated results neutral, and remember that a poor query match is not the same as a globally bad skill. Reuse the same organization or anonymous auth scope as the query. AFTER you actually use a skill or resource, close the post-use loop with submit_feedback (useful true/false plus a one-line lesson and the originating query_id/result_id; it returns a ready submit_remembrance payload), then submit_remembrance if the lesson is reusable. If nothing fit and you built a new workflow, use propose_private_skill for an organization-private candidate. With an organization key, propose_skill_idea also remains organization-private. Never remove, suppress, or bypass that key to force a public submission; submit privately, then use the reviewed public-propagation flow when the organization chooses to share it. When delegating, pass the selected slug, exact version, query_id, and result_id to the subagent; it should invoke/fetch that result or run its own full-context query. The parent reports the terminal outcome unless the subagent creates its own invocation. Route memory deliberately: host-local memory is for facts about this human, repository, or machine; Remembrance is for generalized lessons another approved organization agent would otherwise rediscover. A local-memory write does not satisfy organization-shared capture. Before finishing any reusable task, self-check for a missed query. If you catch your own mistake, the user catches one, CI/deploy fails, a security issue appears, or you fix a release/versioning miss, submit a failure_report remembrance even if no skill was used; raw MCP clients have no plugin Stop hook to remind you later. A host privacy or tenant-policy denial happens before Remembrance receives the request. Tell the user: 'Remembrance was blocked by host policy before reaching Remembrance. Nothing was sent. Querying remains available.' Do not retry the blocked content through another transport or claim submission. Attach evidence (reproduction detail, artifact hashes, or an attestation); evidence-less public reports wait in unverified intake until corroborated. Redact secrets, private URLs, credentials, raw logs, and proprietary content; submit summaries and hashes, not raw traces.";
5561
5576
 
5562
5577
  // ../core/src/redaction.ts
5563
5578
  var SECRET_PATTERN_SPECS = [
@@ -5716,6 +5731,20 @@ var PRIVATE_LESSON_TAG_IDENTITY_HINTS = /* @__PURE__ */ new Set([
5716
5731
  "tenant",
5717
5732
  "user"
5718
5733
  ]);
5734
+ var PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES = /* @__PURE__ */ new Set([
5735
+ "corp",
5736
+ "org",
5737
+ "organization"
5738
+ ]);
5739
+ var PRIVATE_LESSON_TAG_RELATIONAL_PREFIXES = /* @__PURE__ */ new Set([
5740
+ "cross",
5741
+ "inter",
5742
+ "intra",
5743
+ "multi",
5744
+ "per",
5745
+ "single",
5746
+ "sub"
5747
+ ]);
5719
5748
  var privateLessonCanonicalTagSchema = privateLessonTagSyntaxSchema.superRefine((tag, ctx) => {
5720
5749
  const inspected = inspectPrivateLessonTag(tag);
5721
5750
  if (inspected.tag !== tag || inspected.held_reasons.length > 0) {
@@ -6110,8 +6139,14 @@ function computePrivateLessonRedactorDigest() {
6110
6139
  metadata: {
6111
6140
  correlation_ids: "domain-separated-sha256-v1",
6112
6141
  tag_syntax: PRIVATE_LESSON_TAG_PATTERN.source,
6113
- tag_safety: "redaction-plus-identity-hints-v1",
6142
+ tag_safety: "redaction-plus-identity-hints-v2",
6114
6143
  tag_identity_hints: [...PRIVATE_LESSON_TAG_IDENTITY_HINTS].sort(),
6144
+ tag_identity_label_suffixes: [
6145
+ ...PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES
6146
+ ].sort(),
6147
+ tag_relational_prefixes: [
6148
+ ...PRIVATE_LESSON_TAG_RELATIONAL_PREFIXES
6149
+ ].sort(),
6115
6150
  tag_aliases: PRIVATE_LESSON_SAFE_TAG_ALIASES
6116
6151
  },
6117
6152
  rules: REPLACEMENTS.map(({ category, pattern, hold }) => ({
@@ -6135,7 +6170,10 @@ function inspectPrivateLessonTag(value) {
6135
6170
  const identityHints = new Set(
6136
6171
  aliased.split(/[-_]/u).filter((segment) => PRIVATE_LESSON_TAG_IDENTITY_HINTS.has(segment))
6137
6172
  );
6138
- if (!privateLessonTagSyntaxSchema.safeParse(aliased).success || redacted.text !== aliased || identityHints.size >= 2) {
6173
+ const segments = aliased.split(/[-_]/u);
6174
+ const isGenericOrganizationRelationship = segments.length === 2 && PRIVATE_LESSON_TAG_RELATIONAL_PREFIXES.has(segments[0] ?? "") && PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES.has(segments[1] ?? "");
6175
+ const hasLikelyIdentityLabel = segments.length >= 2 && !isGenericOrganizationRelationship && PRIVATE_LESSON_TAG_IDENTITY_LABEL_SUFFIXES.has(segments.at(-1) ?? "") && segments.slice(0, -1).some((segment) => !PRIVATE_LESSON_TAG_IDENTITY_HINTS.has(segment));
6176
+ if (!privateLessonTagSyntaxSchema.safeParse(aliased).success || redacted.text !== aliased || identityHints.size >= 2 || hasLikelyIdentityLabel) {
6139
6177
  if (held.size === 0) {
6140
6178
  counts.identifier = Math.min(999, (counts.identifier ?? 0) + 1);
6141
6179
  held.add("identifier");
@@ -6162,12 +6200,18 @@ function pseudonymizeCorrelationId(value) {
6162
6200
  return `corr_${digest.slice(0, 48)}`;
6163
6201
  }
6164
6202
  var CURRENT_PRIVATE_LESSON_REDACTOR_DIGEST = computePrivateLessonRedactorDigest();
6203
+ var PRIVATE_LESSON_LEGACY_V2_REDACTOR_DIGEST = "sha256:bca83ac50b0124a0401f806b3498bbb1ec977c02c1975d5345cb8d0b85c00b75";
6165
6204
  var PRIVATE_LESSON_SUPPORTED_REDACTOR_PROFILES = Object.freeze({
6166
6205
  [PRIVATE_LESSON_REDACTION_VERSION]: Object.freeze({
6167
6206
  [CURRENT_PRIVATE_LESSON_REDACTOR_DIGEST]: Object.freeze({
6168
6207
  version: PRIVATE_LESSON_REDACTION_VERSION,
6169
6208
  digest: CURRENT_PRIVATE_LESSON_REDACTOR_DIGEST,
6170
6209
  normalization: "unicode-nfc-lf-space-v1"
6210
+ }),
6211
+ [PRIVATE_LESSON_LEGACY_V2_REDACTOR_DIGEST]: Object.freeze({
6212
+ version: PRIVATE_LESSON_REDACTION_VERSION,
6213
+ digest: PRIVATE_LESSON_LEGACY_V2_REDACTOR_DIGEST,
6214
+ normalization: "unicode-nfc-lf-space-v1"
6171
6215
  })
6172
6216
  })
6173
6217
  });
@@ -7595,7 +7639,7 @@ var seedSkills = [
7595
7639
  summary: "Operational setup and troubleshooting workflow for Remembrance across Claude Code, Codex, OpenClaw, Cursor, Gemini, MCP, REST, skill-only installs, enterprise keys, and local agent identity.",
7596
7640
  status: "active",
7597
7641
  visibility: "public",
7598
- version: "0.1.23",
7642
+ version: "0.1.25",
7599
7643
  domains: ["agent-skills", "mcp", "resource-discovery"],
7600
7644
  tags: [
7601
7645
  "remembrance",
@@ -7646,7 +7690,7 @@ var seedSkills = [
7646
7690
  "gemini",
7647
7691
  "rest"
7648
7692
  ],
7649
- version: "0.1.23",
7693
+ version: "0.1.25",
7650
7694
  status: "active",
7651
7695
  visibility: "public",
7652
7696
  providers: ["codex", "claude", "cursor", "openclaw", "generic"],
@@ -8001,16 +8045,23 @@ This is the only host-visible network action in the flow. Approve or persist
8001
8045
  approval for this exact action, not every Remembrance write. The server derives
8002
8046
  the organization and private visibility from the authenticated key, verifies a
8003
8047
  signed organization policy plus purpose-bound attestation, and returns a signed
8004
- content-free receipt. The endpoint can never create or automatically propagate
8005
- public content.
8048
+ content-free receipt. The action always submits privately and never falls back
8049
+ to anonymous or public submission. After an accepted private outcome, a
8050
+ separate server-owned process may create a freshly redacted public candidate
8051
+ only when the organization admin enabled the **Contribution propagation**
8052
+ setting; the submitting agent neither chooses nor sees that process, and the
8053
+ derivative must pass the complete public pipeline.
8006
8054
 
8007
8055
  If the host denies the action, no candidate content was sent. The encrypted
8008
8056
  draft remains on the device in \`awaiting_authorization\`; do not retry it
8009
8057
  through REST, hosted MCP, or another transport. Timeouts, 429s, and 5xx
8010
8058
  responses retry with bounded backoff during later plugin lifecycles. A 401,
8011
8059
  403, policy change, or validation failure remains held for explicit repair.
8012
- Drafts never expire or auto-delete. Inspect, retry, or explicitly delete one
8013
- with the local private-lesson tools; deletion requires confirmation.
8060
+ Unresolved and terminal drafts never expire or auto-delete. Inspect, retry, or
8061
+ explicitly delete one with the local private-lesson tools; deletion requires
8062
+ confirmation. After a signed submission receipt is verified, the encrypted
8063
+ lesson content is removed immediately and its content-free completion marker
8064
+ is automatically deleted after 14 days.
8014
8065
 
8015
8066
  The signed organization policy pins \`private-lesson-redaction-v2\` and its exact
8016
8067
  supported redactor digest. If either is unsupported, the finalized
@@ -10841,7 +10892,7 @@ var toolDefinitions = [
10841
10892
  ),
10842
10893
  tool(
10843
10894
  "submit_private_lesson_candidate",
10844
- "Submit exactly one canonical, challenge-attested organization-private lesson candidate or one content-free held-safety telemetry event. Local plugin clients normally pass only the draft_id returned by prepare_private_lesson_candidate; raw or hosted clients pass the strict candidate-or-hold union. The action can never create or automatically promote public content. Held telemetry contains no lesson prose and can never enter verification, review, topology, or skill materialization. Exact replay is idempotent; host denial leaves the local draft untouched.",
10895
+ "Submit exactly one canonical, challenge-attested organization-private lesson candidate or one content-free held-safety telemetry event. Local plugin clients normally pass only the draft_id returned by prepare_private_lesson_candidate; raw or hosted clients pass the strict candidate-or-hold union. The action always creates only an organization-private submission and never falls back to anonymous or public submission. Held telemetry contains no lesson prose and can never enter verification, review, topology, or skill materialization. Exact replay is idempotent; host denial leaves the local draft untouched.",
10845
10896
  "/api/v1/agent/private-lessons",
10846
10897
  privateLessonSubmissionToolSchema
10847
10898
  ),
@@ -11053,8 +11104,16 @@ function isAlreadyExistsError(error) {
11053
11104
  }
11054
11105
 
11055
11106
  // src/connection-status.ts
11056
- import { existsSync as existsSync2 } from "node:fs";
11057
- import { createHash as createHash2 } from "node:crypto";
11107
+ import {
11108
+ chmodSync,
11109
+ existsSync as existsSync2,
11110
+ lstatSync as lstatSync2,
11111
+ mkdirSync,
11112
+ renameSync,
11113
+ rmSync,
11114
+ writeFileSync
11115
+ } from "node:fs";
11116
+ import { createHash as createHash2, randomBytes } from "node:crypto";
11058
11117
  import { isIP } from "node:net";
11059
11118
  import { homedir as homedir3 } from "node:os";
11060
11119
  import { join as join3 } from "node:path";
@@ -11349,6 +11408,7 @@ var PLUGIN_HEALTH_COMPONENTS = [
11349
11408
  "tool_observer",
11350
11409
  "completion_hook"
11351
11410
  ];
11411
+ var CONNECTION_DOCTOR_PROBE_COMPONENT = "connection_doctor_probe";
11352
11412
  var PLUGIN_HEALTH_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1e3;
11353
11413
  var PLUGIN_HEALTH_MAX_FUTURE_SKEW_MS = 5 * 60 * 1e3;
11354
11414
  var HOST_POLICY_OBSERVATION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
@@ -11371,6 +11431,57 @@ var nodeFileSystem2 = {
11371
11431
  mode: secureLocalFileMode,
11372
11432
  list: listSecureLocalDirectory
11373
11433
  };
11434
+ function recordConnectionDoctorLifecycleProbe(options = {}) {
11435
+ const env = options.env ?? process.env;
11436
+ const rawSurface = String(env.REMEMBRANCE_PLUGIN_HOST ?? "").trim().toLowerCase();
11437
+ if (!isPluginHostSurface(rawSurface)) return false;
11438
+ const healthDir = env.REMEMBRANCE_PLUGIN_HEALTH_DIR ? String(env.REMEMBRANCE_PLUGIN_HEALTH_DIR) : join3(
11439
+ options.homeDirectory ?? homedir3(),
11440
+ ".cache",
11441
+ "remembrance",
11442
+ "plugin-health"
11443
+ );
11444
+ const path = join3(healthDir, `${rawSurface}.json`);
11445
+ if (!existsSync2(path)) return false;
11446
+ const temporaryPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
11447
+ try {
11448
+ const parsed = JSON.parse(
11449
+ readSecureLocalText(path, {
11450
+ maxBytes: MAX_LOCAL_LIFECYCLE_MARKER_BYTES
11451
+ })
11452
+ );
11453
+ if (!isRecord3(parsed) || parsed.surface !== rawSurface) return false;
11454
+ const components = isRecord3(parsed.components) ? parsed.components : {};
11455
+ if (typeof components.session_start !== "string") return false;
11456
+ mkdirSync(healthDir, { recursive: true, mode: 448 });
11457
+ const directoryMetadata = lstatSync2(healthDir);
11458
+ if (directoryMetadata.isSymbolicLink() || !directoryMetadata.isDirectory()) {
11459
+ return false;
11460
+ }
11461
+ chmodSync(healthDir, 448);
11462
+ writeFileSync(
11463
+ temporaryPath,
11464
+ `${JSON.stringify({
11465
+ ...parsed,
11466
+ components: {
11467
+ ...components,
11468
+ [CONNECTION_DOCTOR_PROBE_COMPONENT]: (options.now ?? /* @__PURE__ */ new Date()).toISOString()
11469
+ }
11470
+ })}
11471
+ `,
11472
+ { mode: 384, flag: "wx" }
11473
+ );
11474
+ renameSync(temporaryPath, path);
11475
+ chmodSync(path, 384);
11476
+ return true;
11477
+ } catch {
11478
+ try {
11479
+ rmSync(temporaryPath, { force: true });
11480
+ } catch {
11481
+ }
11482
+ return false;
11483
+ }
11484
+ }
11374
11485
  function remembranceConfigPath(env = process.env, homeDirectory = homedir3()) {
11375
11486
  const configRoot = env.REMEMBRANCE_PLUGIN_HOST?.trim().toLowerCase() === "openclaw" ? join3(homeDirectory, ".config") : env.XDG_CONFIG_HOME ?? join3(homeDirectory, ".config");
11376
11487
  return join3(configRoot, "remembrance", "config.json");
@@ -11735,6 +11846,20 @@ function localPluginLifecycleHealth(options) {
11735
11846
  }
11736
11847
  if (Number.isFinite(latestMs)) mergedComponents[component] = latestValue;
11737
11848
  }
11849
+ let latestDoctorProbe;
11850
+ let latestDoctorProbeMs = Number.NEGATIVE_INFINITY;
11851
+ for (const entry of cohort) {
11852
+ const components2 = isRecord3(entry.marker.components) ? entry.marker.components : {};
11853
+ const value = components2[CONNECTION_DOCTOR_PROBE_COMPONENT];
11854
+ const valueMs = validLifecycleTimestamp(value, nowMs);
11855
+ if (valueMs > latestDoctorProbeMs) {
11856
+ latestDoctorProbeMs = valueMs;
11857
+ latestDoctorProbe = value;
11858
+ }
11859
+ }
11860
+ if (Number.isFinite(latestDoctorProbeMs)) {
11861
+ mergedComponents[CONNECTION_DOCTOR_PROBE_COMPONENT] = latestDoctorProbe;
11862
+ }
11738
11863
  const marker = {
11739
11864
  ...newestEntry.marker,
11740
11865
  components: mergedComponents,
@@ -11772,6 +11897,7 @@ function localPluginLifecycleHealth(options) {
11772
11897
  const coreLifecycleObserved = components.session_start.observed && components.prompt_hook.observed;
11773
11898
  const hookTrust = normalizedPluginHookTrust(marker.hook_trust);
11774
11899
  const hookTrustReviewEvents = hookTrust?.status === "review_required" ? new Set(hookTrust.review_events) : /* @__PURE__ */ new Set();
11900
+ const pendingComponents = [];
11775
11901
  if (hookTrustReviewEvents.size > 0) {
11776
11902
  issues.push({
11777
11903
  code: "hook_trust_required",
@@ -11780,17 +11906,40 @@ function localPluginLifecycleHealth(options) {
11780
11906
  ])
11781
11907
  });
11782
11908
  }
11783
- if (coreLifecycleObserved && !components.tool_observer.observed && !hookTrustReviewEvents.has("PostToolUse")) {
11784
- issues.push({
11785
- code: "tool_observer_not_observed",
11786
- action: surface === "codex" ? `Use a Remembrance MCP tool, then call get_connection_status again. If this remains missing, ${codexHookReviewAction("get_connection_status")}` : "No native tool observer has run in this session yet. Use a Remembrance MCP tool, then call get_connection_status again."
11787
- });
11909
+ if (coreLifecycleObserved && !hookTrustReviewEvents.has("PostToolUse")) {
11910
+ const toolObserverMs = validLifecycleTimestamp(
11911
+ componentRecord.tool_observer,
11912
+ nowMs
11913
+ );
11914
+ const missedProbe = Number.isFinite(latestDoctorProbeMs) && (!Number.isFinite(toolObserverMs) || toolObserverMs < latestDoctorProbeMs);
11915
+ if (missedProbe) {
11916
+ issues.push({
11917
+ code: "tool_observer_not_observed",
11918
+ action: surface === "codex" ? `A prior doctor call completed without a later tool-observer event. ${codexHookReviewAction("get_connection_status")}` : "A prior doctor call completed without a later native tool-observer event. Update or reinstall the plugin, fully restart the host, and retry."
11919
+ });
11920
+ } else if (!components.tool_observer.observed) {
11921
+ pendingComponents.push("tool_observer");
11922
+ }
11788
11923
  }
11789
- if (coreLifecycleObserved && !components.completion_hook.observed && !hookTrustReviewEvents.has("Stop")) {
11790
- issues.push({
11791
- code: "completion_hook_not_observed",
11792
- action: surface === "codex" ? `Complete one turn, then call get_connection_status again. If this remains missing, ${codexHookReviewAction("get_connection_status")}` : "No native completion hook has run in this session yet. Complete one turn, then call get_connection_status again."
11793
- });
11924
+ if (coreLifecycleObserved && !hookTrustReviewEvents.has("Stop")) {
11925
+ const promptHookMs = validLifecycleTimestamp(
11926
+ componentRecord.prompt_hook,
11927
+ nowMs
11928
+ );
11929
+ const completionHookMs = validLifecycleTimestamp(
11930
+ componentRecord.completion_hook,
11931
+ nowMs
11932
+ );
11933
+ const completedTurnOpportunity = Number.isFinite(latestDoctorProbeMs) && Number.isFinite(promptHookMs) && promptHookMs > latestDoctorProbeMs;
11934
+ const missedCompletedTurn = completedTurnOpportunity && (!Number.isFinite(completionHookMs) || completionHookMs < latestDoctorProbeMs);
11935
+ if (missedCompletedTurn) {
11936
+ issues.push({
11937
+ code: "completion_hook_not_observed",
11938
+ action: surface === "codex" ? `A new prompt arrived after a prior doctor call without an intervening completion event. ${codexHookReviewAction("get_connection_status")}` : "A new prompt arrived after a prior doctor call without an intervening native completion event. Update or reinstall the plugin, fully restart the host, and retry."
11939
+ });
11940
+ } else if (!components.completion_hook.observed) {
11941
+ pendingComponents.push("completion_hook");
11942
+ }
11794
11943
  }
11795
11944
  const lastSeenMs = validLifecycleTimestamp(marker.last_seen_at, nowMs);
11796
11945
  const lastSeenAt = Number.isFinite(lastSeenMs) ? String(marker.last_seen_at) : null;
@@ -11834,7 +11983,7 @@ function localPluginLifecycleHealth(options) {
11834
11983
  );
11835
11984
  return {
11836
11985
  expected: true,
11837
- status: issues.length === 0 ? "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
11986
+ status: issues.length === 0 ? pendingComponents.length > 0 ? "pending" : "active" : onlyAwaitingEligibleEvents ? "partial" : "degraded",
11838
11987
  surface,
11839
11988
  plugin_version: markerVersion || null,
11840
11989
  host_version: typeof marker.host_version === "string" && marker.host_version ? marker.host_version : null,
@@ -11847,6 +11996,7 @@ function localPluginLifecycleHealth(options) {
11847
11996
  },
11848
11997
  hook_trust: hookTrust,
11849
11998
  components,
11999
+ pending_components: pendingComponents,
11850
12000
  issues,
11851
12001
  explanation: "Each host process session is tracked independently. Turn-specific prompt, tool, and completion observations are combined only when they share the current SessionStart marker."
11852
12002
  };
@@ -12090,7 +12240,7 @@ async function checkClientUpdate(input) {
12090
12240
  }
12091
12241
 
12092
12242
  // src/private-lesson-outbox.ts
12093
- import { createCipheriv, createDecipheriv, randomBytes as randomBytes2 } from "node:crypto";
12243
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes3 } from "node:crypto";
12094
12244
  import { AsyncLocalStorage } from "node:async_hooks";
12095
12245
  import {
12096
12246
  chmod as chmod2,
@@ -12106,7 +12256,7 @@ import { homedir as homedir4 } from "node:os";
12106
12256
  import { dirname as dirname2, join as join4 } from "node:path";
12107
12257
 
12108
12258
  // src/private-lesson-keychain.ts
12109
- import { randomBytes } from "node:crypto";
12259
+ import { randomBytes as randomBytes2 } from "node:crypto";
12110
12260
  import { execFileSync } from "node:child_process";
12111
12261
  import { existsSync as existsSync3 } from "node:fs";
12112
12262
  var KEYCHAIN_SERVICE = "dev.remembrance.private-lessons";
@@ -12148,7 +12298,7 @@ function resolvePrivateLessonKeychainKey(env, dependencies = defaultDependencies
12148
12298
  if (!keychainEnabled(env, dependencies)) return null;
12149
12299
  const existing = readPrivateLessonKeychainKey(env, dependencies);
12150
12300
  if (existing) return existing;
12151
- const encoded = dependencies.randomKey ?? randomBytes(32).toString("base64url");
12301
+ const encoded = dependencies.randomKey ?? randomBytes2(32).toString("base64url");
12152
12302
  const generated = decodeKey(encoded);
12153
12303
  if (!generated) return null;
12154
12304
  try {
@@ -12184,9 +12334,11 @@ function decodeKey(encoded) {
12184
12334
  // src/private-lesson-outbox.ts
12185
12335
  var PRIVATE_LESSON_OUTBOX_VERSION = "private-lesson-outbox-v1";
12186
12336
  var PRIVATE_LESSON_OUTBOX_MAX_BYTES = 64 * 1024 * 1024;
12337
+ var PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS = 14 * 24 * 60 * 60 * 1e3;
12187
12338
  var PRIVATE_LESSON_OUTBOX_MIN_BYTES = 1024 * 1024;
12188
12339
  var PRIVATE_LESSON_OUTBOX_MAX_CONFIGURED_BYTES = 1024 * 1024 * 1024;
12189
12340
  var PRIVATE_LESSON_DRAFT_MAX_BYTES = 64 * 1024;
12341
+ var PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES = 8 * 1024;
12190
12342
  var PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS = 3e4;
12191
12343
  var PRIVATE_LESSON_OUTBOX_LOCK_WAIT_MS = 5e3;
12192
12344
  var privateLessonOutboxLockContext = new AsyncLocalStorage();
@@ -12254,6 +12406,9 @@ async function readPrivateLessonDraft(draftId, options = {}) {
12254
12406
  if (existsSync4(tombstonePath(outbox.directory, draftId))) {
12255
12407
  throw new Error("Private lesson draft was explicitly deleted.");
12256
12408
  }
12409
+ if (existsSync4(submittedRecordPath(outbox.directory, draftId))) {
12410
+ throw new Error("Private lesson draft was already submitted.");
12411
+ }
12257
12412
  const path = draftPath(outbox.directory, draftId);
12258
12413
  await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12259
12414
  const serialized = await readBoundedFile(
@@ -12272,10 +12427,77 @@ async function readPrivateLessonDraft(draftId, options = {}) {
12272
12427
  return reconciled;
12273
12428
  });
12274
12429
  }
12430
+ async function readPrivateLessonSubmittedRecord(draftId, options = {}) {
12431
+ assertDraftId(draftId);
12432
+ const directory = options.outboxDirectory ?? privateLessonOutboxDirectory(options.env, options.homeDirectory);
12433
+ if (!existsSync4(directory)) return null;
12434
+ assertPrivateLessonPathMetadata(await lstat(directory), "directory");
12435
+ return withPrivateLessonOutboxLock(directory, async () => {
12436
+ await reconcileSubmittedRecordsUnlocked(
12437
+ directory,
12438
+ options.now ?? /* @__PURE__ */ new Date()
12439
+ );
12440
+ if (existsSync4(tombstonePath(directory, draftId))) {
12441
+ throw new Error("Private lesson draft was explicitly deleted.");
12442
+ }
12443
+ const path = submittedRecordPath(directory, draftId);
12444
+ if (!existsSync4(path)) return null;
12445
+ await assertRegularPrivateFile(
12446
+ path,
12447
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12448
+ );
12449
+ return parseSubmittedRecord(
12450
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
12451
+ draftId
12452
+ );
12453
+ });
12454
+ }
12455
+ async function readPrivateLessonSubmissionSource(draftId, options = {}) {
12456
+ assertDraftId(draftId);
12457
+ const outbox = await readyOutbox(options);
12458
+ return withPrivateLessonOutboxLock(outbox.directory, async () => {
12459
+ await reconcileSubmittedRecordsUnlocked(
12460
+ outbox.directory,
12461
+ options.now ?? /* @__PURE__ */ new Date()
12462
+ );
12463
+ if (existsSync4(tombstonePath(outbox.directory, draftId))) {
12464
+ throw new Error("Private lesson draft was explicitly deleted.");
12465
+ }
12466
+ const completionPath = submittedRecordPath(outbox.directory, draftId);
12467
+ if (existsSync4(completionPath)) {
12468
+ await assertRegularPrivateFile(
12469
+ completionPath,
12470
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12471
+ );
12472
+ return {
12473
+ kind: "submitted",
12474
+ completion: parseSubmittedRecord(
12475
+ await readBoundedFile(
12476
+ completionPath,
12477
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12478
+ ),
12479
+ draftId
12480
+ )
12481
+ };
12482
+ }
12483
+ return {
12484
+ kind: "draft",
12485
+ draft: await readPrivateLessonDraft(draftId, {
12486
+ ...options,
12487
+ outboxDirectory: outbox.directory,
12488
+ encryption: outbox.encryption
12489
+ })
12490
+ };
12491
+ });
12492
+ }
12275
12493
  async function reconcilePrivateLessonOutboxProfiles(options = {}) {
12276
12494
  const outbox = await readyOutbox(options);
12277
12495
  return withPrivateLessonOutboxLock(outbox.directory, async () => {
12278
12496
  await reconcileExplicitDeletionTombstones(outbox.directory);
12497
+ await reconcileSubmittedRecordsUnlocked(
12498
+ outbox.directory,
12499
+ options.now ?? /* @__PURE__ */ new Date()
12500
+ );
12279
12501
  const entries = await secureOutboxEntries(outbox.directory);
12280
12502
  let inspected = 0;
12281
12503
  let terminalized = 0;
@@ -12296,10 +12518,18 @@ async function reconcilePrivateLessonOutboxProfiles(options = {}) {
12296
12518
  encryption: outbox.encryption
12297
12519
  });
12298
12520
  inspected += 1;
12521
+ if (draft.state === "submitted") {
12522
+ await compactLegacySubmittedDraftUnlocked(draft, outbox);
12523
+ continue;
12524
+ }
12299
12525
  if (before !== "superseded_redactor" && draft.state === "superseded_redactor") {
12300
12526
  terminalized += 1;
12301
12527
  }
12302
12528
  }
12529
+ await reconcileSubmittedRecordsUnlocked(
12530
+ outbox.directory,
12531
+ options.now ?? /* @__PURE__ */ new Date()
12532
+ );
12303
12533
  return { inspected, terminalized };
12304
12534
  });
12305
12535
  }
@@ -12313,8 +12543,22 @@ async function listPrivateLessonDrafts(options = {}) {
12313
12543
  const entries = await secureOutboxEntries(outbox.directory);
12314
12544
  const limit = Math.max(1, Math.min(options.limit ?? 50, 200));
12315
12545
  const drafts = [];
12316
- for (const name of entries.filter((item) => item.endsWith(".lesson.json"))) {
12546
+ for (const name of entries.filter(
12547
+ (item) => item.endsWith(".lesson.json") || item.endsWith(".submitted.json")
12548
+ )) {
12317
12549
  if (drafts.length >= limit) break;
12550
+ if (name.endsWith(".submitted.json")) {
12551
+ const draftId = name.slice(0, -".submitted.json".length);
12552
+ const completion = parseSubmittedRecord(
12553
+ await readBoundedFile(
12554
+ submittedRecordPath(outbox.directory, draftId),
12555
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12556
+ ),
12557
+ draftId
12558
+ );
12559
+ drafts.push(submittedRecordListItem(completion));
12560
+ continue;
12561
+ }
12318
12562
  const draft = await readPrivateLessonDraft(
12319
12563
  name.slice(0, -".lesson.json".length),
12320
12564
  {
@@ -12333,6 +12577,7 @@ async function listPrivateLessonDrafts(options = {}) {
12333
12577
  created_at: draft.created_at,
12334
12578
  updated_at: draft.updated_at,
12335
12579
  submitted_at: draft.submitted_at,
12580
+ completion_expires_at: null,
12336
12581
  terminal_reason: draft.terminal_reason,
12337
12582
  hold_telemetry_status: draft.hold_telemetry_status,
12338
12583
  hold_event_digest: draft.hold_event_digest,
@@ -12360,6 +12605,72 @@ async function updatePrivateLessonDraft(draftId, update, options = {}) {
12360
12605
  return next;
12361
12606
  });
12362
12607
  }
12608
+ async function completePrivateLessonSubmission(draftId, input, options = {}) {
12609
+ assertDraftId(draftId);
12610
+ const outbox = await readyOutbox(options);
12611
+ return withPrivateLessonOutboxLock(outbox.directory, async () => {
12612
+ await reconcileSubmittedRecordsUnlocked(
12613
+ outbox.directory,
12614
+ options.now ?? /* @__PURE__ */ new Date()
12615
+ );
12616
+ if (existsSync4(tombstonePath(outbox.directory, draftId))) {
12617
+ throw new Error("Private lesson draft was explicitly deleted.");
12618
+ }
12619
+ const existingPath = submittedRecordPath(outbox.directory, draftId);
12620
+ if (existsSync4(existingPath)) {
12621
+ const completion2 = parseSubmittedRecord(
12622
+ await readBoundedFile(
12623
+ existingPath,
12624
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12625
+ ),
12626
+ draftId
12627
+ );
12628
+ if (completion2.receipt_digest !== input.receipt_digest) {
12629
+ throw new Error("Private lesson submitted receipt digest conflicts.");
12630
+ }
12631
+ return completion2;
12632
+ }
12633
+ const path = draftPath(outbox.directory, draftId);
12634
+ await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12635
+ const envelope = parseEnvelope(
12636
+ await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
12637
+ draftId
12638
+ );
12639
+ const current = decryptDraft(envelope, outbox.encryption, true);
12640
+ if (current.state === "superseded_redactor") {
12641
+ throw new Error("Terminal private lesson drafts cannot be submitted.");
12642
+ }
12643
+ if (current.state === "submitted" && current.receipt_digest !== input.receipt_digest) {
12644
+ throw new Error("Private lesson submitted receipt digest conflicts.");
12645
+ }
12646
+ const submittedAt = input.submitted_at ?? options.now ?? /* @__PURE__ */ new Date();
12647
+ const submittedIso = submittedAt.toISOString();
12648
+ const draft = current.state === "submitted" ? current : validateDraft({
12649
+ ...current,
12650
+ state: "submitted",
12651
+ attempts: Math.min(1e3, current.attempts + 1),
12652
+ next_retry_at: null,
12653
+ last_error_code: null,
12654
+ submitted_at: submittedIso,
12655
+ receipt_digest: input.receipt_digest,
12656
+ updated_at: submittedIso
12657
+ });
12658
+ const completion = submittedRecordFromDraft(draft, {
12659
+ remote_status: input.remote_status,
12660
+ processing_state: input.processing_state
12661
+ });
12662
+ await atomicWritePrivate(
12663
+ submittedRecordPath(outbox.directory, draftId),
12664
+ `${JSON.stringify(completion)}
12665
+ `
12666
+ );
12667
+ await unlink(path).catch(() => {
12668
+ });
12669
+ await syncDirectory(outbox.directory).catch(() => {
12670
+ });
12671
+ return completion;
12672
+ });
12673
+ }
12363
12674
  async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12364
12675
  if (!confirmed) {
12365
12676
  throw new Error(
@@ -12370,7 +12681,19 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12370
12681
  const outbox = await readyOutbox(options);
12371
12682
  return withPrivateLessonOutboxLock(outbox.directory, async () => {
12372
12683
  const path = draftPath(outbox.directory, draftId);
12373
- await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12684
+ const completionPath = submittedRecordPath(outbox.directory, draftId);
12685
+ if (!existsSync4(path) && !existsSync4(completionPath)) {
12686
+ throw new Error("Private lesson draft does not exist.");
12687
+ }
12688
+ if (existsSync4(path)) {
12689
+ await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12690
+ }
12691
+ if (existsSync4(completionPath)) {
12692
+ await assertRegularPrivateFile(
12693
+ completionPath,
12694
+ PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
12695
+ );
12696
+ }
12374
12697
  const tombstone = {
12375
12698
  version: "private-lesson-outbox-deletion-v1",
12376
12699
  draft_id: draftId,
@@ -12382,7 +12705,12 @@ async function deletePrivateLessonDraft(draftId, confirmed, options = {}) {
12382
12705
  `${JSON.stringify(tombstone)}
12383
12706
  `
12384
12707
  );
12385
- await unlink(path);
12708
+ await unlink(path).catch((error) => {
12709
+ if (!isMissingError(error)) throw error;
12710
+ });
12711
+ await unlink(completionPath).catch((error) => {
12712
+ if (!isMissingError(error)) throw error;
12713
+ });
12386
12714
  await syncDirectory(outbox.directory);
12387
12715
  return tombstone;
12388
12716
  });
@@ -12415,6 +12743,15 @@ async function privateLessonOutboxSummary(options = {}) {
12415
12743
  deletionTombstones += 1;
12416
12744
  continue;
12417
12745
  }
12746
+ if (name.endsWith(".submitted.json")) {
12747
+ const draftId = name.slice(0, -".submitted.json".length);
12748
+ parseSubmittedRecord(
12749
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
12750
+ draftId
12751
+ );
12752
+ counts.submitted += 1;
12753
+ continue;
12754
+ }
12418
12755
  if (!name.endsWith(".lesson.json")) continue;
12419
12756
  const envelope = parseEnvelope(
12420
12757
  await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
@@ -12477,6 +12814,11 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
12477
12814
  outboxDirectory: directory,
12478
12815
  encryption
12479
12816
  });
12817
+ } else {
12818
+ await withPrivateLessonOutboxLock(
12819
+ directory,
12820
+ () => reconcileSubmittedRecordsUnlocked(directory, options.now ?? /* @__PURE__ */ new Date())
12821
+ );
12480
12822
  }
12481
12823
  const entries = await secureOutboxEntries(directory);
12482
12824
  const counts = emptyOutboxCounts();
@@ -12492,6 +12834,15 @@ async function inspectPrivateLessonOutboxHealth(options = {}) {
12492
12834
  deletionTombstones += 1;
12493
12835
  continue;
12494
12836
  }
12837
+ if (name.endsWith(".submitted.json")) {
12838
+ const draftId = name.slice(0, -".submitted.json".length);
12839
+ parseSubmittedRecord(
12840
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
12841
+ draftId
12842
+ );
12843
+ counts.submitted += 1;
12844
+ continue;
12845
+ }
12495
12846
  if (!name.endsWith(".lesson.json")) continue;
12496
12847
  const envelope = parseEnvelope(
12497
12848
  await readBoundedFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES),
@@ -12559,7 +12910,7 @@ async function resolvePrivateLessonOutboxEncryption(options = {}) {
12559
12910
  encoded = await readConcurrentPrivateLessonFallbackKey(path);
12560
12911
  } catch (error) {
12561
12912
  if (!isMissingError(error)) throw error;
12562
- encoded = randomBytes2(32).toString("base64url");
12913
+ encoded = randomBytes3(32).toString("base64url");
12563
12914
  try {
12564
12915
  await atomicWritePrivate(path, `${encoded}
12565
12916
  `);
@@ -12691,7 +13042,7 @@ async function withPrivateLessonOutboxLock(directory, operation) {
12691
13042
  if (Date.now() - metadata.mtimeMs > PRIVATE_LESSON_OUTBOX_LOCK_STALE_MS) {
12692
13043
  const stalePath = join4(
12693
13044
  directory,
12694
- `.outbox.lock.stale-${randomBytes2(12).toString("hex")}`
13045
+ `.outbox.lock.stale-${randomBytes3(12).toString("hex")}`
12695
13046
  );
12696
13047
  try {
12697
13048
  await rename(lockPath, stalePath);
@@ -12741,14 +13092,22 @@ async function reconcileExplicitDeletionTombstones(directory) {
12741
13092
  await readBoundedFile(join4(directory, name), 4 * 1024),
12742
13093
  name.slice(0, -".deleted.json".length)
12743
13094
  );
12744
- const retainedDraft = draftPath(directory, tombstone.draft_id);
12745
- if (!existsSync4(retainedDraft)) continue;
12746
- await assertRegularPrivateFile(
12747
- retainedDraft,
12748
- PRIVATE_LESSON_DRAFT_MAX_BYTES
12749
- );
12750
- await unlink(retainedDraft);
12751
- changed = true;
13095
+ const retainedPaths = [
13096
+ {
13097
+ path: draftPath(directory, tombstone.draft_id),
13098
+ maxBytes: PRIVATE_LESSON_DRAFT_MAX_BYTES
13099
+ },
13100
+ {
13101
+ path: submittedRecordPath(directory, tombstone.draft_id),
13102
+ maxBytes: PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES
13103
+ }
13104
+ ];
13105
+ for (const retained of retainedPaths) {
13106
+ if (!existsSync4(retained.path)) continue;
13107
+ await assertRegularPrivateFile(retained.path, retained.maxBytes);
13108
+ await unlink(retained.path);
13109
+ changed = true;
13110
+ }
12752
13111
  }
12753
13112
  if (changed) await syncDirectory(directory);
12754
13113
  }
@@ -12779,7 +13138,7 @@ async function writeDraftUnlocked(draft, outbox, replace, _currentOutboxBytes, a
12779
13138
  if (plaintext.length > PRIVATE_LESSON_DRAFT_MAX_BYTES) {
12780
13139
  throw new Error("Private lesson draft exceeds its local size limit.");
12781
13140
  }
12782
- const nonce = randomBytes2(12);
13141
+ const nonce = randomBytes3(12);
12783
13142
  const aad = Buffer.from(
12784
13143
  canonicalJson({
12785
13144
  version: "private-lesson-outbox-envelope-v1",
@@ -12947,6 +13306,135 @@ function terminalizeUnsupportedProfile(draft, now) {
12947
13306
  };
12948
13307
  }
12949
13308
  }
13309
+ function submittedRecordFromDraft(draft, remote) {
13310
+ if (draft.state !== "submitted" || !draft.submitted_at || !draft.receipt_digest) {
13311
+ throw new Error("Private lesson submission completion is incomplete.");
13312
+ }
13313
+ return parseSubmittedRecord(
13314
+ JSON.stringify({
13315
+ version: "private-lesson-outbox-submitted-v1",
13316
+ draft_id: draft.draft_id,
13317
+ state: "submitted",
13318
+ payload_digest: draft.payload_digest,
13319
+ redaction_version: draft.redaction_version,
13320
+ redactor_digest: draft.redactor_digest,
13321
+ attempts: draft.attempts,
13322
+ created_at: draft.created_at,
13323
+ updated_at: draft.updated_at,
13324
+ submitted_at: draft.submitted_at,
13325
+ expires_at: new Date(
13326
+ Date.parse(draft.submitted_at) + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS
13327
+ ).toISOString(),
13328
+ receipt_digest: draft.receipt_digest,
13329
+ remote_status: remote.remote_status,
13330
+ processing_state: remote.processing_state
13331
+ }),
13332
+ draft.draft_id
13333
+ );
13334
+ }
13335
+ async function compactLegacySubmittedDraftUnlocked(draft, outbox) {
13336
+ const completion = submittedRecordFromDraft(draft, {
13337
+ remote_status: null,
13338
+ processing_state: null
13339
+ });
13340
+ const path = submittedRecordPath(outbox.directory, draft.draft_id);
13341
+ try {
13342
+ await atomicWritePrivate(path, `${JSON.stringify(completion)}
13343
+ `);
13344
+ } catch (error) {
13345
+ if (!isAlreadyExistsError2(error)) throw error;
13346
+ const existing = parseSubmittedRecord(
13347
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
13348
+ draft.draft_id
13349
+ );
13350
+ if (existing.payload_digest !== completion.payload_digest || existing.receipt_digest !== completion.receipt_digest) {
13351
+ throw new Error("Private lesson submitted record conflicts.");
13352
+ }
13353
+ }
13354
+ await unlink(draftPath(outbox.directory, draft.draft_id));
13355
+ await syncDirectory(outbox.directory);
13356
+ }
13357
+ async function reconcileSubmittedRecordsUnlocked(directory, now) {
13358
+ const entries = await secureOutboxEntries(directory);
13359
+ let changed = false;
13360
+ for (const name of entries.filter(
13361
+ (entry) => entry.endsWith(".submitted.json")
13362
+ )) {
13363
+ const draftId = name.slice(0, -".submitted.json".length);
13364
+ const path = submittedRecordPath(directory, draftId);
13365
+ const completion = parseSubmittedRecord(
13366
+ await readBoundedFile(path, PRIVATE_LESSON_SUBMITTED_RECORD_MAX_BYTES),
13367
+ draftId
13368
+ );
13369
+ const retainedDraft = draftPath(directory, draftId);
13370
+ if (existsSync4(retainedDraft)) {
13371
+ await assertRegularPrivateFile(
13372
+ retainedDraft,
13373
+ PRIVATE_LESSON_DRAFT_MAX_BYTES
13374
+ );
13375
+ await unlink(retainedDraft);
13376
+ changed = true;
13377
+ }
13378
+ if (Date.parse(completion.expires_at) <= now.getTime()) {
13379
+ await unlink(path);
13380
+ changed = true;
13381
+ }
13382
+ }
13383
+ if (changed) await syncDirectory(directory);
13384
+ }
13385
+ function submittedRecordListItem(completion) {
13386
+ return {
13387
+ draft_id: completion.draft_id,
13388
+ state: "submitted",
13389
+ payload_digest: completion.payload_digest,
13390
+ attempts: completion.attempts,
13391
+ next_retry_at: null,
13392
+ last_error_code: null,
13393
+ created_at: completion.created_at,
13394
+ updated_at: completion.updated_at,
13395
+ submitted_at: completion.submitted_at,
13396
+ completion_expires_at: completion.expires_at,
13397
+ terminal_reason: null,
13398
+ hold_telemetry_status: "not_required",
13399
+ hold_event_digest: null,
13400
+ hold_reported_at: null
13401
+ };
13402
+ }
13403
+ function parseSubmittedRecord(serialized, expectedDraftId) {
13404
+ const value = JSON.parse(serialized);
13405
+ const allowedKeys = /* @__PURE__ */ new Set([
13406
+ "version",
13407
+ "draft_id",
13408
+ "state",
13409
+ "payload_digest",
13410
+ "redaction_version",
13411
+ "redactor_digest",
13412
+ "attempts",
13413
+ "created_at",
13414
+ "updated_at",
13415
+ "submitted_at",
13416
+ "expires_at",
13417
+ "receipt_digest",
13418
+ "remote_status",
13419
+ "processing_state"
13420
+ ]);
13421
+ if (!isRecord4(value) || Object.keys(value).some((key) => !allowedKeys.has(key)) || value.version !== "private-lesson-outbox-submitted-v1" || value.draft_id !== expectedDraftId || value.state !== "submitted" || !isSha256Digest(value.payload_digest) || typeof value.redaction_version !== "string" || !isSha256Digest(value.redactor_digest) || !Number.isInteger(value.attempts) || Number(value.attempts) < 1 || Number(value.attempts) > 1e3 || !isIsoTimestamp(value.created_at) || !isIsoTimestamp(value.updated_at) || !isIsoTimestamp(value.submitted_at) || !isIsoTimestamp(value.expires_at) || !isSha256Digest(value.receipt_digest) || value.remote_status !== null && value.remote_status !== "accepted_private_candidate" && value.remote_status !== "duplicate" || value.processing_state !== null && value.processing_state !== "queued_for_verification" && value.processing_state !== "already_queued") {
13422
+ throw new Error("Private lesson submitted record is invalid.");
13423
+ }
13424
+ const submittedAt = Date.parse(value.submitted_at);
13425
+ if (Date.parse(value.created_at) > submittedAt || Date.parse(value.updated_at) < submittedAt || Date.parse(value.expires_at) !== submittedAt + PRIVATE_LESSON_SUBMITTED_RECORD_RETENTION_MS || value.remote_status === null !== (value.processing_state === null) || value.remote_status === "accepted_private_candidate" && value.processing_state !== "queued_for_verification" || value.remote_status === "duplicate" && value.processing_state !== "already_queued") {
13426
+ throw new Error("Private lesson submitted record is inconsistent.");
13427
+ }
13428
+ return value;
13429
+ }
13430
+ function isSha256Digest(value) {
13431
+ return typeof value === "string" && /^sha256:[a-f0-9]{64}$/.test(value);
13432
+ }
13433
+ function isIsoTimestamp(value) {
13434
+ if (typeof value !== "string") return false;
13435
+ const parsed = Date.parse(value);
13436
+ return Number.isFinite(parsed) && new Date(parsed).toISOString() === value;
13437
+ }
12950
13438
  function isHoldTelemetryStatus(value) {
12951
13439
  return value === "not_required" || value === "pending" || value === "awaiting_authorization" || value === "retry_scheduled" || value === "submitted" || value === "disabled";
12952
13440
  }
@@ -12994,7 +13482,7 @@ async function atomicReplacePrivate(path, data) {
12994
13482
  await assertRegularPrivateFile(path, PRIVATE_LESSON_DRAFT_MAX_BYTES);
12995
13483
  const temporary = join4(
12996
13484
  dirname2(path),
12997
- `.${randomBytes2(12).toString("hex")}.tmp`
13485
+ `.${randomBytes3(12).toString("hex")}.tmp`
12998
13486
  );
12999
13487
  try {
13000
13488
  await atomicWritePrivate(temporary, data);
@@ -13056,6 +13544,10 @@ function draftPath(directory, draftId) {
13056
13544
  assertDraftId(draftId);
13057
13545
  return join4(directory, `${draftId}.lesson.json`);
13058
13546
  }
13547
+ function submittedRecordPath(directory, draftId) {
13548
+ assertDraftId(draftId);
13549
+ return join4(directory, `${draftId}.submitted.json`);
13550
+ }
13059
13551
  function tombstonePath(directory, draftId) {
13060
13552
  assertDraftId(draftId);
13061
13553
  return join4(directory, `${draftId}.deleted.json`);
@@ -13108,7 +13600,7 @@ async function readConcurrentPrivateLessonFallbackKey(path) {
13108
13600
  // src/server.ts
13109
13601
  var MAX_REMOTE_RESPONSE_BYTES = 4 * 1024 * 1024;
13110
13602
  var DOCTOR_PROBE_TIMEOUT_MS = 7500;
13111
- var SERVER_VERSION = true ? "0.1.73" : "0.0.0-dev";
13603
+ var SERVER_VERSION = true ? "0.1.76" : "0.0.0-dev";
13112
13604
  var tools = toolDefinitions;
13113
13605
  var doctorCliRequested = process.argv[2] === "doctor";
13114
13606
  var inputBuffer = Buffer.alloc(0);
@@ -13576,6 +14068,13 @@ async function preparePrivateLessonDraft(input) {
13576
14068
  async function inspectPrivateLessonOutbox(input) {
13577
14069
  const summary = await privateLessonOutboxSummary();
13578
14070
  if (input.draft_id) {
14071
+ const completion = await readPrivateLessonSubmittedRecord(input.draft_id);
14072
+ if (completion) {
14073
+ return {
14074
+ draft: privateLessonSubmittedActionResult(completion),
14075
+ outbox: publicOutboxSummary(summary)
14076
+ };
14077
+ }
13579
14078
  const draft = await readPrivateLessonDraft(input.draft_id);
13580
14079
  return {
13581
14080
  draft: privateLessonDraftActionResult(draft),
@@ -13588,7 +14087,13 @@ async function inspectPrivateLessonOutbox(input) {
13588
14087
  };
13589
14088
  }
13590
14089
  async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
13591
- let draft = await readPrivateLessonDraft(draftId);
14090
+ const source = await readPrivateLessonSubmissionSource(draftId);
14091
+ if (source.kind === "submitted") {
14092
+ return privateLessonSubmittedActionResult(source.completion, {
14093
+ status: "already_submitted"
14094
+ });
14095
+ }
14096
+ let draft = source.draft;
13592
14097
  if (draft.state === "superseded_redactor") {
13593
14098
  return privateLessonDraftActionResult(draft, {
13594
14099
  status: "superseded_redactor",
@@ -13596,7 +14101,18 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
13596
14101
  });
13597
14102
  }
13598
14103
  if (draft.state === "submitted") {
13599
- return privateLessonDraftActionResult(draft, {
14104
+ if (!draft.receipt_digest) {
14105
+ throw new PrivateLessonPolicySafetyError(
14106
+ "Submitted private lesson draft is missing its receipt digest."
14107
+ );
14108
+ }
14109
+ const migrated = await completePrivateLessonSubmission(draft.draft_id, {
14110
+ receipt_digest: draft.receipt_digest,
14111
+ remote_status: null,
14112
+ processing_state: null,
14113
+ submitted_at: draft.submitted_at ? new Date(draft.submitted_at) : void 0
14114
+ });
14115
+ return privateLessonSubmittedActionResult(migrated, {
13600
14116
  status: "already_submitted"
13601
14117
  });
13602
14118
  }
@@ -13758,23 +14274,43 @@ async function submitPrivateLessonDraft(draftId, access, allowPolicyRefresh) {
13758
14274
  );
13759
14275
  }
13760
14276
  const receiptDigest = hashValue(canonicalJson(receipt));
13761
- draft = await updatePrivateLessonDraft(draft.draft_id, (current) => ({
13762
- ...current,
14277
+ const completionProcessingState = processingState === "queued_for_verification" || processingState === "already_queued" ? processingState : null;
14278
+ if (!completionProcessingState) {
14279
+ throw new PrivateLessonPolicySafetyError(
14280
+ "Private lesson submission omitted its processing state."
14281
+ );
14282
+ }
14283
+ const completion = await completePrivateLessonSubmission(draft.draft_id, {
14284
+ receipt_digest: receiptDigest,
14285
+ remote_status: status,
14286
+ processing_state: completionProcessingState
14287
+ });
14288
+ draft = {
14289
+ ...draft,
13763
14290
  state: "submitted",
13764
- attempts: Math.min(1e3, current.attempts + 1),
14291
+ attempts: completion.attempts,
13765
14292
  next_retry_at: null,
13766
14293
  last_error_code: null,
13767
- submitted_at: (/* @__PURE__ */ new Date()).toISOString(),
13768
- receipt_digest: receiptDigest,
13769
- policy: receiptPolicy
13770
- }));
14294
+ submitted_at: completion.submitted_at,
14295
+ receipt_digest: completion.receipt_digest,
14296
+ policy: receiptPolicy,
14297
+ updated_at: completion.updated_at
14298
+ };
13771
14299
  return {
13772
- ...privateLessonDraftActionResult(draft, { status }),
14300
+ ...privateLessonDraftActionResult(draft, {
14301
+ status,
14302
+ completion_expires_at: completion.expires_at
14303
+ }),
13773
14304
  receipt,
13774
14305
  processing_state: processingState
13775
14306
  };
13776
14307
  } catch (error) {
13777
14308
  draft = await persistPrivateLessonFailure(draft, error);
14309
+ if (draft.state === "submitted") {
14310
+ return privateLessonDraftActionResult(draft, {
14311
+ status: "already_submitted"
14312
+ });
14313
+ }
13778
14314
  return privateLessonDraftActionResult(draft, {
13779
14315
  status: "not_submitted",
13780
14316
  action_required: draft.state === "awaiting_authorization" ? "Authorize the exact submit_private_lesson_candidate action, then retry." : null
@@ -14152,13 +14688,37 @@ function privateLessonPolicyBindingConflict(stored, current, allowPolicyRefresh)
14152
14688
  return null;
14153
14689
  }
14154
14690
  async function persistPrivateLessonFailure(draft, error, explicitCode) {
14155
- return updatePrivateLessonDraft(
14156
- draft.draft_id,
14157
- (current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
14158
- ...current,
14159
- ...privateLessonFailureTransition(current, error, explicitCode)
14691
+ const completed = await readPrivateLessonSubmittedRecord(draft.draft_id);
14692
+ if (completed) return submittedDraftFromCompletion(draft, completed);
14693
+ try {
14694
+ return await updatePrivateLessonDraft(
14695
+ draft.draft_id,
14696
+ (current) => current.state === "submitted" || current.state === "superseded_redactor" ? current : {
14697
+ ...current,
14698
+ ...privateLessonFailureTransition(current, error, explicitCode)
14699
+ }
14700
+ );
14701
+ } catch (updateError) {
14702
+ const racedCompletion = await readPrivateLessonSubmittedRecord(
14703
+ draft.draft_id
14704
+ );
14705
+ if (racedCompletion) {
14706
+ return submittedDraftFromCompletion(draft, racedCompletion);
14160
14707
  }
14161
- );
14708
+ throw updateError;
14709
+ }
14710
+ }
14711
+ function submittedDraftFromCompletion(draft, completion) {
14712
+ return {
14713
+ ...draft,
14714
+ state: "submitted",
14715
+ attempts: completion.attempts,
14716
+ next_retry_at: null,
14717
+ last_error_code: null,
14718
+ submitted_at: completion.submitted_at,
14719
+ receipt_digest: completion.receipt_digest,
14720
+ updated_at: completion.updated_at
14721
+ };
14162
14722
  }
14163
14723
  async function persistPrivateLessonHoldFailure(draft, error, explicitCode) {
14164
14724
  return updatePrivateLessonDraft(draft.draft_id, (current) => {
@@ -14250,6 +14810,29 @@ function privateLessonDraftActionResult(draft, extra = {}) {
14250
14810
  ...extra
14251
14811
  };
14252
14812
  }
14813
+ function privateLessonSubmittedActionResult(completion, extra = {}) {
14814
+ return {
14815
+ draft_id: completion.draft_id,
14816
+ state: "submitted",
14817
+ payload_digest: completion.payload_digest,
14818
+ redaction_version: completion.redaction_version,
14819
+ redactor_digest: completion.redactor_digest,
14820
+ redaction_counts: {},
14821
+ held_reasons: [],
14822
+ attempts: completion.attempts,
14823
+ next_retry_at: null,
14824
+ last_error_code: null,
14825
+ terminal_reason: null,
14826
+ hold_telemetry_status: "not_required",
14827
+ hold_event_digest: null,
14828
+ hold_receipt_digest: null,
14829
+ hold_reported_at: null,
14830
+ submitted_at: completion.submitted_at,
14831
+ completion_expires_at: completion.expires_at,
14832
+ retained_locally: false,
14833
+ ...extra
14834
+ };
14835
+ }
14253
14836
  function publicOutboxSummary(summary) {
14254
14837
  const { directory: _directory, ...safe } = summary;
14255
14838
  return safe;
@@ -14792,6 +15375,9 @@ async function runLocalConnectionDoctor(options, execution = "mcp_tool", access
14792
15375
  policyResult,
14793
15376
  outboxResult
14794
15377
  });
15378
+ if (execution === "mcp_tool") {
15379
+ recordConnectionDoctorLifecycleProbe();
15380
+ }
14795
15381
  return {
14796
15382
  ...report,
14797
15383
  status: report.status === "healthy" && privateLessonAutomation.status !== "ready" && privateLessonAutomation.status !== "not_applicable" ? "attention" : report.status,