@nanobpm/agentic 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +1 -0
  2. package/dist/demand/model.d.ts +7 -4
  3. package/dist/demand/model.js +22 -4
  4. package/dist/demand/taskdef.d.ts +13 -1
  5. package/dist/demand/taskdef.js +20 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/protocol/conformance/frames.js +32 -4
  9. package/dist/protocol/index.d.ts +1 -1
  10. package/dist/protocol/payloads.d.ts +44 -0
  11. package/dist/protocol/payloads.js +61 -7
  12. package/dist/session/acp/client.d.ts +109 -0
  13. package/dist/session/acp/client.js +254 -0
  14. package/dist/session/acp/index.d.ts +27 -0
  15. package/dist/session/acp/index.js +27 -0
  16. package/dist/session/acp/jsonrpc.d.ts +25 -0
  17. package/dist/session/acp/jsonrpc.js +148 -0
  18. package/dist/session/acp/normalize.d.ts +48 -0
  19. package/dist/session/acp/normalize.js +162 -0
  20. package/dist/session/acp/protocol.d.ts +94 -0
  21. package/dist/session/acp/protocol.js +136 -0
  22. package/dist/session/acp/spawn.d.ts +36 -0
  23. package/dist/session/acp/spawn.js +68 -0
  24. package/dist/session/acp/transport.d.ts +62 -0
  25. package/dist/session/acp/transport.js +126 -0
  26. package/dist/session/adapter.d.ts +135 -0
  27. package/dist/session/adapter.js +24 -0
  28. package/dist/session/backend.d.ts +43 -0
  29. package/dist/session/backend.js +95 -0
  30. package/dist/session/events.d.ts +152 -0
  31. package/dist/session/events.js +192 -0
  32. package/dist/session/index.d.ts +31 -0
  33. package/dist/session/index.js +5 -0
  34. package/dist/session/log.d.ts +107 -0
  35. package/dist/session/log.js +351 -0
  36. package/dist/session/normalizer/claude.d.ts +23 -0
  37. package/dist/session/normalizer/claude.js +138 -0
  38. package/dist/session/normalizer/copilot.d.ts +27 -0
  39. package/dist/session/normalizer/copilot.js +105 -0
  40. package/dist/session/normalizer/deepseek.d.ts +11 -0
  41. package/dist/session/normalizer/deepseek.js +68 -0
  42. package/dist/session/normalizer/index.d.ts +36 -0
  43. package/dist/session/normalizer/index.js +29 -0
  44. package/dist/session/normalizer/kimi.d.ts +10 -0
  45. package/dist/session/normalizer/kimi.js +80 -0
  46. package/dist/session/normalizer/link.d.ts +36 -0
  47. package/dist/session/normalizer/link.js +56 -0
  48. package/dist/session/normalizer/pi.d.ts +13 -0
  49. package/dist/session/normalizer/pi.js +61 -0
  50. package/dist/session/normalizer/qwen.d.ts +11 -0
  51. package/dist/session/normalizer/qwen.js +65 -0
  52. package/dist/session/normalizer/record.d.ts +21 -0
  53. package/dist/session/normalizer/record.js +87 -0
  54. package/dist/session/normalizer/types.d.ts +139 -0
  55. package/dist/session/normalizer/types.js +31 -0
  56. package/dist/session/schema.d.ts +38 -0
  57. package/dist/session/schema.js +74 -0
  58. package/package.json +17 -1
  59. package/src/demand/model.test.ts +82 -4
  60. package/src/demand/model.ts +30 -9
  61. package/src/demand/taskdef.test.ts +51 -6
  62. package/src/demand/taskdef.ts +31 -2
  63. package/src/index.ts +1 -0
  64. package/src/protocol/conformance/frames.ts +32 -4
  65. package/src/protocol/index.ts +4 -0
  66. package/src/protocol/payloads.test.ts +31 -1
  67. package/src/protocol/payloads.ts +110 -7
  68. package/src/session/acp/client.test.ts +222 -0
  69. package/src/session/acp/client.ts +356 -0
  70. package/src/session/acp/fake-agent.ts +71 -0
  71. package/src/session/acp/index.ts +68 -0
  72. package/src/session/acp/integration.test.ts +37 -0
  73. package/src/session/acp/jsonrpc.test.ts +75 -0
  74. package/src/session/acp/jsonrpc.ts +171 -0
  75. package/src/session/acp/normalize.test.ts +150 -0
  76. package/src/session/acp/normalize.ts +204 -0
  77. package/src/session/acp/protocol.ts +178 -0
  78. package/src/session/acp/spawn.test.ts +45 -0
  79. package/src/session/acp/spawn.ts +91 -0
  80. package/src/session/acp/transport.test.ts +82 -0
  81. package/src/session/acp/transport.ts +155 -0
  82. package/src/session/adapter.ts +159 -0
  83. package/src/session/backend.test.ts +198 -0
  84. package/src/session/backend.ts +128 -0
  85. package/src/session/events.test.ts +168 -0
  86. package/src/session/events.ts +347 -0
  87. package/src/session/index.ts +67 -0
  88. package/src/session/log.test.ts +215 -0
  89. package/src/session/log.ts +525 -0
  90. package/src/session/normalizer/backend-integration.test.ts +103 -0
  91. package/src/session/normalizer/claude.test.ts +68 -0
  92. package/src/session/normalizer/claude.ts +136 -0
  93. package/src/session/normalizer/copilot.test.ts +59 -0
  94. package/src/session/normalizer/copilot.ts +133 -0
  95. package/src/session/normalizer/deepseek.ts +80 -0
  96. package/src/session/normalizer/index.ts +61 -0
  97. package/src/session/normalizer/kimi.ts +82 -0
  98. package/src/session/normalizer/link.test.ts +24 -0
  99. package/src/session/normalizer/link.ts +81 -0
  100. package/src/session/normalizer/pi.ts +75 -0
  101. package/src/session/normalizer/probe.test.ts +49 -0
  102. package/src/session/normalizer/qwen.test.ts +20 -0
  103. package/src/session/normalizer/qwen.ts +77 -0
  104. package/src/session/normalizer/record.test.ts +68 -0
  105. package/src/session/normalizer/record.ts +88 -0
  106. package/src/session/normalizer/resume.test.ts +25 -0
  107. package/src/session/normalizer/types.ts +152 -0
  108. package/src/session/normalizer/vectors.test.ts +180 -0
  109. package/src/session/schema.test.ts +84 -0
  110. package/src/session/schema.ts +78 -0
  111. package/src/session/test-db.ts +56 -0
  112. package/dist/blackboard/test-db.d.ts +0 -5
  113. package/dist/blackboard/test-db.js +0 -42
  114. package/dist/presence/test-db.d.ts +0 -5
  115. package/dist/presence/test-db.js +0 -42
  116. package/dist/transcript/test-db.d.ts +0 -5
  117. package/dist/transcript/test-db.js +0 -41
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The `@nanobpm/agentic/session/normalizer` contract — ADR 0062, slice 3 (the
3
+ * `stream-json`/native-transcript **fallback** ingestion backend).
4
+ *
5
+ * Slice 2 speaks ACP directly; this slice covers every harness that does *not*
6
+ * (yet) speak ACP. ADR 0062 §5 frames `stream-json` as a *transport with N
7
+ * vendor dialects*, so there is no single "stream-json backend": each harness
8
+ * gets a small **normalizer** that maps its native/streaming session output onto
9
+ * Nano's canonical {@link SessionEvent} (slice 1), plus a **resume shim** over
10
+ * that harness's native `--resume <id>` (or SDK equivalent).
11
+ *
12
+ * The three moving parts a harness normalizer exposes:
13
+ *
14
+ * - {@link HarnessNormalizer.toDrafts} — the dialect map: one native record →
15
+ * zero-or-more {@link DraftEvent}s (canonical events *minus* the causal-chain
16
+ * fields the shared {@link linkDrafts} threads in, so a per-harness dialect
17
+ * never re-implements chaining).
18
+ * - {@link HarnessNormalizer.resume} — the resume shim: given a native session
19
+ * id, the exact native invocation ({@link ResumeShim}) that restores it.
20
+ * - {@link HarnessNormalizer.capabilities} — the {@link HarnessCapabilities}
21
+ * the {@link capabilityProbe} folds into a `durable-resume` advertisement
22
+ * (slice 5's enrolment gate reads this).
23
+ *
24
+ * Nothing here interprets a harness schema as *ours*: the native shapes are
25
+ * ingestion details owned entirely by each dialect module; the union they all
26
+ * target is the stable slice-1 contract.
27
+ */
28
+ import type { SessionEvent } from "../events.ts";
29
+ /**
30
+ * Distributive `Omit` over a discriminated union: applies `Omit` to *each* union
31
+ * member, preserving the `type` discriminant. A plain `Omit<Union, K>` collapses
32
+ * to the members' common properties (losing the per-member fields), so we cannot
33
+ * use it to describe "a session event without its chain fields".
34
+ */
35
+ export type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
36
+ /**
37
+ * A canonical {@link SessionEvent} as a dialect first produces it — the full
38
+ * semantic payload (`type` + type-specific fields) but *without* the causal-chain
39
+ * responsibilities (`parentId`, and an optional-only `id`). The shared
40
+ * {@link linkDrafts} threads `parentId` in emission order and fills any missing
41
+ * `id`, so an individual dialect never re-implements chain bookkeeping; it just
42
+ * says "here is the event this record means". A dialect that already knows a
43
+ * stable native id (a tool-call id, a provider message id) may supply it as
44
+ * `id` to preserve correlation across a resume.
45
+ */
46
+ export type DraftEvent = DistributiveOmit<SessionEvent, "id" | "parentId"> & {
47
+ readonly id?: string;
48
+ };
49
+ /**
50
+ * The native resume invocation a harness's shim resolves for a session id. Two
51
+ * transports cover the fleet:
52
+ *
53
+ * - `cli` — a flag-driven harness: `args` are the argv tail to append to the
54
+ * harness command to resume that session (e.g. Claude's `["--resume", id]`,
55
+ * Qwen's `["-r", id]`). Nano spawns; it never parses the harness's output
56
+ * beyond the dialect map.
57
+ * - `sdk` — an in-process harness (Copilot's `copilot-sdk`, the DeepSeek live
58
+ * feed): `call` names the SDK method and `args` are its arguments (e.g.
59
+ * `resumeSession(id)`), so the host invokes it directly rather than spawning.
60
+ *
61
+ * `sessionId` echoes the id the shim resumed, so a caller that only kept the
62
+ * {@link ResumeShim} still knows which session it targets.
63
+ */
64
+ export type ResumeShim = {
65
+ readonly transport: "cli";
66
+ readonly sessionId: string;
67
+ readonly args: readonly string[];
68
+ } | {
69
+ readonly transport: "sdk";
70
+ readonly sessionId: string;
71
+ readonly call: string;
72
+ readonly args: readonly unknown[];
73
+ };
74
+ /**
75
+ * What a harness can do, from the perspective of durable resume. `streaming` is
76
+ * "exposes a machine-readable streaming/native mind source we can normalize";
77
+ * `resumeById` is "can restore a *specific* prior session by id" (not merely
78
+ * `--continue` the latest). {@link CapabilityAdvertisement.durableResume} is
79
+ * derived, never declared — see {@link capabilityProbe}.
80
+ */
81
+ export interface HarnessCapabilities {
82
+ readonly streaming: boolean;
83
+ readonly resumeById: boolean;
84
+ }
85
+ /**
86
+ * The advertisement {@link capabilityProbe} produces: the raw capabilities plus
87
+ * the single **derived** `durableResume` bit slice 5's enrolment gate consumes.
88
+ * Keeping `durableResume` derived (never a hand-set field on a normalizer)
89
+ * eliminates the drift surface where a harness claims durability it can't honour.
90
+ */
91
+ export interface CapabilityAdvertisement {
92
+ readonly harness: string;
93
+ readonly streaming: boolean;
94
+ readonly resumeById: boolean;
95
+ /** `true` iff the harness both streams a mind source AND resumes by id. */
96
+ readonly durableResume: boolean;
97
+ }
98
+ /**
99
+ * One harness's fallback ingestion adapter: a dialect map, a resume shim, and its
100
+ * capabilities. Independent per harness (they fan out in parallel), and all
101
+ * target the one canonical {@link SessionEvent} union.
102
+ */
103
+ export interface HarnessNormalizer {
104
+ /** The harness id this normalizer speaks for, e.g. `"@github/copilot"`. */
105
+ readonly harness: string;
106
+ /** Raw capabilities; `durable-resume` is derived from these by {@link capabilityProbe}. */
107
+ readonly capabilities: HarnessCapabilities;
108
+ /**
109
+ * Map one native record (a parsed `stream-json` line, an SDK `SessionEvent`, a
110
+ * live-feed frame) to zero-or-more canonical {@link DraftEvent}s. Returns `[]`
111
+ * for records that carry no session-log meaning (transport keep-alives, init
112
+ * frames the canonical model does not represent). Throws
113
+ * {@link NormalizerDialectError} on a record that *should* map but is
114
+ * structurally invalid — a corrupt transcript fails loudly, it never
115
+ * fabricates an event.
116
+ */
117
+ toDrafts(record: unknown): readonly DraftEvent[];
118
+ /** Resolve the native invocation that resumes `sessionId` for this harness. */
119
+ resume(sessionId: string): ResumeShim;
120
+ }
121
+ /**
122
+ * Derive a harness's `durable-resume` advertisement from its raw capabilities.
123
+ * A harness advertises `durable-resume` **iff** it both exposes a streaming mind
124
+ * source we can normalize AND can restore a specific session by id — either half
125
+ * alone is insufficient (a stream we can't resume, or a resume with no mind to
126
+ * replay). This is the single place the bit is computed.
127
+ */
128
+ export declare function capabilityProbe(normalizer: HarnessNormalizer): CapabilityAdvertisement;
129
+ /**
130
+ * Raised when a native record that a dialect *should* map is structurally
131
+ * invalid (a missing tool-call id, a message with no content). Mirrors slice 1's
132
+ * `SessionEventShapeError` at the ingestion boundary: normalization is a trusted
133
+ * map, so a malformed native record surfaces loudly rather than silently
134
+ * dropping or fabricating a canonical event.
135
+ */
136
+ export declare class NormalizerDialectError extends Error {
137
+ readonly harness: string;
138
+ constructor(harness: string, message: string);
139
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Derive a harness's `durable-resume` advertisement from its raw capabilities.
3
+ * A harness advertises `durable-resume` **iff** it both exposes a streaming mind
4
+ * source we can normalize AND can restore a specific session by id — either half
5
+ * alone is insufficient (a stream we can't resume, or a resume with no mind to
6
+ * replay). This is the single place the bit is computed.
7
+ */
8
+ export function capabilityProbe(normalizer) {
9
+ const { streaming, resumeById } = normalizer.capabilities;
10
+ return {
11
+ harness: normalizer.harness,
12
+ streaming,
13
+ resumeById,
14
+ durableResume: streaming && resumeById,
15
+ };
16
+ }
17
+ /**
18
+ * Raised when a native record that a dialect *should* map is structurally
19
+ * invalid (a missing tool-call id, a message with no content). Mirrors slice 1's
20
+ * `SessionEventShapeError` at the ingestion boundary: normalization is a trusted
21
+ * map, so a malformed native record surfaces loudly rather than silently
22
+ * dropping or fabricating a canonical event.
23
+ */
24
+ export class NormalizerDialectError extends Error {
25
+ harness;
26
+ constructor(harness, message) {
27
+ super(`[${harness}] ${message}`);
28
+ this.name = "NormalizerDialectError";
29
+ this.harness = harness;
30
+ }
31
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The canonical authoritative session-log schema — ADR 0062, slice 1.
3
+ *
4
+ * The DDL here is the single source of truth {@link SqliteSessionLog.ensureSchema}
5
+ * applies, and is mirrored statement-for-statement by the app-boot migration
6
+ * `db/migrations/005_agentic_session.sql`. `schema.test.ts` normalises both and
7
+ * asserts they are identical — drift is a red test, not a silent boot mismatch.
8
+ * This mirrors the S6 transcript store's drift-guard exactly (ADR 0056 §12), the
9
+ * advisory precedent this authoritative log is promoted from.
10
+ *
11
+ * Three tables back the log:
12
+ * - `agentic_session_log` — one row per **activation** `(processInstanceKey,
13
+ * elementId)`: its fence high-water (`incarnation`), retention lifecycle,
14
+ * status, and the retained offset window (`first_offset` … `next_offset`).
15
+ * - `agentic_session_event` — the durable, authoritative events, keyed
16
+ * `(process_instance_key, element_id, event_offset)` so a re-lease can replay
17
+ * from any offset and a resuming incarnation can overwrite an uncommitted tail
18
+ * idempotently.
19
+ * - `agentic_session_checkpoint` — the mind/world join points, keyed
20
+ * `(process_instance_key, element_id, checkpoint_id)` with the pinned offset.
21
+ *
22
+ * `event_offset`/`checkpoint_offset` (not `offset`) is deliberate: `OFFSET` is a
23
+ * SQLite keyword, so the columns are named to avoid quoting it everywhere.
24
+ */
25
+ /** The per-activation metadata + fence table name. */
26
+ export declare const SESSION_LOG_TABLE = "agentic_session_log";
27
+ /** The durable per-event table name. */
28
+ export declare const SESSION_EVENT_TABLE = "agentic_session_event";
29
+ /** The checkpoint table name. */
30
+ export declare const SESSION_CHECKPOINT_TABLE = "agentic_session_checkpoint";
31
+ /**
32
+ * The canonical session-log DDL. Forward-only and additive; every column added
33
+ * here must also be added to the boot migration (the drift guard enforces it).
34
+ * Events are immutable once committed under an incarnation; a resume overwrites
35
+ * only the *uncommitted* tail past the last checkpoint (a re-key at the same
36
+ * `(activation, offset)`), never a committed row.
37
+ */
38
+ export declare const SESSION_SCHEMA_SQL = "CREATE TABLE IF NOT EXISTS agentic_session_log (\n process_instance_key TEXT NOT NULL,\n element_id TEXT NOT NULL,\n incarnation INTEGER NOT NULL DEFAULT 0,\n lifecycle TEXT NOT NULL DEFAULT 'activation',\n status TEXT NOT NULL DEFAULT 'open',\n created_at TEXT NOT NULL,\n completed_at TEXT,\n first_offset INTEGER,\n next_offset INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (process_instance_key, element_id)\n);\nCREATE TABLE IF NOT EXISTS agentic_session_event (\n process_instance_key TEXT NOT NULL,\n element_id TEXT NOT NULL,\n event_offset INTEGER NOT NULL,\n incarnation INTEGER NOT NULL,\n event_id TEXT NOT NULL,\n parent_id TEXT,\n event_type TEXT NOT NULL,\n payload TEXT NOT NULL,\n appended_at TEXT NOT NULL,\n PRIMARY KEY (process_instance_key, element_id, event_offset)\n);\nCREATE TABLE IF NOT EXISTS agentic_session_checkpoint (\n process_instance_key TEXT NOT NULL,\n element_id TEXT NOT NULL,\n checkpoint_id TEXT NOT NULL,\n checkpoint_offset INTEGER NOT NULL,\n incarnation INTEGER NOT NULL,\n commit_sha TEXT NOT NULL,\n effect_ledger TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (process_instance_key, element_id, checkpoint_id)\n);\nCREATE INDEX IF NOT EXISTS idx_agentic_session_checkpoint_offset ON agentic_session_checkpoint (process_instance_key, element_id, checkpoint_offset);\nCREATE INDEX IF NOT EXISTS idx_agentic_session_log_retention ON agentic_session_log (lifecycle, status, completed_at);";
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The canonical authoritative session-log schema — ADR 0062, slice 1.
3
+ *
4
+ * The DDL here is the single source of truth {@link SqliteSessionLog.ensureSchema}
5
+ * applies, and is mirrored statement-for-statement by the app-boot migration
6
+ * `db/migrations/005_agentic_session.sql`. `schema.test.ts` normalises both and
7
+ * asserts they are identical — drift is a red test, not a silent boot mismatch.
8
+ * This mirrors the S6 transcript store's drift-guard exactly (ADR 0056 §12), the
9
+ * advisory precedent this authoritative log is promoted from.
10
+ *
11
+ * Three tables back the log:
12
+ * - `agentic_session_log` — one row per **activation** `(processInstanceKey,
13
+ * elementId)`: its fence high-water (`incarnation`), retention lifecycle,
14
+ * status, and the retained offset window (`first_offset` … `next_offset`).
15
+ * - `agentic_session_event` — the durable, authoritative events, keyed
16
+ * `(process_instance_key, element_id, event_offset)` so a re-lease can replay
17
+ * from any offset and a resuming incarnation can overwrite an uncommitted tail
18
+ * idempotently.
19
+ * - `agentic_session_checkpoint` — the mind/world join points, keyed
20
+ * `(process_instance_key, element_id, checkpoint_id)` with the pinned offset.
21
+ *
22
+ * `event_offset`/`checkpoint_offset` (not `offset`) is deliberate: `OFFSET` is a
23
+ * SQLite keyword, so the columns are named to avoid quoting it everywhere.
24
+ */
25
+ /** The per-activation metadata + fence table name. */
26
+ export const SESSION_LOG_TABLE = "agentic_session_log";
27
+ /** The durable per-event table name. */
28
+ export const SESSION_EVENT_TABLE = "agentic_session_event";
29
+ /** The checkpoint table name. */
30
+ export const SESSION_CHECKPOINT_TABLE = "agentic_session_checkpoint";
31
+ /**
32
+ * The canonical session-log DDL. Forward-only and additive; every column added
33
+ * here must also be added to the boot migration (the drift guard enforces it).
34
+ * Events are immutable once committed under an incarnation; a resume overwrites
35
+ * only the *uncommitted* tail past the last checkpoint (a re-key at the same
36
+ * `(activation, offset)`), never a committed row.
37
+ */
38
+ export const SESSION_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${SESSION_LOG_TABLE} (
39
+ process_instance_key TEXT NOT NULL,
40
+ element_id TEXT NOT NULL,
41
+ incarnation INTEGER NOT NULL DEFAULT 0,
42
+ lifecycle TEXT NOT NULL DEFAULT 'activation',
43
+ status TEXT NOT NULL DEFAULT 'open',
44
+ created_at TEXT NOT NULL,
45
+ completed_at TEXT,
46
+ first_offset INTEGER,
47
+ next_offset INTEGER NOT NULL DEFAULT 0,
48
+ PRIMARY KEY (process_instance_key, element_id)
49
+ );
50
+ CREATE TABLE IF NOT EXISTS ${SESSION_EVENT_TABLE} (
51
+ process_instance_key TEXT NOT NULL,
52
+ element_id TEXT NOT NULL,
53
+ event_offset INTEGER NOT NULL,
54
+ incarnation INTEGER NOT NULL,
55
+ event_id TEXT NOT NULL,
56
+ parent_id TEXT,
57
+ event_type TEXT NOT NULL,
58
+ payload TEXT NOT NULL,
59
+ appended_at TEXT NOT NULL,
60
+ PRIMARY KEY (process_instance_key, element_id, event_offset)
61
+ );
62
+ CREATE TABLE IF NOT EXISTS ${SESSION_CHECKPOINT_TABLE} (
63
+ process_instance_key TEXT NOT NULL,
64
+ element_id TEXT NOT NULL,
65
+ checkpoint_id TEXT NOT NULL,
66
+ checkpoint_offset INTEGER NOT NULL,
67
+ incarnation INTEGER NOT NULL,
68
+ commit_sha TEXT NOT NULL,
69
+ effect_ledger TEXT NOT NULL,
70
+ created_at TEXT NOT NULL,
71
+ PRIMARY KEY (process_instance_key, element_id, checkpoint_id)
72
+ );
73
+ CREATE INDEX IF NOT EXISTS idx_${SESSION_CHECKPOINT_TABLE}_offset ON ${SESSION_CHECKPOINT_TABLE} (process_instance_key, element_id, checkpoint_offset);
74
+ CREATE INDEX IF NOT EXISTS idx_${SESSION_LOG_TABLE}_retention ON ${SESSION_LOG_TABLE} (lifecycle, status, completed_at);`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/agentic",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "The Nano agentic protocol (ADR 0056): one app-tier channel carrying agent presence/registry, demand×supply, a shared blackboard and live terminal relay — with the wire contract, channel/hub, family modules and the operator cockpit, as subpath exports.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -15,6 +15,7 @@
15
15
  "transcript",
16
16
  "blackboard",
17
17
  "cockpit",
18
+ "session",
18
19
  "conformance"
19
20
  ],
20
21
  "publishConfig": {
@@ -63,6 +64,21 @@
63
64
  "default": "./dist/relay/index.js"
64
65
  },
65
66
  "./source/relay": "./src/relay/index.ts",
67
+ "./session": {
68
+ "types": "./dist/session/index.d.ts",
69
+ "default": "./dist/session/index.js"
70
+ },
71
+ "./source/session": "./src/session/index.ts",
72
+ "./session/acp": {
73
+ "types": "./dist/session/acp/index.d.ts",
74
+ "default": "./dist/session/acp/index.js"
75
+ },
76
+ "./source/session/acp": "./src/session/acp/index.ts",
77
+ "./session/normalizer": {
78
+ "types": "./dist/session/normalizer/index.d.ts",
79
+ "default": "./dist/session/normalizer/index.js"
80
+ },
81
+ "./source/session/normalizer": "./src/session/normalizer/index.ts",
66
82
  "./transcript": {
67
83
  "types": "./dist/transcript/index.d.ts",
68
84
  "default": "./dist/transcript/index.js"
@@ -25,8 +25,8 @@ const AMBER_VOCAB: VocabDocument = {
25
25
  };
26
26
  const amberResolver = new VocabResolver(AMBER_VOCAB);
27
27
 
28
- function leaf(taskType: string, process = "p", elementId = "e"): TaskDefinitionLeaf {
29
- return { taskType, process, elementId };
28
+ function leaf(taskType: string, process = "p", elementId = "e", agentic = true): TaskDefinitionLeaf {
29
+ return { taskType, process, elementId, agentic };
30
30
  }
31
31
 
32
32
  function worker(instance: string, cognition: string, family: string, weight?: number): RegisteredWorker {
@@ -155,9 +155,9 @@ test("distinct demand: a token demanded by many elements is one entry", () => {
155
155
  assert.equal(qa.tokens[0].supply, 1);
156
156
  });
157
157
 
158
- test("non-routing-token task types are surfaced but excluded from accounting", () => {
158
+ test("non-agentic (prompt-less) task types are surfaced but excluded from accounting", () => {
159
159
  const report = computeDemandSupply({
160
- taskDefinitions: [leaf("planning.planner"), leaf("legacy:job#bad#token")],
160
+ taskDefinitions: [leaf("planning.planner"), leaf("legacy:job#bad#token", "p", "e", false)],
161
161
  workers: [worker("w", "planning", "gpt")],
162
162
  resolver,
163
163
  });
@@ -169,6 +169,84 @@ test("non-routing-token task types are surfaced but excluded from accounting", (
169
169
  assert.deepEqual(report.missing, []);
170
170
  });
171
171
 
172
+ test("a prompt-less pr.* leaf is nonAgentic (not falsely reported missing)", () => {
173
+ // `pr.retro-record` parses as a routing token, but with no prompt link it is a
174
+ // deterministic in-process worker — it must NOT show as missing agentic demand.
175
+ const report = computeDemandSupply({
176
+ taskDefinitions: [leaf("pr.retro-record", "p", "e", false)],
177
+ workers: [],
178
+ resolver,
179
+ });
180
+ assert.deepEqual(report.nonAgentic, ["pr.retro-record"]);
181
+ assert.deepEqual(report.networks, []);
182
+ assert.deepEqual(report.missing, []);
183
+ assert.equal(report.status, "green");
184
+ });
185
+
186
+ test("a prompt-bearing colon-form fleet type is agentic demand, not nonAgentic", () => {
187
+ // `senior:retro` / `senior:rebase` do not parse as routing tokens, but carry a
188
+ // prompt link → they must appear as agentic demand (bucketed), never dropped.
189
+ const report = computeDemandSupply({
190
+ taskDefinitions: [leaf("senior:retro"), leaf("senior:rebase")],
191
+ workers: [],
192
+ resolver,
193
+ });
194
+ assert.deepEqual(report.nonAgentic, []);
195
+ assert.deepEqual(
196
+ report.networks.map((n) => n.network),
197
+ ["senior"],
198
+ );
199
+ const senior = report.networks[0];
200
+ assert.deepEqual(
201
+ senior.tokens.map((t) => t.token),
202
+ ["senior:rebase", "senior:retro"],
203
+ );
204
+ assert.deepEqual(senior.missing, ["senior:rebase", "senior:retro"]);
205
+ });
206
+
207
+ // Defect-class guard: classification must follow the prompt signal, NOT the
208
+ // token string. This table pairs adversarial type strings (colon-form, dot-form,
209
+ // bare, malformed) with a hasPrompt flag and asserts agentic-ness tracks the
210
+ // flag alone — locking the class so a future naming convention can't re-invert
211
+ // the buckets.
212
+ const CLASSIFICATION_CASES: ReadonlyArray<{ type: string; hasPrompt: boolean }> = [
213
+ { type: "senior:retro", hasPrompt: true },
214
+ { type: "senior:retro", hasPrompt: false },
215
+ { type: "planning.planner", hasPrompt: true },
216
+ { type: "planning.planner", hasPrompt: false },
217
+ { type: "pr.retro-record", hasPrompt: true },
218
+ { type: "pr.retro-record", hasPrompt: false },
219
+ { type: "decide", hasPrompt: true },
220
+ { type: "decide", hasPrompt: false },
221
+ { type: "legacy:job#bad#token", hasPrompt: true },
222
+ { type: "legacy:job#bad#token", hasPrompt: false },
223
+ { type: "UPPER::weird!!", hasPrompt: true },
224
+ { type: "UPPER::weird!!", hasPrompt: false },
225
+ { type: "", hasPrompt: true },
226
+ ];
227
+
228
+ for (const { type, hasPrompt } of CLASSIFICATION_CASES) {
229
+ test(`classification follows the prompt signal, not the token: ${JSON.stringify(type)} hasPrompt=${hasPrompt}`, () => {
230
+ const report = computeDemandSupply({
231
+ taskDefinitions: [{ taskType: type, process: "p", elementId: "e", agentic: hasPrompt }],
232
+ workers: [],
233
+ resolver,
234
+ });
235
+ if (hasPrompt) {
236
+ // Prompt-bearing → agentic demand: bucketed, never dumped in nonAgentic,
237
+ // and admitted without throwing regardless of how pathological the string.
238
+ assert.deepEqual(report.nonAgentic, []);
239
+ assert.equal(report.networks.length, 1);
240
+ assert.equal(report.networks[0].tokens[0].token, type);
241
+ } else {
242
+ // Prompt-less → nonAgentic, whatever the string parses to.
243
+ assert.deepEqual(report.nonAgentic, [type]);
244
+ assert.deepEqual(report.networks, []);
245
+ assert.deepEqual(report.missing, []);
246
+ }
247
+ });
248
+ }
249
+
172
250
  test("the report is deterministic: lists sorted regardless of input order", () => {
173
251
  const a = computeDemandSupply({
174
252
  taskDefinitions: [leaf("qa.tester"), leaf("ci.runner"), leaf("planning.planner")],
@@ -72,10 +72,13 @@ export interface DemandSupplyReport {
72
72
  /** The overall SLO state: worst of the missing-agent signal and diversity. */
73
73
  readonly status: SloStatus;
74
74
  /**
75
- * Deployed `taskDefinition` types that are NOT valid routing tokens — ordinary
76
- * (non-agentic) C8 jobs the engine also runs. Surfaced (not silently dropped)
77
- * so an operator can spot a mistyped agentic token, but excluded from the
78
- * agentic demand×supply accounting.
75
+ * Deployed `taskDefinition` types that carry NO `linkName="prompt"` linked
76
+ * resource — ordinary (non-agentic) in-process C8 jobs the engine also runs
77
+ * (e.g. the deterministic `pr.*` workers). Classification is by the prompt
78
+ * signal, not the token string, so a prompt-less type is `nonAgentic` even when
79
+ * it happens to parse as a routing token. Surfaced (not silently dropped) so an
80
+ * operator can spot a mis-modelled task, but excluded from the agentic
81
+ * demand×supply accounting.
79
82
  */
80
83
  readonly nonAgentic: readonly string[];
81
84
  }
@@ -90,14 +93,23 @@ export interface DemandSupplyInput {
90
93
  readonly resolver: VocabResolver;
91
94
  }
92
95
 
93
- /** A demanded token's network-prefix bucket, or `undefined` if not a routing token. */
94
- function bucketOf(token: string): string | undefined {
96
+ /**
97
+ * A demanded token's bucket. For a valid routing token this is its network
98
+ * prefix (or a bare token's own role). For an arbitrary agentic type that is not
99
+ * a routing token (colon-form `senior:retro`, etc.) this is best-effort: the
100
+ * segment before the first `:` when present, else the whole type — a synthetic
101
+ * bucket so the leaf still surfaces as demand rather than being dropped. This is
102
+ * only ever called for prompt-bearing (agentic) leaves; non-agentic leaves are
103
+ * classified out before bucketing.
104
+ */
105
+ function bucketOf(token: string): string {
95
106
  try {
96
107
  const parsed = parseToken(token);
97
108
  // A bare (network-less) token like `decide` buckets under its own role name.
98
109
  return parsed.network ?? parsed.role;
99
110
  } catch {
100
- return undefined;
111
+ const colon = token.indexOf(":");
112
+ return colon > 0 ? token.slice(0, colon) : token;
101
113
  }
102
114
  }
103
115
 
@@ -127,15 +139,24 @@ export function computeDemandSupply(input: DemandSupplyInput): DemandSupplyRepor
127
139
  }
128
140
 
129
141
  const demandTokens = distinctTaskTypes(input.taskDefinitions);
142
+ // Agentic-ness is the prompt signal, OR-folded across every leaf of a type: a
143
+ // type is agentic demand iff at least one deployed leaf carries a
144
+ // `linkName="prompt"` linked resource. This is independent of the type string,
145
+ // so colon-form fleet types (`senior:retro`) are admitted and prompt-less
146
+ // deterministic `pr.*` tasks are excluded — regardless of token grammar.
147
+ const agenticByToken = new Map<string, boolean>();
148
+ for (const leaf of input.taskDefinitions) {
149
+ agenticByToken.set(leaf.taskType, (agenticByToken.get(leaf.taskType) ?? false) || leaf.agentic);
150
+ }
130
151
  const byNetwork = new Map<string, Map<string, TokenDemand>>();
131
152
  const nonAgentic: string[] = [];
132
153
 
133
154
  for (const token of demandTokens) {
134
- const network = bucketOf(token);
135
- if (network === undefined) {
155
+ if (!(agenticByToken.get(token) ?? false)) {
136
156
  nonAgentic.push(token);
137
157
  continue;
138
158
  }
159
+ const network = bucketOf(token);
139
160
  const instances = sortedUnique(supplyByToken.get(token) ?? []);
140
161
  const demand: TokenDemand = {
141
162
  token,
@@ -27,8 +27,8 @@ const MODEL = `<?xml version="1.0" encoding="UTF-8"?>
27
27
  test("scans taskDefinition leaves with process and element provenance", () => {
28
28
  const leaves = scanTaskDefinitions(MODEL);
29
29
  assert.deepEqual(leaves, [
30
- { taskType: "planning.planner", process: "plan-fanout", elementId: "plan" },
31
- { taskType: "qa.tester", process: "plan-fanout", elementId: "test" },
30
+ { taskType: "planning.planner", process: "plan-fanout", elementId: "plan", agentic: false },
31
+ { taskType: "qa.tester", process: "plan-fanout", elementId: "test", agentic: false },
32
32
  ]);
33
33
  });
34
34
 
@@ -69,17 +69,62 @@ test("tolerates whitespace around attribute equals signs (id / type / process id
69
69
  </bpmn:definitions>`;
70
70
  const leaves = scanTaskDefinitions(xml);
71
71
  assert.deepEqual(leaves, [
72
- { taskType: "ci.runner", process: "spaced-proc", elementId: "t" },
72
+ { taskType: "ci.runner", process: "spaced-proc", elementId: "t", agentic: false },
73
73
  ]);
74
74
  });
75
75
 
76
76
  test("distinctTaskTypes de-duplicates in first-occurrence order", () => {
77
77
  assert.deepEqual(
78
78
  distinctTaskTypes([
79
- { taskType: "b", process: "p", elementId: "1" },
80
- { taskType: "a", process: "p", elementId: "2" },
81
- { taskType: "b", process: "p", elementId: "3" },
79
+ { taskType: "b", process: "p", elementId: "1", agentic: false },
80
+ { taskType: "a", process: "p", elementId: "2", agentic: false },
81
+ { taskType: "b", process: "p", elementId: "3", agentic: false },
82
82
  ]),
83
83
  ["b", "a"],
84
84
  );
85
85
  });
86
+
87
+ test("marks a leaf agentic when it carries a linkName=\"prompt\" linked resource", () => {
88
+ const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
89
+ <bpmn:process id="nwf-retro">
90
+ <bpmn:serviceTask id="retro">
91
+ <bpmn:extensionElements>
92
+ <zeebe:taskDefinition type="senior:retro" />
93
+ <zeebe:linkedResources>
94
+ <zeebe:linkedResource resourceId="retro.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
95
+ </zeebe:linkedResources>
96
+ </bpmn:extensionElements>
97
+ </bpmn:serviceTask>
98
+ <bpmn:serviceTask id="gather">
99
+ <bpmn:extensionElements>
100
+ <zeebe:taskDefinition type="pr.retro-gather" />
101
+ </bpmn:extensionElements>
102
+ </bpmn:serviceTask>
103
+ </bpmn:process>
104
+ </bpmn:definitions>`;
105
+ const leaves = scanTaskDefinitions(xml);
106
+ assert.deepEqual(leaves, [
107
+ { taskType: "senior:retro", process: "nwf-retro", elementId: "retro", agentic: true },
108
+ { taskType: "pr.retro-gather", process: "nwf-retro", elementId: "gather", agentic: false },
109
+ ]);
110
+ });
111
+
112
+ test("prompt detection tolerates attribute ordering (linkName before resourceId)", () => {
113
+ const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
114
+ <bpmn:serviceTask id="t">
115
+ <zeebe:taskDefinition type="senior:feature" />
116
+ <zeebe:linkedResource linkName="prompt" resourceId="feature.md" />
117
+ </bpmn:serviceTask>
118
+ </bpmn:definitions>`;
119
+ assert.equal(scanTaskDefinitions(xml)[0].agentic, true);
120
+ });
121
+
122
+ test("a non-prompt linked resource (other linkName) does not mark a leaf agentic", () => {
123
+ const xml = `<bpmn:definitions xmlns:bpmn="x" xmlns:zeebe="y">
124
+ <bpmn:serviceTask id="t">
125
+ <zeebe:taskDefinition type="senior:feature" />
126
+ <zeebe:linkedResource resourceId="schema.md" linkName="schema" />
127
+ </bpmn:serviceTask>
128
+ </bpmn:definitions>`;
129
+ assert.equal(scanTaskDefinitions(xml)[0].agentic, false);
130
+ });
@@ -22,6 +22,17 @@ export interface TaskDefinitionLeaf {
22
22
  readonly process: string;
23
23
  /** The service-task element id carrying the leaf (best-effort, may be empty). */
24
24
  readonly elementId: string;
25
+ /**
26
+ * True when the service task carries a `<zeebe:linkedResource … linkName="prompt">`
27
+ * — its base-prompt side-car. This is the *structural* agentic signal (per nwf
28
+ * `SPEC.md` §"Agent job contract"): every external agent task delivers its base
29
+ * prompt through a `linkName="prompt"` linked resource, and no in-process
30
+ * (deterministic) worker task does. Agentic-ness is read from this flag, NOT
31
+ * from the `taskType` string — the deployed models use arbitrary type strings
32
+ * (colon-form `senior:retro`, dot-form, bare) that a routing-token grammar
33
+ * would mis-classify.
34
+ */
35
+ readonly agentic: boolean;
25
36
  }
26
37
 
27
38
  function attr(tag: string, name: string): string {
@@ -34,13 +45,31 @@ function processId(xml: string): string {
34
45
  return match ? match[1] : "";
35
46
  }
36
47
 
48
+ /**
49
+ * True when a service-task body carries a `linkName="prompt"` linked resource —
50
+ * the base-prompt side-car that marks a task as external agentic work.
51
+ *
52
+ * Scans every `<zeebe:linkedResource …>` in the body (tolerating attribute
53
+ * ordering and self-closing tags, consistent with {@link scanTaskDefinitions})
54
+ * and returns true as soon as one carries `linkName="prompt"`.
55
+ */
56
+ function hasPromptLink(body: string): boolean {
57
+ const linkRe = /<zeebe:linkedResource\b[^>]*?\/?>/g;
58
+ let link: RegExpExecArray | null;
59
+ while ((link = linkRe.exec(body)) !== null) {
60
+ if (attr(link[0], "linkName") === "prompt") return true;
61
+ }
62
+ return false;
63
+ }
64
+
37
65
  /**
38
66
  * Scan one BPMN document for its service-task `taskDefinition` leaves.
39
67
  *
40
68
  * Every `<bpmn:serviceTask>` carrying a `<zeebe:taskDefinition type="…">` with a
41
69
  * non-empty type yields a leaf; tasks without a task definition (or with an empty
42
70
  * type) are skipped. The scan is order-preserving and tolerant of attribute
43
- * ordering and self-closing task-definition tags.
71
+ * ordering and self-closing task-definition tags. Each leaf also records whether
72
+ * the task carries a `linkName="prompt"` linked resource (its `agentic` flag).
44
73
  */
45
74
  export function scanTaskDefinitions(xml: string): TaskDefinitionLeaf[] {
46
75
  const proc = processId(xml);
@@ -55,7 +84,7 @@ export function scanTaskDefinitions(xml: string): TaskDefinitionLeaf[] {
55
84
  if (!tdMatch) continue;
56
85
  const taskType = attr(tdMatch[0], "type");
57
86
  if (!taskType) continue;
58
- out.push({ taskType, elementId, process: proc });
87
+ out.push({ taskType, elementId, process: proc, agentic: hasPromptLink(body) });
59
88
  }
60
89
  return out;
61
90
  }
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * as presence from "./presence/index.ts";
12
12
  export * as vocab from "./vocab/index.ts";
13
13
  export * as demand from "./demand/index.ts";
14
14
  export * as relay from "./relay/index.ts";
15
+ export * as session from "./session/index.ts";
15
16
  export * as transcript from "./transcript/index.ts";
16
17
  export * as blackboard from "./blackboard/index.ts";
17
18
  export * as cockpit from "./cockpit/index.ts";