@haaaiawd/second-nature 0.1.17 → 0.1.19

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "second-nature",
3
3
  "name": "Second Nature",
4
- "version": "0.1.17",
4
+ "version": "0.1.19",
5
5
  "description": "OpenClaw native plugin with synchronous surface registration and bundled runtime spine. Set SECOND_NATURE_WORKSPACE_ROOT or tool workspaceRoot to the same path as the agent workspace (see README / T1.1.4 ops norm).",
6
6
  "activation": {
7
7
  "onStartup": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@haaaiawd/second-nature",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "OpenClaw native plugin with synchronous registration, a packaged runtime artifact, and operator-facing status/explain flows.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -1,23 +1,31 @@
1
- import { createObservabilityDatabase } from "../observability/db/index.js";
2
- import { createStateAPI, createStateDatabase } from "../storage/index.js";
1
+ import { createObservabilityDatabase, } from "../observability/db/index.js";
2
+ import { createStateAPI, createStateDatabase, } from "../storage/index.js";
3
3
  import path from "node:path";
4
4
  import { createActionBridge } from "./action-bridge.js";
5
- import { createCliCommands } from "./commands/index.js";
5
+ import { createCliCommands, } from "./commands/index.js";
6
6
  import { createOpsRouter } from "./ops/ops-router.js";
7
- import { createCliReadModels } from "./read-models/index.js";
7
+ import { createCliReadModels, } from "./read-models/index.js";
8
8
  import { resolvePackagedRuntime } from "./runtime/runtime-artifact-boundary.js";
9
+ import { createRuntimeDecisionRecorder, } from "../observability/services/runtime-decision-recorder.js";
9
10
  export function createCliRuntimeDeps(overrides = {}) {
10
11
  const stateDb = overrides.stateDb ?? createStateDatabase();
11
12
  const observabilityDb = overrides.observabilityDb ?? createObservabilityDatabase();
12
13
  const stateApi = overrides.stateApi ?? createStateAPI(stateDb);
13
- const readModels = overrides.readModels ?? createCliReadModels({ stateDb, observabilityDb });
14
+ const readModels = overrides.readModels ??
15
+ createCliReadModels({
16
+ stateDb,
17
+ observabilityDb,
18
+ workspaceRoot: process.cwd(),
19
+ });
14
20
  const actionBridge = overrides.actionBridge ?? createActionBridge(stateApi);
21
+ const runtimeRecorder = overrides.runtimeRecorder ?? createRuntimeDecisionRecorder(observabilityDb);
15
22
  return {
16
23
  stateDb,
17
24
  observabilityDb,
18
25
  stateApi,
19
26
  readModels,
20
27
  actionBridge,
28
+ runtimeRecorder,
21
29
  };
22
30
  }
23
31
  export function createCommandRouter(options = {}) {
@@ -26,6 +34,9 @@ export function createCommandRouter(options = {}) {
26
34
  const opsRouter = createOpsRouter({
27
35
  runtimeAvailable: resolvePackagedRuntime(pluginRoot).ok,
28
36
  readModels: runtime.readModels,
37
+ runtimeRecorder: runtime.runtimeRecorder,
38
+ state: runtime.stateDb,
39
+ workspaceRoot: process.cwd(),
29
40
  });
30
41
  const commands = createCliCommands({
31
42
  readModels: runtime.readModels,
@@ -43,12 +54,12 @@ export function closeCliRuntimeDeps(deps) {
43
54
  deps.stateDb.close();
44
55
  deps.observabilityDb.close();
45
56
  }
46
- export { createOpsRouter } from "./ops/ops-router.js";
57
+ export { createOpsRouter, } from "./ops/ops-router.js";
47
58
  export { heartbeatCheck, } from "./ops/heartbeat-surface.js";
48
59
  export * from "./host-capability/types.js";
49
- export { classifyDeliveryCapability } from "./host-capability/classify-delivery.js";
60
+ export { classifyDeliveryCapability, } from "./host-capability/classify-delivery.js";
50
61
  export { probeHostCapability } from "./host-capability/probe-host-capability.js";
51
62
  export { recordHostCapability } from "./host-capability/record-host-capability.js";
52
63
  export { runHostSmoke } from "./host-smoke/run-host-smoke.js";
53
64
  export { explainSurfaceSubject } from "./explain/explain-surface-subject.js";
54
- export { showOperatorFallback, OperatorFallbackNotFoundError } from "./ops/show-operator-fallback.js";
65
+ export { showOperatorFallback, OperatorFallbackNotFoundError, } from "./ops/show-operator-fallback.js";
@@ -7,6 +7,8 @@
7
7
  import type { SurfaceMode } from "../runtime/runtime-artifact-boundary.js";
8
8
  import type { HeartbeatSignal } from "../../core/second-nature/heartbeat/signal.js";
9
9
  import type { CliReadModels } from "../read-models/index.js";
10
+ import type { RuntimeDecisionRecorder } from "../../observability/services/runtime-decision-recorder.js";
11
+ import type { StateDatabase } from "../../storage/db/index.js";
10
12
  export type HeartbeatSurfaceStatus = "heartbeat_ok" | "intent_selected" | "denied" | "deferred" | "runtime_carrier_only" | "delivery_unavailable";
11
13
  export interface HeartbeatSurfaceResult {
12
14
  ok: boolean;
@@ -28,6 +30,14 @@ export interface HeartbeatCheckInput {
28
30
  fakeControlPlanePassthrough?: Record<string, unknown>;
29
31
  /** When set, full-runtime heartbeat_check runs the control-plane decision loop (US-001). */
30
32
  readModels?: CliReadModels;
33
+ /** When set, full-runtime cycles are persisted so `loadStatus` exits unknown (T1.2.3). */
34
+ runtimeRecorder?: RuntimeDecisionRecorder;
35
+ /**
36
+ * T2.2.2: when set together with `workspaceRoot`, life evidence from the state DB is loaded
37
+ * and merged into SnapshotInputs so planner/guard paths see real source-ref truth.
38
+ */
39
+ state?: StateDatabase;
40
+ workspaceRoot?: string;
31
41
  timestamp?: string;
32
42
  sessionContext?: string;
33
43
  scopeHint?: HeartbeatSignal["scopeHint"];
@@ -1,6 +1,8 @@
1
1
  import { createWorkspaceHeartbeatRunner } from "./workspace-heartbeat-runner.js";
2
2
  function mapCycleToSurface(cycle, surfaceMode) {
3
- const status = cycle.status === "runtime_carrier_only" ? "runtime_carrier_only" : cycle.status;
3
+ const status = cycle.status === "runtime_carrier_only"
4
+ ? "runtime_carrier_only"
5
+ : cycle.status;
4
6
  return {
5
7
  ok: true,
6
8
  status,
@@ -62,10 +64,16 @@ export async function heartbeatCheck(input) {
62
64
  scopeHint: input.scopeHint,
63
65
  payload: {
64
66
  timestamp,
65
- sessionContext: typeof input.sessionContext === "string" ? input.sessionContext : undefined,
67
+ sessionContext: typeof input.sessionContext === "string"
68
+ ? input.sessionContext
69
+ : undefined,
66
70
  },
67
71
  };
68
- const run = createWorkspaceHeartbeatRunner(input.readModels);
72
+ const run = createWorkspaceHeartbeatRunner(input.readModels, {
73
+ runtimeRecorder: input.runtimeRecorder,
74
+ state: input.state,
75
+ workspaceRoot: input.workspaceRoot ?? process.cwd(),
76
+ });
69
77
  const cycle = await run(signal);
70
78
  return mapCycleToSurface(cycle, "workspace_full_runtime");
71
79
  }
@@ -3,11 +3,21 @@
3
3
  */
4
4
  import { type HeartbeatCheckInput, type HeartbeatSurfaceResult } from "./heartbeat-surface.js";
5
5
  import type { CliReadModels } from "../read-models/index.js";
6
+ import type { RuntimeDecisionRecorder } from "../../observability/services/runtime-decision-recorder.js";
7
+ import type { StateDatabase } from "../../storage/db/index.js";
6
8
  export interface OpsRouterDeps {
7
9
  /** When true, packaged runtime artifacts resolved and full graph is loadable */
8
10
  runtimeAvailable: boolean;
9
11
  /** Workspace read models: fallback view + heartbeat decision loop inputs (T1.2.2 / US-001). */
10
12
  readModels?: CliReadModels;
13
+ /** Persists full-runtime heartbeat cycles so `loadStatus` exits the unknown baseline (T1.2.3). */
14
+ runtimeRecorder?: RuntimeDecisionRecorder;
15
+ /**
16
+ * T2.2.2: state DB + workspace root for life evidence loading in full-runtime heartbeat cycles.
17
+ * When set, `loadSnapshotInputsForWorkspaceHeartbeat` can fill `lifeEvidenceRefs` from real DB truth.
18
+ */
19
+ state?: StateDatabase;
20
+ workspaceRoot?: string;
11
21
  }
12
22
  export interface OpsRouter {
13
23
  heartbeatCheck(input: HeartbeatCheckInput): Promise<HeartbeatSurfaceResult>;
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Shared ops command dispatch for CLI + tool surfaces (T1.1.3, T1.2.2).
3
3
  */
4
- import { heartbeatCheck } from "./heartbeat-surface.js";
5
- import { showOperatorFallback, OperatorFallbackNotFoundError } from "./show-operator-fallback.js";
4
+ import { heartbeatCheck, } from "./heartbeat-surface.js";
5
+ import { showOperatorFallback, OperatorFallbackNotFoundError, } from "./show-operator-fallback.js";
6
6
  function coerceProbeOnlyFlag(input) {
7
7
  const v = input?.probeOnly;
8
8
  return v === true || v === "true" || v === 1 || v === "1";
@@ -13,19 +13,34 @@ export function createOpsRouter(deps) {
13
13
  ...input,
14
14
  runtimeAvailable: input.runtimeAvailable ?? deps.runtimeAvailable,
15
15
  readModels: input.readModels ?? deps.readModels,
16
+ runtimeRecorder: input.runtimeRecorder ?? deps.runtimeRecorder,
17
+ state: input.state ?? deps.state,
18
+ workspaceRoot: input.workspaceRoot ?? deps.workspaceRoot,
16
19
  }),
17
20
  dispatch(command, input) {
18
21
  if (command === "heartbeat_check") {
19
- const runtimeAvailable = typeof input?.runtimeAvailable === "boolean" ? input.runtimeAvailable : deps.runtimeAvailable;
22
+ const runtimeAvailable = typeof input?.runtimeAvailable === "boolean"
23
+ ? input.runtimeAvailable
24
+ : deps.runtimeAvailable;
20
25
  return heartbeatCheck({
21
26
  probeOnly: coerceProbeOnlyFlag(input),
22
27
  runtimeAvailable,
23
- fakeControlPlanePassthrough: input?.fakeControlPlanePassthrough && typeof input.fakeControlPlanePassthrough === "object"
28
+ fakeControlPlanePassthrough: input?.fakeControlPlanePassthrough &&
29
+ typeof input.fakeControlPlanePassthrough === "object"
24
30
  ? input.fakeControlPlanePassthrough
25
31
  : undefined,
26
- readModels: input?.readModels ?? deps.readModels,
32
+ readModels: input?.readModels ??
33
+ deps.readModels,
34
+ runtimeRecorder: input
35
+ ?.runtimeRecorder ?? deps.runtimeRecorder,
36
+ state: input?.state ??
37
+ deps.state,
38
+ workspaceRoot: input
39
+ ?.workspaceRoot ?? deps.workspaceRoot,
27
40
  timestamp: typeof input?.timestamp === "string" ? input.timestamp : undefined,
28
- sessionContext: typeof input?.sessionContext === "string" ? input.sessionContext : undefined,
41
+ sessionContext: typeof input?.sessionContext === "string"
42
+ ? input.sessionContext
43
+ : undefined,
29
44
  scopeHint: input?.scopeHint,
30
45
  });
31
46
  }
@@ -1,10 +1,40 @@
1
1
  /**
2
- * Wires CLI read models into control-plane `runHeartbeatCycle` for `heartbeat_check` (US-001 / CH-09-02).
2
+ * Wires CLI read models into control-plane `runHeartbeatCycle` for `heartbeat_check` (US-001 / CH-09-02 / T1.2.3).
3
3
  *
4
4
  * Snapshot inputs are derived from aggregated status; delivery defaults to none until host capability is modeled here.
5
+ *
6
+ * T1.2.3: when a `RuntimeDecisionRecorder` is provided, persist a `sn-runtime-*` ledger row +
7
+ * `second-nature-runtime` execution attempt after each cycle so `loadStatus` exits its `unknown`
8
+ * baseline once the runtime has actually executed at least one full-runtime turn.
9
+ *
10
+ * T2.2.2: when `state` + `workspaceRoot` are supplied, call `loadLifeEvidenceSnapshot` to fill
11
+ * `lifeEvidenceRefs`, `platformEventCount`, `workEventCount`, and `lifeEvidenceEmptyReason` on
12
+ * `SnapshotInputs` so planner/guard paths that require source refs see real DB truth.
13
+ * Falls back gracefully to `lifeEvidenceEmptyReason: "state_unavailable"` when state is absent.
5
14
  */
6
15
  import type { HeartbeatSignal, HeartbeatCycleResult } from "../../core/second-nature/heartbeat/signal.js";
7
16
  import type { SnapshotInputs } from "../../core/second-nature/heartbeat/snapshot-builder.js";
8
17
  import type { CliReadModels } from "../read-models/index.js";
9
- export declare function loadSnapshotInputsForWorkspaceHeartbeat(readModels: CliReadModels): Promise<SnapshotInputs>;
10
- export declare function createWorkspaceHeartbeatRunner(readModels: CliReadModels): (signal: HeartbeatSignal) => Promise<HeartbeatCycleResult>;
18
+ import type { RuntimeDecisionRecorder } from "../../observability/services/runtime-decision-recorder.js";
19
+ import type { StateDatabase } from "../../storage/db/index.js";
20
+ export interface WorkspaceHeartbeatRunnerOptions {
21
+ /** When supplied, the runner persists the cycle so `loadStatus` can read it (T1.2.3). */
22
+ runtimeRecorder?: RuntimeDecisionRecorder;
23
+ /**
24
+ * T2.2.2: when state + workspaceRoot are provided, life evidence is loaded from DB and merged
25
+ * into SnapshotInputs so planner/guard paths have real source-ref truth.
26
+ */
27
+ state?: StateDatabase;
28
+ workspaceRoot?: string;
29
+ /**
30
+ * T1.2.4: when true (and workspaceRoot is set), inject a `quietWorkflow` dep into the heartbeat
31
+ * cycle so quiet/reflection intents can call `runSourceBackedQuiet` and write artifacts to disk.
32
+ * Defaults to true when workspaceRoot is provided, since this is the host-safe workspace path.
33
+ */
34
+ enableQuietWorkflow?: boolean;
35
+ }
36
+ export declare function loadSnapshotInputsForWorkspaceHeartbeat(readModels: CliReadModels, options?: {
37
+ state?: StateDatabase;
38
+ workspaceRoot?: string;
39
+ }): Promise<SnapshotInputs>;
40
+ export declare function createWorkspaceHeartbeatRunner(readModels: CliReadModels, options?: WorkspaceHeartbeatRunnerOptions): (signal: HeartbeatSignal) => Promise<HeartbeatCycleResult>;
@@ -1,8 +1,48 @@
1
1
  import { runHeartbeatCycle } from "../../core/second-nature/heartbeat/run-heartbeat-cycle.js";
2
- export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels) {
2
+ import { loadLifeEvidenceSnapshot } from "../../storage/snapshots/life-evidence-snapshot.js";
3
+ export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels, options = {}) {
3
4
  const status = await readModels.loadStatus();
4
5
  const mode = status.rhythm.mode === "unknown" ? "active" : status.rhythm.mode;
5
- const quietEnabledBridge = status.quiet.mode === "quiet";
6
+ // CH-15-03: quietEnabledBridge should reflect whether the quiet *execution path* is wired
7
+ // (workspaceRoot available), not whether the last observed rhythm decision was "quiet".
8
+ // status.quiet.mode is typically "unknown" until a Quiet artifact has been persisted, which
9
+ // means binding to it would permanently suppress the quiet window — the opposite of intent.
10
+ // We instead enable the bridge whenever workspaceRoot is provided (same condition as
11
+ // `createWorkspaceHeartbeatRunner` uses for injecting quietWorkflow).
12
+ const quietEnabledBridge = !!options.workspaceRoot;
13
+ // T2.2.2: Load life evidence from state DB when available so SnapshotInputs carries real refs.
14
+ let lifeEvidenceRefs;
15
+ let platformEventCount;
16
+ let workEventCount;
17
+ let lifeEvidenceEmptyReason;
18
+ if (options.state && options.workspaceRoot) {
19
+ try {
20
+ const snapshot = await loadLifeEvidenceSnapshot(options.state, options.workspaceRoot, { limit: 50 },
21
+ // Skip repair gate here — runner is called inside a live cycle; gate ran at startup.
22
+ { runRepairGate: false });
23
+ lifeEvidenceRefs = snapshot.evidenceRefs.map((ref) => ({
24
+ id: ref.id,
25
+ kind: ref.kind,
26
+ uri: ref.uri,
27
+ }));
28
+ platformEventCount = snapshot.platformEvents.length;
29
+ workEventCount = snapshot.workEvents.length;
30
+ if (snapshot.empty) {
31
+ lifeEvidenceEmptyReason = "no_sources";
32
+ }
33
+ }
34
+ catch {
35
+ // If evidence load fails, signal state_unavailable rather than crashing the cycle.
36
+ lifeEvidenceRefs = [];
37
+ platformEventCount = 0;
38
+ workEventCount = 0;
39
+ lifeEvidenceEmptyReason = "state_unavailable";
40
+ }
41
+ }
42
+ else {
43
+ // No state wired — record that life evidence wasn't loaded so guards can reason honestly.
44
+ lifeEvidenceEmptyReason = "state_unavailable";
45
+ }
6
46
  return {
7
47
  mode,
8
48
  currentWindowId: status.rhythm.windowId ?? "workspace-default",
@@ -13,14 +53,41 @@ export async function loadSnapshotInputsForWorkspaceHeartbeat(readModels) {
13
53
  awaitingUserInput: false,
14
54
  quietEnabledBridge,
15
55
  deliveryCapability: { target: "none" },
56
+ lifeEvidenceRefs,
57
+ platformEventCount,
58
+ workEventCount,
59
+ lifeEvidenceEmptyReason,
16
60
  };
17
61
  }
18
- export function createWorkspaceHeartbeatRunner(readModels) {
19
- return (signal) => runHeartbeatCycle({
20
- signal,
21
- runtimeAvailable: true,
22
- deps: {
23
- loadSnapshotInputs: () => loadSnapshotInputsForWorkspaceHeartbeat(readModels),
24
- },
25
- });
62
+ export function createWorkspaceHeartbeatRunner(readModels, options = {}) {
63
+ // T1.2.4: inject quietWorkflow dep when workspaceRoot is set so quiet/reflection intents
64
+ // can trigger runSourceBackedQuiet and persist artifacts to disk.
65
+ const quietEnabled = options.workspaceRoot && options.enableQuietWorkflow !== false;
66
+ return async (signal) => {
67
+ const cycle = await runHeartbeatCycle({
68
+ signal,
69
+ runtimeAvailable: true,
70
+ deps: {
71
+ loadSnapshotInputs: () => loadSnapshotInputsForWorkspaceHeartbeat(readModels, {
72
+ state: options.state,
73
+ workspaceRoot: options.workspaceRoot,
74
+ }),
75
+ // T1.2.4: pass quietWorkflow dep so runSourceBackedQuiet can persist artifacts.
76
+ quietWorkflow: quietEnabled
77
+ ? { workspaceRoot: options.workspaceRoot }
78
+ : undefined,
79
+ },
80
+ });
81
+ if (options.runtimeRecorder) {
82
+ try {
83
+ await options.runtimeRecorder.recordHeartbeatCycle({ cycle, signal });
84
+ }
85
+ catch {
86
+ // T1.2.3: recorder must never break the heartbeat surface response.
87
+ // Failure here means status simply remains at its previous aggregate; the
88
+ // cycle outcome itself is still returned to the caller.
89
+ }
90
+ }
91
+ return cycle;
92
+ };
26
93
  }
@@ -25,5 +25,11 @@ export interface CliReadModelsDeps {
25
25
  observabilityDb: ObservabilityDatabase;
26
26
  /** When set, explain can resolve delivery/fallback/report/source_ref and enrich decision subjects from lived-experience audit envelopes (T5.3.1 / T1.2.1). */
27
27
  livedExperienceAuditStore?: AppendOnlyAuditStore;
28
+ /**
29
+ * T1.2.4: when set, `loadQuiet` and `loadDailyReport` also scan `.second-nature/quiet/{day}/`
30
+ * for persisted Quiet artifact JSON files (from `persistQuietArtifactToWorkspace`) and merge
31
+ * them into the read model so operators see non-zero counts after Quiet actually runs.
32
+ */
33
+ workspaceRoot?: string;
28
34
  }
29
35
  export declare function createCliReadModels(deps: CliReadModelsDeps): CliReadModels;
@@ -1,12 +1,15 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
1
3
  import { desc } from "drizzle-orm";
2
4
  import { createQuietInputLoader } from "../../storage/services/quiet-input-loader.js";
3
5
  import { AssetRepository } from "../../storage/repositories/asset-repository.js";
4
6
  import { CredentialRepository } from "../../storage/repositories/credential-repository.js";
5
7
  import { EvidenceQueryEngine } from "../../observability/query/evidence-query-engine.js";
6
- import { decisionLedger, executionAttempts } from "../../observability/db/schema/index.js";
7
- import { queryExplain } from "../../observability/query/explain-query.js";
8
+ import { decisionLedger, executionAttempts, } from "../../observability/db/schema/index.js";
9
+ import { AppendOnlyAuditStore } from "../../observability/audit/append-only-audit-store.js";
10
+ import { queryExplain, } from "../../observability/query/explain-query.js";
8
11
  import { mapOperatorExplainToReadModel } from "./operator-explain-map.js";
9
- import { loadOperatorFallbackRow, toOperatorFallbackView } from "../../storage/fallback/load-operator-fallback.js";
12
+ import { loadOperatorFallbackRow, toOperatorFallbackView, } from "../../storage/fallback/load-operator-fallback.js";
10
13
  const INTERNAL_RUNTIME_PLATFORM_ID = "second-nature-runtime";
11
14
  const INTERNAL_RUNTIME_TRACE_PREFIX = "sn-runtime-";
12
15
  function toExplainQuery(subject) {
@@ -14,7 +17,9 @@ function toExplainQuery(subject) {
14
17
  case "decision":
15
18
  return { kind: "decision", decisionId: subject.id };
16
19
  case "fallback": {
17
- const ref = subject.id.startsWith("fallback:") ? subject.id : `fallback:${subject.id}`;
20
+ const ref = subject.id.startsWith("fallback:")
21
+ ? subject.id
22
+ : `fallback:${subject.id}`;
18
23
  return { kind: "fallback", fallbackRef: ref };
19
24
  }
20
25
  case "probe":
@@ -29,7 +34,11 @@ function toExplainQuery(subject) {
29
34
  }
30
35
  }
31
36
  function isAuditOnlySubjectKind(kind) {
32
- return kind === "fallback" || kind === "probe" || kind === "report" || kind === "delivery" || kind === "source_ref";
37
+ return (kind === "fallback" ||
38
+ kind === "probe" ||
39
+ kind === "report" ||
40
+ kind === "delivery" ||
41
+ kind === "source_ref");
33
42
  }
34
43
  function buildCredentialNextStep(status) {
35
44
  if (status === "pending_verification")
@@ -38,6 +47,47 @@ function buildCredentialNextStep(status) {
38
47
  return "refresh_credential_context";
39
48
  return undefined;
40
49
  }
50
+ /**
51
+ * T1.2.4: count persisted Quiet artifact JSON files under `.second-nature/quiet/{day}/`
52
+ * so `loadQuiet` / `loadDailyReport` can reflect Quiet artifacts in the read model.
53
+ */
54
+ function countQuietArtifactsForDay(workspaceRoot, day) {
55
+ try {
56
+ const dir = path.join(workspaceRoot, ".second-nature", "quiet", day);
57
+ if (!fs.existsSync(dir))
58
+ return 0;
59
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".json")).length;
60
+ }
61
+ catch {
62
+ return 0;
63
+ }
64
+ }
65
+ /**
66
+ * T1.2.4: scan the last N days under `.second-nature/quiet/` and count total JSON artifacts.
67
+ * Returns { totalArtifacts, recentDays } for merging into QuietReadModel.
68
+ */
69
+ function countRecentQuietArtifacts(workspaceRoot, windowDays = 2) {
70
+ try {
71
+ const quietRoot = path.join(workspaceRoot, ".second-nature", "quiet");
72
+ if (!fs.existsSync(quietRoot))
73
+ return { totalArtifacts: 0, recentDays: [] };
74
+ const now = Date.now();
75
+ const recentDays = [];
76
+ let total = 0;
77
+ for (let i = 0; i < windowDays; i++) {
78
+ const d = new Date(now - i * 86400000).toISOString().slice(0, 10);
79
+ const count = countQuietArtifactsForDay(workspaceRoot, d);
80
+ if (count > 0) {
81
+ recentDays.push(d);
82
+ total += count;
83
+ }
84
+ }
85
+ return { totalArtifacts: total, recentDays };
86
+ }
87
+ catch {
88
+ return { totalArtifacts: 0, recentDays: [] };
89
+ }
90
+ }
41
91
  function mapRuntimeStatus(attempt) {
42
92
  if (!attempt) {
43
93
  return "unknown";
@@ -61,7 +111,11 @@ export function createCliReadModels(deps) {
61
111
  const credentialRepository = new CredentialRepository(deps.stateDb);
62
112
  const quietLoader = createQuietInputLoader(assetRepository);
63
113
  const evidenceQuery = new EvidenceQueryEngine(deps.observabilityDb);
64
- const auditStore = deps.livedExperienceAuditStore;
114
+ // T1.2.5 (CH-14-05): default-inject an empty AppendOnlyAuditStore so `explain` does not
115
+ // immediately return `lived_experience_audit_store_unavailable` for callers that don't supply
116
+ // an explicit store. The empty store means audit-only subjects return `no_matching_audit_events`
117
+ // instead of a configuration error — which is more accurate and less alarming to operators.
118
+ const auditStore = deps.livedExperienceAuditStore ?? new AppendOnlyAuditStore();
65
119
  return {
66
120
  async loadStatus(_scope) {
67
121
  let recentAttempts = [];
@@ -94,15 +148,26 @@ export function createCliReadModels(deps) {
94
148
  credentials = [];
95
149
  }
96
150
  const latestRuntimeAttempt = recentAttempts.find((attempt) => attempt.platformId === INTERNAL_RUNTIME_PLATFORM_ID);
151
+ // CH-15-04 (CH-14-03): latestConnectorAttempt is the most recent execution attempt whose
152
+ // platformId is NOT the internal sn-runtime sentinel — i.e. a real connector platform
153
+ // (Moltbook, EvoMap, etc.). The `connectors` array in StatusReadModel reflects this single
154
+ // most-recent non-runtime attempt, NOT the full connector manifest. An empty array means
155
+ // no connector attempt has been recorded yet, not that connectors are misconfigured.
97
156
  const latestConnectorAttempt = recentAttempts.find((attempt) => attempt.platformId !== INTERNAL_RUNTIME_PLATFORM_ID);
98
157
  const latestRuntimeDecision = recentDecisions.find((decision) => decision.traceId.startsWith(INTERNAL_RUNTIME_TRACE_PREFIX));
99
- const runtimeUpdatedAt = latestRuntimeAttempt?.finishedAt ?? latestRuntimeAttempt?.startedAt ?? latestRuntimeDecision?.createdAt ?? "";
158
+ const runtimeUpdatedAt = latestRuntimeAttempt?.finishedAt ??
159
+ latestRuntimeAttempt?.startedAt ??
160
+ latestRuntimeDecision?.createdAt ??
161
+ "";
100
162
  const quietMode = latestRuntimeDecision?.mode === "quiet" ||
101
163
  latestRuntimeDecision?.mode === "maintenance_only" ||
102
164
  latestRuntimeDecision?.mode === "paused_for_interrupt"
103
165
  ? latestRuntimeDecision.mode
104
166
  : "unknown";
105
- const riskFlags = [latestRuntimeAttempt?.failureClass, latestConnectorAttempt?.failureClass].filter((value) => Boolean(value));
167
+ const riskFlags = [
168
+ latestRuntimeAttempt?.failureClass,
169
+ latestConnectorAttempt?.failureClass,
170
+ ].filter((value) => Boolean(value));
106
171
  const connectorSummary = latestConnectorAttempt
107
172
  ? [
108
173
  {
@@ -126,11 +191,14 @@ export function createCliReadModels(deps) {
126
191
  quiet: {
127
192
  mode: quietMode,
128
193
  lastEvent: latestRuntimeDecision?.traceId,
129
- interrupted: latestRuntimeDecision?.mode === "paused_for_interrupt" ? true : undefined,
194
+ interrupted: latestRuntimeDecision?.mode === "paused_for_interrupt"
195
+ ? true
196
+ : undefined,
130
197
  },
131
198
  connectors: connectorSummary,
132
199
  credentials: credentials.map((item) => ({
133
- platformId: item.platformId ?? item.platform_id,
200
+ platformId: item.platformId ??
201
+ item.platform_id,
134
202
  status: item.status,
135
203
  nextStep: buildCredentialNextStep(item.status),
136
204
  })),
@@ -138,25 +206,49 @@ export function createCliReadModels(deps) {
138
206
  level: riskFlags.length > 0 ? "medium" : "low",
139
207
  flags: riskFlags,
140
208
  },
209
+ // T1.2.5 (CH-14-04): default delivery posture is workspace_default_none because the
210
+ // workspace heartbeat hardcodes `deliveryCapability: { target: "none" }` until a host
211
+ // capability probe explicitly sets a valid target.
212
+ deliveryPosture: {
213
+ verdict: "none",
214
+ source: "workspace_default_none",
215
+ reasonCode: "delivery_target_none",
216
+ },
141
217
  };
142
218
  },
143
219
  async loadDailyReport(day) {
144
220
  let bundle;
145
221
  try {
146
222
  bundle = await quietLoader.loadQuietInputs({
147
- dateRange: { start: `${day}T00:00:00.000Z`, end: `${day}T23:59:59.999Z` },
148
- assetFilters: { includeJournal: false, includeReports: true, includeCurated: false },
223
+ dateRange: {
224
+ start: `${day}T00:00:00.000Z`,
225
+ end: `${day}T23:59:59.999Z`,
226
+ },
227
+ assetFilters: {
228
+ includeJournal: false,
229
+ includeReports: true,
230
+ includeCurated: false,
231
+ },
149
232
  });
150
233
  }
151
234
  catch {
152
235
  bundle = { dailyReports: [], journalEntries: [], sourceCount: 0 };
153
236
  }
237
+ // T1.2.4: merge persisted Quiet artifact JSON files from `.second-nature/quiet/{day}/`
238
+ // into the daily report sourceRefs so the read model reflects artifacts written by
239
+ // `persistQuietArtifactToWorkspace` (closes the canonical read/write gap for loadDailyReport).
240
+ const fsArtifactCount = deps.workspaceRoot
241
+ ? countQuietArtifactsForDay(deps.workspaceRoot, day)
242
+ : 0;
154
243
  const report = bundle.dailyReports[0];
244
+ const existingSources = report?.sources ?? [];
245
+ // Append synthetic source refs for each FS artifact not already in the list.
246
+ const fsSourceRefs = Array.from({ length: fsArtifactCount }, (_, i) => `quiet_artifact:${day}:${i}`).filter((ref) => !existingSources.includes(ref));
155
247
  return {
156
248
  day,
157
249
  summary: report?.summary ?? "",
158
250
  highlights: report?.highlights ?? [],
159
- sourceRefs: report?.sources ?? [],
251
+ sourceRefs: [...existingSources, ...fsSourceRefs],
160
252
  };
161
253
  },
162
254
  async loadQuiet(scope) {
@@ -172,11 +264,20 @@ export function createCliReadModels(deps) {
172
264
  catch {
173
265
  bundle = { dailyReports: [], journalEntries: [], sourceCount: 0 };
174
266
  }
267
+ // T1.2.4 (CH-14-07): also count persisted Quiet artifact JSON files under
268
+ // `.second-nature/quiet/` so that once `runSourceBackedQuiet` has written
269
+ // artifacts to disk, the read model is non-zero even if the legacy memory/
270
+ // journal path is empty.
271
+ const quietArtifacts = deps.workspaceRoot
272
+ ? countRecentQuietArtifacts(deps.workspaceRoot, 2)
273
+ : { totalArtifacts: 0, recentDays: [] };
274
+ const totalSourceCount = bundle.sourceCount + quietArtifacts.totalArtifacts;
275
+ const totalReportCount = bundle.dailyReports.length + quietArtifacts.totalArtifacts;
175
276
  return {
176
277
  scope,
177
- mode: bundle.sourceCount > 0 ? "quiet" : "unknown",
178
- sourceCount: bundle.sourceCount,
179
- reportCount: bundle.dailyReports.length,
278
+ mode: totalSourceCount > 0 ? "quiet" : "unknown",
279
+ sourceCount: totalSourceCount,
280
+ reportCount: totalReportCount,
180
281
  recentJournalCount: bundle.journalEntries.length,
181
282
  };
182
283
  },
@@ -209,7 +310,8 @@ export function createCliReadModels(deps) {
209
310
  };
210
311
  }
211
312
  return {
212
- platformId: record.platformId ?? record.platform_id,
313
+ platformId: record.platformId ??
314
+ record.platform_id,
213
315
  status: record.status,
214
316
  verificationDeadline: record.expiresAt ?? undefined,
215
317
  attemptsRemaining: record.attemptsRemaining ?? undefined,
@@ -224,6 +326,9 @@ export function createCliReadModels(deps) {
224
326
  },
225
327
  async explain(subject) {
226
328
  const q = toExplainQuery(subject);
329
+ // T1.2.5: auditStore is always non-null (default-injected), so the explain path always
330
+ // has a store available. For audit-only subjects with no matching events the summary
331
+ // from queryExplain will be "no_matching_audit_events" — accurate and non-alarming.
227
332
  if (auditStore && q) {
228
333
  const op = queryExplain(q, auditStore);
229
334
  if (isAuditOnlySubjectKind(subject.kind)) {
@@ -236,12 +341,16 @@ export function createCliReadModels(deps) {
236
341
  if (isAuditOnlySubjectKind(subject.kind)) {
237
342
  return {
238
343
  subjectType: subject.kind,
239
- conclusion: auditStore ? "no_matching_audit_events" : "lived_experience_audit_store_unavailable",
240
- keyFactors: auditStore ? [] : ["configure_lived_experience_audit_store_for_operator_explain"],
344
+ // auditStore is always present (default-injected by T1.2.5), so this branch is
345
+ // only reached when q is undefined (unresolvable subject kind).
346
+ conclusion: "no_matching_audit_events",
347
+ keyFactors: [],
241
348
  evidenceRefs: [],
242
349
  };
243
350
  }
244
- const query = subject.kind === "decision" || subject.kind === "platform-selection" || subject.kind === "outreach"
351
+ const query = subject.kind === "decision" ||
352
+ subject.kind === "platform-selection" ||
353
+ subject.kind === "outreach"
245
354
  ? { decisionId: subject.id }
246
355
  : { assetId: subject.id };
247
356
  const bundle = await evidenceQuery.queryEvidence(query);
@@ -27,6 +27,34 @@ export interface RiskSummary {
27
27
  level: "low" | "medium" | "high";
28
28
  flags: string[];
29
29
  }
30
+ /**
31
+ * T1.2.5 (CH-14-04): delivery posture summarises why `deliveryCapability.target` is `none`
32
+ * and which layer set it — workspace heartbeat default vs OpenClaw cron config vs host probe.
33
+ *
34
+ * CH-15-02 implementation note: `loadStatus` currently always emits `workspace_default_none`
35
+ * because the workspace heartbeat hardcodes `target: none` and no T1.1.2 HostCapabilityReport
36
+ * probe is wired into the read model path yet. The other two `source` values are reserved for
37
+ * future integration:
38
+ * - `openclaw_cron_delivery_none`: when the OpenClaw cron layer exposes `delivery.mode: none`
39
+ * in the host config and that value is surfaced via a new probe or bridge field.
40
+ * - `host_capability_probe`: when `HostCapabilityReport.deliveryTarget` is read from the DB
41
+ * (T1.1.2) and routed into `loadStatus`.
42
+ * Do NOT infer either value without a real observation — see ADR-007 "no proof, not sent".
43
+ */
44
+ export interface DeliveryPosture {
45
+ /** Current effective verdict: none = no delivery channel; available = a valid target exists. */
46
+ verdict: "none" | "available";
47
+ /**
48
+ * Stable source discriminator for operator tooling (CH-15-02: only workspace_default_none
49
+ * is emitted today; cron/probe values require additional host-side wiring):
50
+ * workspace_default_none — workspace heartbeat hardcodes target:none (no host probe ran).
51
+ * openclaw_cron_delivery_none — cron layer has delivery.mode:none (host config decision).
52
+ * host_capability_probe — a HostCapabilityReport probe determined the posture.
53
+ */
54
+ source: "workspace_default_none" | "openclaw_cron_delivery_none" | "host_capability_probe";
55
+ /** Human-readable reason code included in explain surfaces. */
56
+ reasonCode: string;
57
+ }
30
58
  export interface StatusReadModel {
31
59
  runtime: RuntimeSummary;
32
60
  rhythm: RhythmSummary;
@@ -34,6 +62,11 @@ export interface StatusReadModel {
34
62
  connectors: ConnectorSummary[];
35
63
  credentials: CredentialSummary[];
36
64
  risk: RiskSummary;
65
+ /**
66
+ * T1.2.5: structured delivery posture so operators can distinguish workspace default "none"
67
+ * from cron-layer "none" without inspecting raw heartbeat JSON.
68
+ */
69
+ deliveryPosture?: DeliveryPosture;
37
70
  }
38
71
  export interface DailyReportReadModel {
39
72
  day: string;
@@ -1,8 +1,8 @@
1
- import { buildContinuitySnapshot } from "./snapshot-builder.js";
2
- import { buildHeartbeatRuntimeSnapshot } from "./runtime-snapshot.js";
1
+ import { buildContinuitySnapshot, } from "./snapshot-builder.js";
2
+ import { buildHeartbeatRuntimeSnapshot, } from "./runtime-snapshot.js";
3
3
  import { planCandidateIntents } from "../orchestrator/intent-planner.js";
4
4
  import { evaluateHardGuards } from "../orchestrator/guard-layer.js";
5
- import { dispatchUserOutreachIntent } from "../outreach/dispatch-user-outreach.js";
5
+ import { dispatchUserOutreachIntent, } from "../outreach/dispatch-user-outreach.js";
6
6
  import { buildJudgeOutreachInputFromSnapshot } from "../outreach/judge-input-from-snapshot.js";
7
7
  import { runSourceBackedQuiet } from "../quiet/run-source-backed-quiet.js";
8
8
  /**
@@ -10,7 +10,9 @@ import { runSourceBackedQuiet } from "../quiet/run-source-backed-quiet.js";
10
10
  * Exported for unit tests (CR-M1 wiring).
11
11
  */
12
12
  export async function resolveAllowedIntentResult(intent, runtime, inputs, signal, deps) {
13
- const day = typeof signal.payload.timestamp === "string" ? signal.payload.timestamp.slice(0, 10) : "1970-01-01";
13
+ const day = typeof signal.payload.timestamp === "string"
14
+ ? signal.payload.timestamp.slice(0, 10)
15
+ : "1970-01-01";
14
16
  if (intent.effectClass === "user_outreach" && deps.outreachDispatch) {
15
17
  return dispatchUserOutreachIntent({
16
18
  candidate: intent,
@@ -22,7 +24,9 @@ export async function resolveAllowedIntentResult(intent, runtime, inputs, signal
22
24
  });
23
25
  }
24
26
  if (deps.quietWorkflow &&
25
- (intent.kind === "quiet" || (intent.kind === "reflection" && intent.effectClass === "narrative_reflection"))) {
27
+ (intent.kind === "quiet" ||
28
+ (intent.kind === "reflection" &&
29
+ intent.effectClass === "narrative_reflection"))) {
26
30
  const quietRun = await runSourceBackedQuiet({
27
31
  candidate: intent,
28
32
  runtime,
@@ -32,11 +36,28 @@ export async function resolveAllowedIntentResult(intent, runtime, inputs, signal
32
36
  });
33
37
  return quietRun.result;
34
38
  }
39
+ // T2.2.3 (CH-14-02/03 / CH-15-01): all intent_selected results must carry at least one
40
+ // machine-readable reason so operators can distinguish between effect classes:
41
+ // - maintenance / no_effect → "internal_tick" (no external side-effects)
42
+ // - connector_action without dispatch wired → "connector_dispatch_unwired"
43
+ // - external_platform_action / memory_curation → not generated by intent-planner today;
44
+ // if a future path produces them, they will reach the fallback [] branch below and
45
+ // should have dedicated reason codes added (e.g. "external_platform_action_unwired").
46
+ // - other (outreach / quiet) → caught by the early-return branches above
47
+ const noExternalEffect = intent.effectClass === "maintenance" ||
48
+ intent.effectClass === "no_effect" ||
49
+ intent.kind === "maintenance";
50
+ const connectorUnwired = intent.effectClass === "connector_action";
51
+ const reasons = noExternalEffect
52
+ ? ["internal_tick"]
53
+ : connectorUnwired
54
+ ? ["connector_dispatch_unwired"]
55
+ : [];
35
56
  return {
36
57
  scope: "rhythm",
37
58
  status: "intent_selected",
38
59
  selectedIntentId: intent.id,
39
- reasons: [],
60
+ reasons,
40
61
  };
41
62
  }
42
63
  /**
@@ -82,7 +103,9 @@ export async function ingestRhythmSignal(signal, deps) {
82
103
  reasons: evaluation.reasons,
83
104
  };
84
105
  const resolved = await resolveAllowedIntentResult(intent, runtime, inputs, signal, deps);
85
- const result = resolved.status === "intent_selected" && resolved.reasons.length === 0 && evaluation.reasons.length > 0
106
+ const result = resolved.status === "intent_selected" &&
107
+ resolved.reasons.length === 0 &&
108
+ evaluation.reasons.length > 0
86
109
  ? { ...resolved, reasons: evaluation.reasons }
87
110
  : resolved;
88
111
  await emitTrace(result);
@@ -27,7 +27,7 @@ export function startRuntimeService(ctx) {
27
27
  // - control-plane-system (heartbeat bridge preparation)
28
28
  const workspaceRoot = ctx?.workspaceRoot ?? process.cwd();
29
29
  /** Keep in sync with `plugin/package.json` when cutting releases. */
30
- const version = "0.1.17";
30
+ const version = "0.1.19";
31
31
  activeHandle = {
32
32
  ready: true,
33
33
  version,
@@ -6,83 +6,83 @@ import { fileURLToPath } from "node:url";
6
6
  import * as schema from "./schema/index.js";
7
7
  // Pre-initialize sql.js WASM at module load time
8
8
  const SQL = await initSqlJs();
9
- const OBSERVABILITY_SCHEMA_SQL = `
10
- CREATE TABLE IF NOT EXISTS decision_ledger (
11
- id TEXT PRIMARY KEY,
12
- tick_id TEXT NOT NULL,
13
- trace_id TEXT NOT NULL,
14
- intent_id TEXT,
15
- platform_id TEXT,
16
- verdict TEXT NOT NULL,
17
- mode TEXT NOT NULL,
18
- reasons TEXT NOT NULL,
19
- reason_codes TEXT NOT NULL,
20
- decision_basis TEXT NOT NULL,
21
- evidence_refs TEXT NOT NULL,
22
- model_eval_ref TEXT,
23
- created_at TEXT NOT NULL
24
- );
25
- CREATE UNIQUE INDEX IF NOT EXISTS decision_trace_idx ON decision_ledger(trace_id);
26
- CREATE INDEX IF NOT EXISTS decision_tick_idx ON decision_ledger(tick_id);
27
- CREATE TABLE IF NOT EXISTS execution_attempts (
28
- id TEXT PRIMARY KEY,
29
- trace_id TEXT NOT NULL,
30
- decision_id TEXT NOT NULL,
31
- intent_id TEXT NOT NULL,
32
- platform_id TEXT NOT NULL,
33
- capability TEXT NOT NULL,
34
- channel TEXT NOT NULL,
35
- status TEXT NOT NULL,
36
- commit_state TEXT,
37
- failure_class TEXT,
38
- retry_policy TEXT,
39
- idempotency_key TEXT,
40
- started_at TEXT,
41
- finished_at TEXT
42
- );
43
- CREATE UNIQUE INDEX IF NOT EXISTS attempt_trace_idx ON execution_attempts(trace_id);
44
- CREATE INDEX IF NOT EXISTS attempt_decision_idx ON execution_attempts(decision_id);
45
- CREATE INDEX IF NOT EXISTS attempt_platform_idx ON execution_attempts(platform_id);
46
- CREATE TABLE IF NOT EXISTS governance_audit (
47
- id TEXT PRIMARY KEY,
48
- event_type TEXT NOT NULL,
49
- proposal_id TEXT,
50
- target_asset_id TEXT,
51
- asset_path TEXT,
52
- status_from TEXT,
53
- status_to TEXT NOT NULL,
54
- before_hash TEXT,
55
- after_hash TEXT,
56
- supporting_sources TEXT,
57
- reason TEXT,
58
- verification_deadline TEXT,
59
- attempts_remaining INTEGER,
60
- created_at TEXT NOT NULL
61
- );
62
- CREATE INDEX IF NOT EXISTS audit_proposal_idx ON governance_audit(proposal_id);
63
- CREATE INDEX IF NOT EXISTS audit_asset_idx ON governance_audit(target_asset_id);
64
- CREATE INDEX IF NOT EXISTS audit_event_idx ON governance_audit(event_type);
65
- CREATE TABLE IF NOT EXISTS redaction_manifest (
66
- id TEXT PRIMARY KEY,
67
- event_id TEXT NOT NULL,
68
- event_type TEXT NOT NULL,
69
- field_name TEXT NOT NULL,
70
- action TEXT NOT NULL,
71
- original_value_hash TEXT,
72
- created_at TEXT NOT NULL
73
- );
74
- CREATE INDEX IF NOT EXISTS redact_event_idx ON redaction_manifest(event_id);
75
- CREATE TABLE IF NOT EXISTS host_capability_reports (
76
- report_id TEXT PRIMARY KEY,
77
- generated_at TEXT NOT NULL,
78
- host_version TEXT,
79
- observed_version TEXT,
80
- doc_checked_at TEXT NOT NULL,
81
- doc_links_json TEXT NOT NULL,
82
- delivery_target TEXT NOT NULL,
83
- conflict_records_json TEXT NOT NULL,
84
- full_report_json TEXT NOT NULL
85
- );
9
+ const OBSERVABILITY_SCHEMA_SQL = `
10
+ CREATE TABLE IF NOT EXISTS decision_ledger (
11
+ id TEXT PRIMARY KEY,
12
+ tick_id TEXT NOT NULL,
13
+ trace_id TEXT NOT NULL,
14
+ intent_id TEXT,
15
+ platform_id TEXT,
16
+ verdict TEXT NOT NULL,
17
+ mode TEXT NOT NULL,
18
+ reasons TEXT NOT NULL,
19
+ reason_codes TEXT NOT NULL,
20
+ decision_basis TEXT NOT NULL,
21
+ evidence_refs TEXT NOT NULL,
22
+ model_eval_ref TEXT,
23
+ created_at TEXT NOT NULL
24
+ );
25
+ CREATE UNIQUE INDEX IF NOT EXISTS decision_trace_idx ON decision_ledger(trace_id);
26
+ CREATE INDEX IF NOT EXISTS decision_tick_idx ON decision_ledger(tick_id);
27
+ CREATE TABLE IF NOT EXISTS execution_attempts (
28
+ id TEXT PRIMARY KEY,
29
+ trace_id TEXT NOT NULL,
30
+ decision_id TEXT NOT NULL,
31
+ intent_id TEXT NOT NULL,
32
+ platform_id TEXT NOT NULL,
33
+ capability TEXT NOT NULL,
34
+ channel TEXT NOT NULL,
35
+ status TEXT NOT NULL,
36
+ commit_state TEXT,
37
+ failure_class TEXT,
38
+ retry_policy TEXT,
39
+ idempotency_key TEXT,
40
+ started_at TEXT,
41
+ finished_at TEXT
42
+ );
43
+ CREATE UNIQUE INDEX IF NOT EXISTS attempt_trace_idx ON execution_attempts(trace_id);
44
+ CREATE INDEX IF NOT EXISTS attempt_decision_idx ON execution_attempts(decision_id);
45
+ CREATE INDEX IF NOT EXISTS attempt_platform_idx ON execution_attempts(platform_id);
46
+ CREATE TABLE IF NOT EXISTS governance_audit (
47
+ id TEXT PRIMARY KEY,
48
+ event_type TEXT NOT NULL,
49
+ proposal_id TEXT,
50
+ target_asset_id TEXT,
51
+ asset_path TEXT,
52
+ status_from TEXT,
53
+ status_to TEXT NOT NULL,
54
+ before_hash TEXT,
55
+ after_hash TEXT,
56
+ supporting_sources TEXT,
57
+ reason TEXT,
58
+ verification_deadline TEXT,
59
+ attempts_remaining INTEGER,
60
+ created_at TEXT NOT NULL
61
+ );
62
+ CREATE INDEX IF NOT EXISTS audit_proposal_idx ON governance_audit(proposal_id);
63
+ CREATE INDEX IF NOT EXISTS audit_asset_idx ON governance_audit(target_asset_id);
64
+ CREATE INDEX IF NOT EXISTS audit_event_idx ON governance_audit(event_type);
65
+ CREATE TABLE IF NOT EXISTS redaction_manifest (
66
+ id TEXT PRIMARY KEY,
67
+ event_id TEXT NOT NULL,
68
+ event_type TEXT NOT NULL,
69
+ field_name TEXT NOT NULL,
70
+ action TEXT NOT NULL,
71
+ original_value_hash TEXT,
72
+ created_at TEXT NOT NULL
73
+ );
74
+ CREATE INDEX IF NOT EXISTS redact_event_idx ON redaction_manifest(event_id);
75
+ CREATE TABLE IF NOT EXISTS host_capability_reports (
76
+ report_id TEXT PRIMARY KEY,
77
+ generated_at TEXT NOT NULL,
78
+ host_version TEXT,
79
+ observed_version TEXT,
80
+ doc_checked_at TEXT NOT NULL,
81
+ doc_links_json TEXT NOT NULL,
82
+ delivery_target TEXT NOT NULL,
83
+ conflict_records_json TEXT NOT NULL,
84
+ full_report_json TEXT NOT NULL
85
+ );
86
86
  `;
87
87
  function resolveDbPath(filename) {
88
88
  if (path.isAbsolute(filename) || filename === ":memory:") {
@@ -8,6 +8,7 @@ export { redactEvent, createEmptyManifest, mergeManifests, type RedactionManifes
8
8
  export { DecisionLedger, type QuietLifecycleEvent, type OutreachDecision, type HeartbeatDecisionEvent } from "./services/decision-ledger.js";
9
9
  export { GovernanceAudit, type CredentialLifecycleAudit } from "./services/governance-audit.js";
10
10
  export { ExecutionTelemetry, type ExecutionAttemptInput } from "./services/execution-telemetry.js";
11
+ export { createRuntimeDecisionRecorder, RUNTIME_DECISION_TRACE_PREFIX, RUNTIME_INTERNAL_PLATFORM_ID, type RuntimeDecisionRecorder, type RecordHeartbeatCycleInput, type RecordHeartbeatCycleOutput, } from "./services/runtime-decision-recorder.js";
11
12
  export { LivedExperienceAuditRecorder, createLivedExperienceAuditRecorder, type DecisionTracePayload, type DeliveryAuditPayload, type DeliveryAuditStatus, type ExplainLinkageSummary, type GuidanceGroundingAuditPayload, type SourceCoverageAuditPayload, } from "./services/lived-experience-audit.js";
12
13
  export { GovernancePlaneRecorder, createGovernancePlaneRecorder, type AuditAppendAck, type ConnectorAttemptAudit, type ConnectorAttemptOutcome, type StateGovernanceAudit, type StateGovernanceKind, } from "./services/governance-plane-recorder.js";
13
14
  export { queryExplain, type ExplainQuery, type OperatorExplainReadModel, type RedactedExplainEvent, } from "./query/explain-query.js";
@@ -8,6 +8,7 @@ export { redactEvent, createEmptyManifest, mergeManifests, } from "./redaction/m
8
8
  export { DecisionLedger } from "./services/decision-ledger.js";
9
9
  export { GovernanceAudit } from "./services/governance-audit.js";
10
10
  export { ExecutionTelemetry } from "./services/execution-telemetry.js";
11
+ export { createRuntimeDecisionRecorder, RUNTIME_DECISION_TRACE_PREFIX, RUNTIME_INTERNAL_PLATFORM_ID, } from "./services/runtime-decision-recorder.js";
11
12
  export { LivedExperienceAuditRecorder, createLivedExperienceAuditRecorder, } from "./services/lived-experience-audit.js";
12
13
  export { GovernancePlaneRecorder, createGovernancePlaneRecorder, } from "./services/governance-plane-recorder.js";
13
14
  export { queryExplain, } from "./query/explain-query.js";
@@ -0,0 +1,29 @@
1
+ import type { ObservabilityDatabase } from "../db/index.js";
2
+ import { DecisionLedger, type HeartbeatDecisionEvent } from "./decision-ledger.js";
3
+ import { ExecutionTelemetry } from "./execution-telemetry.js";
4
+ import type { HeartbeatCycleResult, HeartbeatSignal } from "../../core/second-nature/heartbeat/signal.js";
5
+ export declare const RUNTIME_DECISION_TRACE_PREFIX = "sn-runtime-";
6
+ export declare const RUNTIME_INTERNAL_PLATFORM_ID = "second-nature-runtime";
7
+ export interface RecordHeartbeatCycleInput {
8
+ cycle: HeartbeatCycleResult;
9
+ signal: HeartbeatSignal;
10
+ /**
11
+ * Override rhythm `mode` written to the ledger row. When omitted, falls back
12
+ * to `"active"`; downstream loadStatus only treats `quiet` /
13
+ * `maintenance_only` / `paused_for_interrupt` as Quiet-aware values.
14
+ */
15
+ rhythmMode?: HeartbeatDecisionEvent["mode"];
16
+ }
17
+ export interface RecordHeartbeatCycleOutput {
18
+ traceId: string;
19
+ decisionId: string;
20
+ attemptId: string;
21
+ }
22
+ export interface RuntimeDecisionRecorder {
23
+ recordHeartbeatCycle(input: RecordHeartbeatCycleInput): Promise<RecordHeartbeatCycleOutput>;
24
+ }
25
+ export interface CreateRuntimeDecisionRecorderDeps {
26
+ ledger?: DecisionLedger;
27
+ telemetry?: ExecutionTelemetry;
28
+ }
29
+ export declare function createRuntimeDecisionRecorder(observabilityDb: ObservabilityDatabase, overrides?: CreateRuntimeDecisionRecorderDeps): RuntimeDecisionRecorder;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Runtime Decision Recorder (T1.2.3).
3
+ *
4
+ * Core logic: after a workspace `runHeartbeatCycle` completes, persist two rows that
5
+ * `loadStatus` already filters on, so operator status stops returning `unknown` for
6
+ * `rhythm.mode` / `runtime.serviceStatus` once the runtime has executed at least once.
7
+ * - `decision_ledger` row via `DecisionLedger.recordHeartbeatDecision()` with
8
+ * `traceId` prefix `sn-runtime-` (matches `INTERNAL_RUNTIME_TRACE_PREFIX`).
9
+ * - `execution_attempts` row via `ExecutionTelemetry.startAttempt` +
10
+ * `completeAttempt` with `platformId === "second-nature-runtime"` (matches
11
+ * `INTERNAL_RUNTIME_PLATFORM_ID`).
12
+ *
13
+ * Boundaries:
14
+ * - Recorder failure must NOT break the heartbeat surface response — caller wraps with try/catch.
15
+ * - Carrier-only / probe-only / runtime-unavailable paths do NOT invoke this recorder
16
+ * (their semantics intentionally remain "unknown" until a full-runtime turn happens).
17
+ * - This is a derived observability writer; it is not the canonical decision producer
18
+ * (control-plane keeps that contract). It exists to close the read-side aggregation gap.
19
+ */
20
+ import { randomUUID } from "node:crypto";
21
+ import { DecisionLedger } from "./decision-ledger.js";
22
+ import { ExecutionTelemetry } from "./execution-telemetry.js";
23
+ export const RUNTIME_DECISION_TRACE_PREFIX = "sn-runtime-";
24
+ export const RUNTIME_INTERNAL_PLATFORM_ID = "second-nature-runtime";
25
+ const RUNTIME_INTERNAL_CAPABILITY = "runtime.heartbeat";
26
+ const RUNTIME_INTERNAL_CHANNEL = "internal";
27
+ export function createRuntimeDecisionRecorder(observabilityDb, overrides = {}) {
28
+ const ledger = overrides.ledger ?? new DecisionLedger(observabilityDb);
29
+ const telemetry = overrides.telemetry ?? new ExecutionTelemetry(observabilityDb);
30
+ return {
31
+ async recordHeartbeatCycle({ cycle, signal, rhythmMode }) {
32
+ const timestamp = typeof signal.payload.timestamp === "string" && signal.payload.timestamp.trim().length > 0
33
+ ? signal.payload.timestamp
34
+ : new Date().toISOString();
35
+ const uniqueId = randomUUID();
36
+ const traceId = `${RUNTIME_DECISION_TRACE_PREFIX}${cycle.scope}-${cycle.status}-${uniqueId}`;
37
+ const decisionId = `decision-runtime-${uniqueId}`;
38
+ const tickId = `tick-runtime-${uniqueId}`;
39
+ const event = {
40
+ id: decisionId,
41
+ tickId,
42
+ traceId,
43
+ runtimeScope: cycle.scope,
44
+ triggerSource: signal.trigger,
45
+ decisionStatus: mapCycleStatus(cycle.status),
46
+ reasons: cycle.reasons,
47
+ intentId: cycle.selectedIntentId,
48
+ mode: rhythmMode ?? "active",
49
+ createdAt: timestamp,
50
+ };
51
+ await ledger.recordHeartbeatDecision(event);
52
+ const attemptId = await telemetry.startAttempt({
53
+ traceId,
54
+ decisionId,
55
+ intentId: cycle.selectedIntentId ?? `${RUNTIME_INTERNAL_PLATFORM_ID}-tick`,
56
+ platformId: RUNTIME_INTERNAL_PLATFORM_ID,
57
+ capability: RUNTIME_INTERNAL_CAPABILITY,
58
+ channel: RUNTIME_INTERNAL_CHANNEL,
59
+ startedAt: timestamp,
60
+ });
61
+ const status = isFailureCycle(cycle.status) ? "failed" : "succeeded";
62
+ const failureClass = status === "failed" ? cycleStatusFailureClass(cycle.status) : undefined;
63
+ await telemetry.completeAttempt(traceId, status, undefined, failureClass);
64
+ return { traceId, decisionId, attemptId };
65
+ },
66
+ };
67
+ }
68
+ function mapCycleStatus(status) {
69
+ switch (status) {
70
+ case "intent_selected":
71
+ return "intent_selected";
72
+ case "denied":
73
+ return "denied";
74
+ case "deferred":
75
+ return "deferred";
76
+ case "delivery_unavailable":
77
+ return "delivery_unavailable";
78
+ case "runtime_carrier_only":
79
+ return "runtime_carrier_only";
80
+ case "heartbeat_ok":
81
+ default:
82
+ return "heartbeat_ok";
83
+ }
84
+ }
85
+ function isFailureCycle(status) {
86
+ return status === "delivery_unavailable" || status === "denied";
87
+ }
88
+ function cycleStatusFailureClass(status) {
89
+ if (status === "delivery_unavailable")
90
+ return "delivery_unavailable";
91
+ if (status === "denied")
92
+ return "decision_denied";
93
+ return undefined;
94
+ }
@@ -6,99 +6,99 @@ import { fileURLToPath } from "node:url";
6
6
  import * as schema from "./schema/index.js";
7
7
  // Pre-initialize sql.js WASM at module load time
8
8
  const SQL = await initSqlJs();
9
- const STATE_SCHEMA_SQL = `
10
- CREATE TABLE IF NOT EXISTS credential_records (
11
- platform_id TEXT PRIMARY KEY,
12
- credential_type TEXT NOT NULL,
13
- encrypted_value TEXT NOT NULL,
14
- status TEXT NOT NULL,
15
- verification_code TEXT,
16
- challenge_text TEXT,
17
- expires_at TEXT,
18
- attempts_remaining INTEGER,
19
- updated_at TEXT NOT NULL
20
- );
21
- CREATE TABLE IF NOT EXISTS policy_records (
22
- platform_id TEXT PRIMARY KEY,
23
- social_daily_limit INTEGER NOT NULL,
24
- quiet_enabled INTEGER NOT NULL,
25
- outreach_daily_budget INTEGER NOT NULL DEFAULT 2,
26
- updated_at TEXT NOT NULL
27
- );
28
- CREATE TABLE IF NOT EXISTS life_evidence_index (
29
- id TEXT PRIMARY KEY,
30
- timestamp TEXT NOT NULL,
31
- evidence_type TEXT NOT NULL,
32
- sensitivity TEXT NOT NULL,
33
- producer TEXT NOT NULL,
34
- artifact_path TEXT NOT NULL,
35
- platform_id TEXT,
36
- source_refs_json TEXT NOT NULL
37
- );
38
- CREATE TABLE IF NOT EXISTS asset_registry (
39
- id TEXT PRIMARY KEY,
40
- kind TEXT NOT NULL,
41
- path TEXT NOT NULL,
42
- hash TEXT NOT NULL,
43
- version INTEGER NOT NULL DEFAULT 1,
44
- layer TEXT NOT NULL,
45
- last_indexed_at TEXT NOT NULL
46
- );
47
- CREATE UNIQUE INDEX IF NOT EXISTS asset_registry_path_idx ON asset_registry(path);
48
- CREATE TABLE IF NOT EXISTS intent_commit_records (
49
- id TEXT PRIMARY KEY,
50
- intent_id TEXT NOT NULL,
51
- decision_id TEXT NOT NULL,
52
- checkpoint_id TEXT,
53
- state TEXT NOT NULL,
54
- outcome_ref TEXT,
55
- metadata_json TEXT,
56
- updated_at TEXT NOT NULL
57
- );
58
- CREATE TABLE IF NOT EXISTS proposal_records (
59
- id TEXT PRIMARY KEY,
60
- target_asset_id TEXT NOT NULL,
61
- before_hash TEXT,
62
- after_hash TEXT,
63
- status TEXT NOT NULL,
64
- proposed_diff TEXT NOT NULL,
65
- reason TEXT NOT NULL,
66
- supporting_sources TEXT NOT NULL,
67
- confidence REAL NOT NULL,
68
- created_at TEXT NOT NULL,
69
- applied_at TEXT
70
- );
71
- CREATE TABLE IF NOT EXISTS provenance_edges (
72
- id TEXT PRIMARY KEY,
73
- from_id TEXT NOT NULL,
74
- to_id TEXT NOT NULL,
75
- kind TEXT NOT NULL,
76
- created_at TEXT NOT NULL
77
- );
78
- CREATE TABLE IF NOT EXISTS delivery_attempts (
79
- attempt_id TEXT PRIMARY KEY,
80
- decision_id TEXT NOT NULL,
81
- target TEXT,
82
- channel TEXT,
83
- status TEXT NOT NULL,
84
- message_id TEXT,
85
- host_proof_ref_json TEXT,
86
- error_class TEXT,
87
- fallback_ref TEXT,
88
- created_at TEXT NOT NULL
89
- );
90
- CREATE INDEX IF NOT EXISTS delivery_attempt_decision_idx ON delivery_attempts(decision_id);
91
- CREATE TABLE IF NOT EXISTS operator_fallback_artifacts (
92
- fallback_ref TEXT PRIMARY KEY,
93
- decision_id TEXT NOT NULL,
94
- status TEXT NOT NULL,
95
- reason TEXT NOT NULL,
96
- source_refs_json TEXT NOT NULL,
97
- candidate_message TEXT,
98
- next_step TEXT NOT NULL,
99
- created_at TEXT NOT NULL
100
- );
101
- CREATE INDEX IF NOT EXISTS operator_fallback_decision_idx ON operator_fallback_artifacts(decision_id);
9
+ const STATE_SCHEMA_SQL = `
10
+ CREATE TABLE IF NOT EXISTS credential_records (
11
+ platform_id TEXT PRIMARY KEY,
12
+ credential_type TEXT NOT NULL,
13
+ encrypted_value TEXT NOT NULL,
14
+ status TEXT NOT NULL,
15
+ verification_code TEXT,
16
+ challenge_text TEXT,
17
+ expires_at TEXT,
18
+ attempts_remaining INTEGER,
19
+ updated_at TEXT NOT NULL
20
+ );
21
+ CREATE TABLE IF NOT EXISTS policy_records (
22
+ platform_id TEXT PRIMARY KEY,
23
+ social_daily_limit INTEGER NOT NULL,
24
+ quiet_enabled INTEGER NOT NULL,
25
+ outreach_daily_budget INTEGER NOT NULL DEFAULT 2,
26
+ updated_at TEXT NOT NULL
27
+ );
28
+ CREATE TABLE IF NOT EXISTS life_evidence_index (
29
+ id TEXT PRIMARY KEY,
30
+ timestamp TEXT NOT NULL,
31
+ evidence_type TEXT NOT NULL,
32
+ sensitivity TEXT NOT NULL,
33
+ producer TEXT NOT NULL,
34
+ artifact_path TEXT NOT NULL,
35
+ platform_id TEXT,
36
+ source_refs_json TEXT NOT NULL
37
+ );
38
+ CREATE TABLE IF NOT EXISTS asset_registry (
39
+ id TEXT PRIMARY KEY,
40
+ kind TEXT NOT NULL,
41
+ path TEXT NOT NULL,
42
+ hash TEXT NOT NULL,
43
+ version INTEGER NOT NULL DEFAULT 1,
44
+ layer TEXT NOT NULL,
45
+ last_indexed_at TEXT NOT NULL
46
+ );
47
+ CREATE UNIQUE INDEX IF NOT EXISTS asset_registry_path_idx ON asset_registry(path);
48
+ CREATE TABLE IF NOT EXISTS intent_commit_records (
49
+ id TEXT PRIMARY KEY,
50
+ intent_id TEXT NOT NULL,
51
+ decision_id TEXT NOT NULL,
52
+ checkpoint_id TEXT,
53
+ state TEXT NOT NULL,
54
+ outcome_ref TEXT,
55
+ metadata_json TEXT,
56
+ updated_at TEXT NOT NULL
57
+ );
58
+ CREATE TABLE IF NOT EXISTS proposal_records (
59
+ id TEXT PRIMARY KEY,
60
+ target_asset_id TEXT NOT NULL,
61
+ before_hash TEXT,
62
+ after_hash TEXT,
63
+ status TEXT NOT NULL,
64
+ proposed_diff TEXT NOT NULL,
65
+ reason TEXT NOT NULL,
66
+ supporting_sources TEXT NOT NULL,
67
+ confidence REAL NOT NULL,
68
+ created_at TEXT NOT NULL,
69
+ applied_at TEXT
70
+ );
71
+ CREATE TABLE IF NOT EXISTS provenance_edges (
72
+ id TEXT PRIMARY KEY,
73
+ from_id TEXT NOT NULL,
74
+ to_id TEXT NOT NULL,
75
+ kind TEXT NOT NULL,
76
+ created_at TEXT NOT NULL
77
+ );
78
+ CREATE TABLE IF NOT EXISTS delivery_attempts (
79
+ attempt_id TEXT PRIMARY KEY,
80
+ decision_id TEXT NOT NULL,
81
+ target TEXT,
82
+ channel TEXT,
83
+ status TEXT NOT NULL,
84
+ message_id TEXT,
85
+ host_proof_ref_json TEXT,
86
+ error_class TEXT,
87
+ fallback_ref TEXT,
88
+ created_at TEXT NOT NULL
89
+ );
90
+ CREATE INDEX IF NOT EXISTS delivery_attempt_decision_idx ON delivery_attempts(decision_id);
91
+ CREATE TABLE IF NOT EXISTS operator_fallback_artifacts (
92
+ fallback_ref TEXT PRIMARY KEY,
93
+ decision_id TEXT NOT NULL,
94
+ status TEXT NOT NULL,
95
+ reason TEXT NOT NULL,
96
+ source_refs_json TEXT NOT NULL,
97
+ candidate_message TEXT,
98
+ next_step TEXT NOT NULL,
99
+ created_at TEXT NOT NULL
100
+ );
101
+ CREATE INDEX IF NOT EXISTS operator_fallback_decision_idx ON operator_fallback_artifacts(decision_id);
102
102
  `;
103
103
  function resolveDbPath(filename) {
104
104
  if (path.isAbsolute(filename) || filename === ":memory:") {
@@ -39,6 +39,7 @@ export async function openWorkspaceOpsBridge(workspaceRoot) {
39
39
  const opsRouter = cliIndex.createOpsRouter({
40
40
  runtimeAvailable: runtimeResolved.ok,
41
41
  readModels: deps.readModels,
42
+ runtimeRecorder: deps.runtimeRecorder,
42
43
  });
43
44
  const commands = commandsMod.createCliCommands({
44
45
  readModels: deps.readModels,