loreli 0.0.0 → 2.0.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 (104) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +710 -97
  3. package/bin/loreli.js +89 -0
  4. package/package.json +77 -14
  5. package/packages/README.md +101 -0
  6. package/packages/action/README.md +98 -0
  7. package/packages/action/prompts/action.md +172 -0
  8. package/packages/action/src/index.js +684 -0
  9. package/packages/agent/README.md +606 -0
  10. package/packages/agent/src/backends/claude.js +387 -0
  11. package/packages/agent/src/backends/codex.js +351 -0
  12. package/packages/agent/src/backends/cursor.js +371 -0
  13. package/packages/agent/src/backends/index.js +486 -0
  14. package/packages/agent/src/base.js +138 -0
  15. package/packages/agent/src/cli.js +275 -0
  16. package/packages/agent/src/discover.js +396 -0
  17. package/packages/agent/src/factory.js +124 -0
  18. package/packages/agent/src/index.js +12 -0
  19. package/packages/agent/src/models.js +159 -0
  20. package/packages/agent/src/output.js +62 -0
  21. package/packages/agent/src/session.js +162 -0
  22. package/packages/agent/src/trace.js +186 -0
  23. package/packages/classify/README.md +136 -0
  24. package/packages/classify/prompts/blocker.md +12 -0
  25. package/packages/classify/prompts/feedback.md +14 -0
  26. package/packages/classify/prompts/pane-state.md +20 -0
  27. package/packages/classify/src/index.js +81 -0
  28. package/packages/config/README.md +898 -0
  29. package/packages/config/src/defaults.js +145 -0
  30. package/packages/config/src/index.js +223 -0
  31. package/packages/config/src/schema.js +291 -0
  32. package/packages/config/src/validate.js +160 -0
  33. package/packages/context/README.md +165 -0
  34. package/packages/context/src/index.js +198 -0
  35. package/packages/hub/README.md +338 -0
  36. package/packages/hub/src/base.js +154 -0
  37. package/packages/hub/src/github.js +1597 -0
  38. package/packages/hub/src/index.js +79 -0
  39. package/packages/hub/src/labels.js +48 -0
  40. package/packages/identity/README.md +288 -0
  41. package/packages/identity/src/index.js +620 -0
  42. package/packages/identity/src/themes/avatar.js +217 -0
  43. package/packages/identity/src/themes/digimon.js +217 -0
  44. package/packages/identity/src/themes/dragonball.js +217 -0
  45. package/packages/identity/src/themes/lotr.js +217 -0
  46. package/packages/identity/src/themes/marvel.js +217 -0
  47. package/packages/identity/src/themes/pokemon.js +217 -0
  48. package/packages/identity/src/themes/starwars.js +217 -0
  49. package/packages/identity/src/themes/transformers.js +217 -0
  50. package/packages/identity/src/themes/zelda.js +217 -0
  51. package/packages/knowledge/README.md +217 -0
  52. package/packages/knowledge/src/index.js +243 -0
  53. package/packages/log/README.md +93 -0
  54. package/packages/log/src/index.js +252 -0
  55. package/packages/marker/README.md +200 -0
  56. package/packages/marker/src/index.js +184 -0
  57. package/packages/mcp/README.md +323 -0
  58. package/packages/mcp/instructions.md +126 -0
  59. package/packages/mcp/scaffolding/.agents/skills/loreli-context/SKILL.md +89 -0
  60. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/config.yml +2 -0
  61. package/packages/mcp/scaffolding/ISSUE_TEMPLATE/loreli.yml +83 -0
  62. package/packages/mcp/scaffolding/loreli.yml +491 -0
  63. package/packages/mcp/scaffolding/mcp-configs/.codex/config.toml +4 -0
  64. package/packages/mcp/scaffolding/mcp-configs/.cursor/mcp.json +14 -0
  65. package/packages/mcp/scaffolding/mcp-configs/.mcp.json +14 -0
  66. package/packages/mcp/scaffolding/pull-request.md +23 -0
  67. package/packages/mcp/src/index.js +600 -0
  68. package/packages/mcp/src/tools/agent-context.js +44 -0
  69. package/packages/mcp/src/tools/agents.js +450 -0
  70. package/packages/mcp/src/tools/context.js +200 -0
  71. package/packages/mcp/src/tools/github.js +1163 -0
  72. package/packages/mcp/src/tools/hitl.js +162 -0
  73. package/packages/mcp/src/tools/index.js +18 -0
  74. package/packages/mcp/src/tools/refactor.js +227 -0
  75. package/packages/mcp/src/tools/repo.js +44 -0
  76. package/packages/mcp/src/tools/start.js +904 -0
  77. package/packages/mcp/src/tools/status.js +149 -0
  78. package/packages/mcp/src/tools/work.js +134 -0
  79. package/packages/orchestrator/README.md +192 -0
  80. package/packages/orchestrator/src/index.js +1492 -0
  81. package/packages/planner/README.md +251 -0
  82. package/packages/planner/prompts/plan-reviewer.md +109 -0
  83. package/packages/planner/prompts/planner.md +191 -0
  84. package/packages/planner/prompts/tiebreaker-reviewer.md +71 -0
  85. package/packages/planner/src/index.js +1381 -0
  86. package/packages/review/README.md +129 -0
  87. package/packages/review/prompts/reviewer.md +158 -0
  88. package/packages/review/src/index.js +1403 -0
  89. package/packages/risk/README.md +178 -0
  90. package/packages/risk/prompts/risk.md +272 -0
  91. package/packages/risk/src/index.js +439 -0
  92. package/packages/session/README.md +165 -0
  93. package/packages/session/src/index.js +215 -0
  94. package/packages/test-utils/README.md +96 -0
  95. package/packages/test-utils/src/index.js +354 -0
  96. package/packages/tmux/README.md +261 -0
  97. package/packages/tmux/src/index.js +501 -0
  98. package/packages/workflow/README.md +317 -0
  99. package/packages/workflow/prompts/preamble.md +14 -0
  100. package/packages/workflow/src/index.js +660 -0
  101. package/packages/workflow/src/proof-of-life.js +74 -0
  102. package/packages/workspace/README.md +143 -0
  103. package/packages/workspace/src/index.js +1127 -0
  104. package/index.js +0 -8
@@ -0,0 +1,124 @@
1
+ import { prepare, pathFor, mirror, create as createWorktree } from 'loreli/workspace';
2
+ import { vendor, pick } from 'loreli/identity';
3
+ import { resolve as resolveModel } from './models.js';
4
+ import { logger } from 'loreli/log';
5
+
6
+ const log = logger('factory');
7
+
8
+ /**
9
+ * Agent factory that centralizes the creation pipeline:
10
+ * discover → acquire identity → create cwd → select backend → instantiate.
11
+ *
12
+ * Both the orchestrator's `enlist()` and `rework()` paths were duplicating
13
+ * this sequence with subtle inconsistencies (e.g. rework used `defaultBackend()`
14
+ * instead of `forProvider()`). This class is the single source of truth.
15
+ *
16
+ * The factory **creates** agents but does not **spawn** them. Spawning
17
+ * and registration is the caller's responsibility (e.g. the orchestrator's
18
+ * `spawn()` method handles process start, map registration, and rollback).
19
+ *
20
+ * The factory composes the backend registry and identity registry — it does
21
+ * not own them. The orchestrator (or any other caller) injects them.
22
+ */
23
+ export class Factory {
24
+ /**
25
+ * @param {object} opts
26
+ * @param {import('./backends/index.js').BackendRegistry} opts.backends - Backend registry.
27
+ * @param {import('loreli/identity').Registry} opts.identities - Identity registry.
28
+ * @param {object} [opts.config] - Config instance for model resolution.
29
+ */
30
+ constructor({ backends, identities, config }) {
31
+ /** @type {import('./backends/index.js').BackendRegistry} */
32
+ this.backends = backends;
33
+
34
+ /** @type {import('loreli/identity').Registry} */
35
+ this.identities = identities;
36
+
37
+ /** @type {object|undefined} */
38
+ this.config = config;
39
+ }
40
+
41
+ /**
42
+ * Create an agent for the given provider and role.
43
+ *
44
+ * Pipeline:
45
+ * 1. Discover available backends (idempotent)
46
+ * 2. Acquire a themed identity from the identity registry
47
+ * 3. Create the agent's working directory
48
+ * 4. Select the best backend via `forProvider()`
49
+ * 5. Instantiate the backend class
50
+ *
51
+ * The returned agent is in `idle` state — call `agent.spawn()` (or
52
+ * the orchestrator's `spawn()` for registration+rollback) to start it.
53
+ *
54
+ * @param {string} provider - AI provider ('anthropic', 'openai').
55
+ * @param {string} role - Agent role ('action', 'reviewer', 'planner').
56
+ * @param {object} [opts] - Additional options.
57
+ * @param {string} [opts.theme='transformers'] - Theme for identity.
58
+ * @param {string} [opts.model='balanced'] - Model alias or exact string.
59
+ * @param {object} [opts.config] - Config instance override (falls back to factory-level config).
60
+ * @param {string} [opts.cwd] - Override working directory (default: ~/.loreli/workspaces/loreli-{name}).
61
+ * @returns {Promise<object>} The unspawned agent instance (state: idle).
62
+ */
63
+ async create(provider, role, opts = {}) {
64
+ const theme = opts.theme ?? pick('transformers');
65
+ const model = opts.model ?? 'balanced';
66
+
67
+ await this.backends.discover();
68
+
69
+ const backendName = this.backends.forProvider(provider);
70
+
71
+ // Resolve the model alias to a concrete identifier so the identity
72
+ // carries the real model name for signatures and labels. Without
73
+ // this, identities created via Factory.create() (the enlist() path)
74
+ // would have an empty model while add_agent identities had the
75
+ // resolved name — causing "unknown" in reviewer stamps.
76
+ const config = opts.config ?? this.config;
77
+ const resolved = resolveModel(model, backendName, vendor(provider), config, this.backends.models);
78
+
79
+ const identity = this.identities.acquire(theme, provider, resolved, opts.taken);
80
+
81
+ try {
82
+ // Update context.agent before prepare() and backend construction
83
+ // so the Codex -c flags and .mcp.json env vars have the real name
84
+ // instead of null. Context is mutated in-place intentionally —
85
+ // callers (e.g. enlist) pass the same reference and read it back.
86
+ if (opts.context) {
87
+ opts.context.agent = identity.name;
88
+
89
+ if (!opts.context.denied) {
90
+ opts.context.denied = config?.get?.('agents.disallowedTools') ?? [];
91
+ }
92
+ }
93
+
94
+ let cwd = opts.cwd ?? pathFor(identity.name);
95
+
96
+ // Collect scaffold descriptors from all registered backends so
97
+ // workspace.prepare() and workspace.create() write configs, hooks,
98
+ // and files generically — no backend-specific knowledge needed.
99
+ const descriptors = this.backends.scaffoldAll(opts.context);
100
+
101
+ // When we have full orchestration context (repo + home), create a
102
+ // git worktree instead of an empty directory. This gives the agent
103
+ // a fully checked-out repo with zero network overhead per agent —
104
+ // all from a shared bare mirror.
105
+ if (opts.context?.repo && opts.context?.home && !opts.cwd) {
106
+ const url = `https://github.com/${opts.context.repo}.git`;
107
+ const bare = await mirror(url, { home: opts.context.home, token: opts.context.token });
108
+ cwd = await createWorktree(bare, 'HEAD', identity.name, undefined, opts.context, descriptors);
109
+ log.info(`worktree ready at ${cwd} from mirror of ${opts.context.repo}`);
110
+ } else {
111
+ await prepare(cwd, opts.context, descriptors);
112
+ }
113
+
114
+ const agentOpts = { identity, role, cwd, model, config };
115
+ if (opts.context) agentOpts.context = opts.context;
116
+
117
+ log.info(`created ${identity.name} (${role}) via ${backendName}`);
118
+ return this.backends.create(backendName, agentOpts);
119
+ } catch (err) {
120
+ this.identities.release(identity);
121
+ throw err;
122
+ }
123
+ }
124
+ }
@@ -0,0 +1,12 @@
1
+ export { Agent } from './base.js';
2
+ export { CliAgent } from './cli.js';
3
+ export { Session, STATES, TRANSITIONS } from './session.js';
4
+ export { BackendRegistry } from './backends/index.js';
5
+ export { Factory } from './factory.js';
6
+ export { ClaudeBackend } from './backends/claude.js';
7
+ export { CodexBackend } from './backends/codex.js';
8
+ export { CursorBackend } from './backends/cursor.js';
9
+ export * as models from './models.js';
10
+ export * as output from './output.js';
11
+ export * as trace from './trace.js';
12
+ export { prepare } from 'loreli/workspace';
@@ -0,0 +1,159 @@
1
+ import { defaults } from 'loreli/config';
2
+ import { validate as validateModel } from './discover.js';
3
+ import { logger } from 'loreli/log';
4
+
5
+ const log = logger('models');
6
+
7
+ /**
8
+ * Resolve a model alias to a concrete model identifier.
9
+ *
10
+ * Resolution order:
11
+ * 1. Config layer: `config.get('backends.{backend}.models.{alias}.{provider}')`
12
+ * 2. Discovery layer: runtime-discovered models classified into tiers
13
+ * 3. Built-in defaults: `defaults.backends[backend].models[alias][provider]`
14
+ * 4. Pass-through: return the alias string unchanged (exact model IDs)
15
+ *
16
+ * When discovery data is available, the resolved model is validated
17
+ * against the known model list. Invalid models trigger a warning and
18
+ * fall back to the backend's default discovered model.
19
+ *
20
+ * @param {string} alias - Alias ('fast', 'balanced', 'powerful') or exact model string.
21
+ * @param {string} backend - Backend name ('claude', 'codex', 'cursor').
22
+ * @param {string} provider - AI provider ('openai' | 'anthropic').
23
+ * @param {object} [config] - Config instance with a `get()` method.
24
+ * @param {Map} [discovered] - Discovery cache from BackendRegistry.
25
+ * @returns {string} Resolved model identifier.
26
+ */
27
+ export function resolve(alias, backend, provider, config, discovered) {
28
+ const fromConfig = config?.get?.(`backends.${backend}.models.${alias}.${provider}`);
29
+ if (fromConfig) return fromConfig;
30
+
31
+ const fromDiscovery = discovered?.get(backend)?.tiers?.[alias]?.[provider];
32
+ if (fromDiscovery) return fromDiscovery;
33
+
34
+ const fallback = defaults.backends?.[backend]?.models?.[alias]?.[provider];
35
+ if (fallback) {
36
+ if (discovered && !validateModel(fallback, backend, discovered)) {
37
+ log.warn(`model "${fallback}" (${alias}/${backend}/${provider}) not found in discovered models — using backend default`);
38
+ const def = discovered.get(backend)?.models?.find(function isDefault(m) { return m.marker === 'default'; });
39
+ if (def) return def.id;
40
+ }
41
+ return fallback;
42
+ }
43
+
44
+ // Pass-through for exact model strings
45
+ return alias;
46
+ }
47
+
48
+ /**
49
+ * Env var prefixes relevant to each backend. Used to inherit
50
+ * process.env vars so launcher scripts in tmux get the right
51
+ * proxy URLs, auth tokens, and feature flags.
52
+ *
53
+ * @type {Record<string, string[]>}
54
+ */
55
+ const PREFIXES = {
56
+ claude: ['ANTHROPIC_', 'CLAUDE_'],
57
+ codex: ['OPENAI_', 'CODEX_'],
58
+ cursor: ['ANTHROPIC_', 'OPENAI_', 'CLAUDE_', 'CURSOR_']
59
+ };
60
+
61
+ /**
62
+ * Collect process.env vars matching a backend's known prefixes.
63
+ *
64
+ * @param {string} backend - Backend name.
65
+ * @returns {object} Matching env vars from process.env.
66
+ */
67
+ function inherit(backend) {
68
+ const prefixes = PREFIXES[backend] ?? [];
69
+ if (!prefixes.length) return {};
70
+
71
+ const result = {};
72
+ for (const [key, value] of Object.entries(process.env)) {
73
+ if (value && prefixes.some(function match(p) { return key.startsWith(p); })) {
74
+ result[key] = value;
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+
80
+ /**
81
+ * Retrieve backend-specific environment variables.
82
+ *
83
+ * Merges two layers:
84
+ * 1. Inherited: process.env vars matching the backend's known prefixes
85
+ * (e.g. ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
86
+ * 2. Config overrides: `backends.{backend}.env` from loreli.yml
87
+ *
88
+ * Config values take precedence over process.env when keys collide.
89
+ * Returns undefined when no vars are collected from either layer.
90
+ *
91
+ * @param {string} backend - Backend name ('claude', 'codex', 'cursor').
92
+ * @param {object} [config] - Config instance with a `get()` method.
93
+ * @returns {object|undefined} Merged environment variables, or undefined if empty.
94
+ */
95
+ export function env(backend, config) {
96
+ const inherited = inherit(backend);
97
+
98
+ const configured = config?.get?.(`backends.${backend}.env`);
99
+ const overrides = (configured && typeof configured === 'object') ? configured : {};
100
+
101
+ // Config overrides process.env
102
+ const merged = { ...inherited, ...overrides };
103
+ if (!Object.keys(merged).length) return undefined;
104
+ return merged;
105
+ }
106
+
107
+ /**
108
+ * Format an env object into shell export lines for launcher scripts.
109
+ *
110
+ * Returns an empty string when no env is configured, keeping the
111
+ * launcher script clean. Values are single-quoted to prevent expansion.
112
+ *
113
+ * @param {object|undefined} vars - Key/value environment map.
114
+ * @returns {string} Shell export statements (with trailing newline), or empty string.
115
+ */
116
+ export function format(vars) {
117
+ if (!vars || !Object.keys(vars).length) return '';
118
+ return Object.entries(vars)
119
+ .map(function line([k, v]) { return `export ${k}='${v}'`; })
120
+ .join('\n') + '\n';
121
+ }
122
+
123
+ /**
124
+ * Format an env object as a single-line shell export string.
125
+ *
126
+ * Designed for inline use with tmux commands — no newlines, exports
127
+ * joined with ` && `. Values are single-quoted to prevent expansion.
128
+ *
129
+ * @param {object|undefined} vars - Key/value environment map.
130
+ * @returns {string} Inline export string, or empty string when no vars.
131
+ */
132
+ export function inline(vars) {
133
+ if (!vars || !Object.keys(vars).length) return '';
134
+ return Object.entries(vars)
135
+ .map(function line([k, v]) { return `export ${k}='${v}'`; })
136
+ .join(' && ');
137
+ }
138
+
139
+ /**
140
+ * List all known aliases for a given backend.
141
+ *
142
+ * When a config instance is provided, reads alias keys from config's
143
+ * backend models. Otherwise falls back to built-in defaults.
144
+ *
145
+ * @param {string} backend - Backend name ('claude', 'codex', 'cursor').
146
+ * @param {object} [config] - Config instance with a `get()` method.
147
+ * @returns {string[]} Array of alias names.
148
+ */
149
+ export function list(backend, config) {
150
+ if (!backend) throw new Error('models.list() requires a backend name');
151
+
152
+ const models = config?.get?.(`backends.${backend}.models`);
153
+ if (models && typeof models === 'object') return Object.keys(models);
154
+
155
+ const fallback = defaults.backends?.[backend]?.models;
156
+ if (fallback) return Object.keys(fallback);
157
+
158
+ return [];
159
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Agent output processing utilities.
3
+ *
4
+ * Handles cleaning, truncating, and formatting captured terminal output
5
+ * from CLI-backed agents. Used by the orchestrator's relay and any other
6
+ * consumer that reads agent pane output.
7
+ */
8
+
9
+ /**
10
+ * Regex to strip ANSI escape codes from terminal output.
11
+ * Matches both CSI sequences (e.g. `\x1b[31m`) and OSC sequences
12
+ * (e.g. `\x1b]0;title\x07`).
13
+ *
14
+ * @type {RegExp}
15
+ */
16
+ export const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07/g;
17
+
18
+ /**
19
+ * Default maximum characters for truncated output.
20
+ *
21
+ * @type {number}
22
+ */
23
+ export const MAX_CHARS = 12000;
24
+
25
+ /**
26
+ * Strip ANSI escape codes from a string.
27
+ *
28
+ * @param {string} text - Raw terminal output.
29
+ * @returns {string} Clean text without ANSI codes.
30
+ */
31
+ export function strip(text) {
32
+ return text.replace(ANSI_RE, '');
33
+ }
34
+
35
+ /**
36
+ * Truncate text to a maximum length, keeping the tail.
37
+ *
38
+ * When the input exceeds `max` characters, the beginning is removed
39
+ * and a `...(truncated)` prefix is prepended. This preserves the most
40
+ * recent output which is typically the most relevant.
41
+ *
42
+ * @param {string} text - Text to truncate.
43
+ * @param {number} [max=MAX_CHARS] - Maximum character count.
44
+ * @returns {string} Truncated text, or original if under the limit.
45
+ */
46
+ export function truncate(text, max = MAX_CHARS) {
47
+ if (text.length <= max) return text;
48
+ return `...(truncated)\n${text.slice(-max)}`;
49
+ }
50
+
51
+ /**
52
+ * Clean agent output: strip ANSI codes, then truncate.
53
+ *
54
+ * Convenience function combining both operations in the standard order.
55
+ *
56
+ * @param {string} text - Raw terminal output.
57
+ * @param {number} [max=MAX_CHARS] - Maximum character count.
58
+ * @returns {string} Cleaned and truncated text.
59
+ */
60
+ export function clean(text, max = MAX_CHARS) {
61
+ return truncate(strip(text), max);
62
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Valid session states.
3
+ *
4
+ * @type {Set<string>}
5
+ */
6
+ export const STATES = new Set([
7
+ 'spawned', 'working', 'standby', 'reviewing', 'awaiting_hitl', 'dormant'
8
+ ]);
9
+
10
+ /**
11
+ * Explicit state transition map.
12
+ *
13
+ * Each key is a current state, and the value is a Set of states
14
+ * it can transition to. Transitions not in this map are rejected.
15
+ *
16
+ * Uses an explicit graph instead of linear ordering because agent
17
+ * states have branching paths (e.g. working -> awaiting_hitl vs
18
+ * working -> dormant).
19
+ *
20
+ * @type {Record<string, Set<string>>}
21
+ */
22
+ export const TRANSITIONS = {
23
+ spawned: new Set(['working', 'standby', 'dormant']),
24
+ working: new Set(['standby', 'reviewing', 'awaiting_hitl', 'dormant']),
25
+ standby: new Set(['working', 'reviewing', 'awaiting_hitl', 'dormant']),
26
+ reviewing: new Set(['working', 'standby', 'awaiting_hitl', 'dormant']),
27
+ awaiting_hitl: new Set(['working', 'standby', 'dormant']),
28
+ dormant: new Set([])
29
+ };
30
+
31
+ /**
32
+ * Tracks an active agent's runtime state.
33
+ *
34
+ * Sessions are persisted to disk so agents survive orchestrator shutdown.
35
+ * State transitions are validated against the TRANSITIONS map to catch
36
+ * bugs where agents are reactivated without proper re-initialization.
37
+ */
38
+ export class Session {
39
+ /**
40
+ * @param {object} opts
41
+ * @param {object} opts.identity - Agent identity object.
42
+ * @param {string} opts.role - Agent role ('planner' | 'action' | 'reviewer').
43
+ * @param {string} opts.backend - Backend name ('claude', 'codex', etc.).
44
+ * @param {string} [opts.paneId] - Tmux pane ID.
45
+ * @param {number} [opts.pid] - Process ID.
46
+ * @param {string} [opts.repo] - Target repository (owner/name).
47
+ */
48
+ constructor({ identity, role, backend, paneId, pid, repo }) {
49
+ /** @type {object} Agent identity. */
50
+ this.identity = identity;
51
+
52
+ /** @type {string} Agent role. */
53
+ this.role = role;
54
+
55
+ /** @type {string} Backend name. */
56
+ this.backend = backend;
57
+
58
+ /** @type {string|null} Tmux pane ID. */
59
+ this.paneId = paneId ?? null;
60
+
61
+ /** @type {number|null} Process ID. */
62
+ this.pid = pid ?? null;
63
+
64
+ /** @type {string|null} Target repository. */
65
+ this.repo = repo ?? null;
66
+
67
+ /** @type {string} Current state. */
68
+ this.state = 'spawned';
69
+
70
+ /** @type {string} When the session started. */
71
+ this.startedAt = new Date().toISOString();
72
+
73
+ /** @type {string} Last activity timestamp. */
74
+ this.lastActivity = this.startedAt;
75
+
76
+ /** @type {number|null} Claimed issue number. */
77
+ this.claimedIssue = null;
78
+
79
+ /** @type {object|null} Dynamic task context from latest dispatch. */
80
+ this.task = null;
81
+
82
+ /** @type {string[]} Human reviewers assigned during HITL. */
83
+ this.reviewers = [];
84
+
85
+ /** @type {Array<{name: string, provider: string, timestamp: string}>} Agent approval records. */
86
+ this.agentApprovals = [];
87
+
88
+ /** @type {string|null} ISO timestamp when HITL was activated. */
89
+ this.hitlAt = null;
90
+
91
+ /**
92
+ * Token usage accumulator for the current task.
93
+ * Reset when a new task is assigned via dispatch.
94
+ *
95
+ * @type {{ input: number, output: number, model: string|null }}
96
+ */
97
+ this.usage = { input: 0, output: 0, model: null };
98
+ }
99
+
100
+ /**
101
+ * Transition to a new state with validation.
102
+ *
103
+ * Validates the transition against the TRANSITIONS map. Invalid
104
+ * transitions throw an error to catch bugs where agents are moved
105
+ * to impossible states (e.g. dormant -> working without re-spawn).
106
+ *
107
+ * @param {string} next - New state (spawned | working | standby | reviewing | awaiting_hitl | dormant).
108
+ * @throws {Error} If the transition is invalid.
109
+ */
110
+ transition(next) {
111
+ if (!STATES.has(next)) {
112
+ throw new Error(`Invalid state: "${next}". Valid: ${[...STATES].join(', ')}`);
113
+ }
114
+
115
+ const allowed = TRANSITIONS[this.state];
116
+ if (!allowed?.has(next)) {
117
+ throw new Error(
118
+ `Invalid transition: "${this.state}" -> "${next}". ` +
119
+ `Allowed from "${this.state}": ${allowed ? [...allowed].join(', ') || 'none (terminal)' : 'unknown'}`
120
+ );
121
+ }
122
+
123
+ this.state = next;
124
+ this.lastActivity = new Date().toISOString();
125
+ }
126
+
127
+ /**
128
+ * Check whether a transition to the given state is valid.
129
+ *
130
+ * @param {string} next - Target state to check.
131
+ * @returns {boolean} True if the transition is allowed.
132
+ */
133
+ canTransition(next) {
134
+ return TRANSITIONS[this.state]?.has(next) ?? false;
135
+ }
136
+
137
+ /**
138
+ * Serialize the session to a plain object for JSON storage.
139
+ *
140
+ * @returns {object} Plain object representation.
141
+ */
142
+ toJSON() {
143
+ return {
144
+ identity: this.identity,
145
+ role: this.role,
146
+ backend: this.backend,
147
+ paneId: this.paneId,
148
+ pid: this.pid,
149
+ repo: this.repo,
150
+ state: this.state,
151
+ startedAt: this.startedAt,
152
+ lastActivity: this.lastActivity,
153
+ claimedIssue: this.claimedIssue,
154
+ task: this.task,
155
+ reviewers: this.reviewers,
156
+ agentApprovals: this.agentApprovals,
157
+ hitlAt: this.hitlAt,
158
+ usage: this.usage
159
+ };
160
+ }
161
+
162
+ }