@particle-academy/prism-harness 0.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +179 -0
  3. package/dist/doctor.d.ts +35 -0
  4. package/dist/doctor.js +110 -0
  5. package/dist/errors.d.ts +199 -0
  6. package/dist/errors.js +201 -0
  7. package/dist/events.d.ts +67 -0
  8. package/dist/events.js +40 -0
  9. package/dist/harness.d.ts +51 -0
  10. package/dist/harness.js +70 -0
  11. package/dist/index.d.ts +36 -0
  12. package/dist/index.js +20 -0
  13. package/dist/json.d.ts +15 -0
  14. package/dist/json.js +15 -0
  15. package/dist/modes.d.ts +63 -0
  16. package/dist/modes.js +109 -0
  17. package/dist/runtime.d.ts +104 -0
  18. package/dist/runtime.js +244 -0
  19. package/dist/session.d.ts +114 -0
  20. package/dist/session.js +198 -0
  21. package/dist/skills.d.ts +37 -0
  22. package/dist/skills.js +73 -0
  23. package/dist/store-task-source.d.ts +169 -0
  24. package/dist/store-task-source.js +513 -0
  25. package/dist/stores/file-session-store.d.ts +34 -0
  26. package/dist/stores/file-session-store.js +284 -0
  27. package/dist/stores/memory-session-store.d.ts +22 -0
  28. package/dist/stores/memory-session-store.js +97 -0
  29. package/dist/stores/session-store.d.ts +65 -0
  30. package/dist/stores/session-store.js +3 -0
  31. package/dist/stores/store-manager.d.ts +32 -0
  32. package/dist/stores/store-manager.js +57 -0
  33. package/dist/subagent-runner.d.ts +45 -0
  34. package/dist/subagent-runner.js +89 -0
  35. package/dist/subagents.d.ts +182 -0
  36. package/dist/subagents.js +286 -0
  37. package/dist/task-tools.d.ts +55 -0
  38. package/dist/task-tools.js +136 -0
  39. package/dist/tasks.d.ts +257 -0
  40. package/dist/tasks.js +162 -0
  41. package/dist/thread.d.ts +49 -0
  42. package/dist/thread.js +88 -0
  43. package/dist/tools.d.ts +89 -0
  44. package/dist/tools.js +148 -0
  45. package/package.json +34 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Particle Academy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # Prism Harness for TypeScript
2
+
3
+ Durable agent sessions — threads, session state and store drivers. The
4
+ TypeScript port of [`particle-academy/prism-harness`](https://github.com/Particle-Academy/prism-harness).
5
+
6
+ Zero runtime dependencies. Node 20+.
7
+
8
+ This package is private while coordinated parity work is in progress.
9
+
10
+ ```ts
11
+ import { FileSessionStore, MemorySessionStore, PrismHarness } from '@particle-academy/prism-harness';
12
+
13
+ const harness = new PrismHarness({
14
+ drivers: {
15
+ memory: () => new MemorySessionStore(),
16
+ files: () => new FileSessionStore('./storage/harness'),
17
+ },
18
+ stores: { ephemeral: 'memory', durable: 'files' },
19
+ });
20
+
21
+ const session = harness.for({ type: 'User', id: 7 }).session('support');
22
+
23
+ await session.usingMode('plan').then((s) => s.usingModel('claude-sonnet-4-5'));
24
+
25
+ await session.lock(async (live) => {
26
+ // whatever must not happen twice
27
+ await live.beginRun('run-1', 'plan', 'anthropic', 'claude-sonnet-4-5');
28
+ });
29
+ ```
30
+
31
+ ## Resolved, never held
32
+
33
+ A server handles a request and moves on, so a session cannot be an object kept
34
+ in memory the way a single-process agent's is. Every call rebuilds one from a
35
+ store, which is what makes a fresh worker see the same mode, model and
36
+ conversation as the request that set them.
37
+
38
+ ## The two halves
39
+
40
+ State is split into named slots, because the halves have genuinely different
41
+ requirements:
42
+
43
+ | Slot | Holds | Losing it means |
44
+ |---|---|---|
45
+ | `ephemeral` | active mode, selected model, run bookkeeping | falls back to a default |
46
+ | `durable` | threads, stored capabilities, agent task lists | work is gone |
47
+
48
+ **A store that reports itself volatile is REFUSED for the durable slot**, at the
49
+ moment a session is opened. That is the guard the package exists for: a cache is
50
+ disposable by definition, and the durable slot holds approvals a human has not
51
+ answered yet. Accepting it and finding out later is exactly the failure this
52
+ package was written to avoid.
53
+
54
+ Construct a `PrismHarness` with no drivers and it works — and then refuses
55
+ durable state, loudly, with a message that names the fix. That is deliberate. A
56
+ package that silently accepted an in-memory durable store would pass every test
57
+ on one process and lose a half-executed action the first time it ran on two.
58
+
59
+ ## The same address as PHP
60
+
61
+ `Session.key()` is byte for byte what the reference builds: `session:` plus the
62
+ sha1 of the participant type truncated to 12, the participant id, and the scope.
63
+ Matching exactly is what lets a PHP app and a TypeScript agent **share one store
64
+ and resolve the same session**. A different digest would give two conversations
65
+ that look identical and never meet.
66
+
67
+ ## Threads
68
+
69
+ `record()` assigns positions inside the store lock, reading the current length
70
+ and writing the new messages as one operation. Two turns landing concurrently
71
+ would otherwise both read position 4 and both write position 5, silently losing
72
+ a message — the race the reference tracks as prism-harness#2.
73
+
74
+ ## Agent task lists
75
+
76
+ An agent given a goal has to keep working across many requests. It needs a list
77
+ of what remains, and that list has to survive the request, the worker, a crash
78
+ and a deploy.
79
+
80
+ ```ts
81
+ const tasks = session.tasks();
82
+
83
+ await tasks.addMany(['read the issue', 'write the patch', 'run the gates']);
84
+
85
+ const task = await tasks.claim('worker-1'); // ONE atomic call
86
+
87
+ if (task !== null) {
88
+ // …do the work…
89
+ await tasks.release(task, 'worker-1', 'done');
90
+ }
91
+
92
+ await tasks.pending(); // a COUNT, which is what a loop needs to stop
93
+ ```
94
+
95
+ There is **no task model, no schema and no migration**. Two contracts and
96
+ adapters: `AgentTaskSource` is where tasks come from — `claim`, `release`,
97
+ `pending`, `find` — and `AgentTask` is one unit of work. `find()` is on the
98
+ contract because `release()` takes a task while every external caller holds
99
+ only an id: a tool call carries `{"id": "t-1"}` and an HTTP route has
100
+ `/tasks/t-1`.
101
+
102
+ `StoreTaskSource` is the default, backed by the durable slot; a consumer with an
103
+ existing table wraps their own record with `asAgentTask()` or mixes
104
+ `withAgentTask()` into their class. Both satisfy one contract.
105
+
106
+ Four states — `todo`, `claimed`, `done`, `failed` — and the transitions are
107
+ pinned across all three languages:
108
+
109
+ | | |
110
+ |---|---|
111
+ | `claim()` is one call | Read-then-mark is two operations with a window between them, and two workers arriving in that window get the same task. |
112
+ | A claim carries an owner AND an expiry | Five minutes by default. An expired claim returns the task to **`todo`**, never to `failed`: a worker dying is not the task failing, and conflating them burns a retry that never ran. |
113
+ | `claimed` is written before the work begins | So "started and died" is distinguishable from "never started". |
114
+ | `done` and `failed` are terminal | Re-releasing one is an error, not a silent no-op. |
115
+ | A worker may extend its own lease | Only while it still holds it, and bounded by the RUN. `RunLedger.exhaustion()` refuses outright — cancelled, out of steps, out of money or out of time — and `remainingSeconds()` bounds what is granted. There is no second timeout to set, or to forget to set. The new expiry is `now + granted` even when that shortens the lease: the grant is what the run can still afford. |
116
+ | A lease must be whole positive seconds | Zero or less is refused rather than clamped, and a fraction is refused rather than truncated — both are a value quietly becoming a different value, and `claimed_until` is an integer timestamp so a fractional lease could never have been honoured anyway. The check is `Number.isInteger`, which also refuses `NaN`, the infinities, and the string a JSON config would have delivered past the type annotation. |
117
+ | `release()` names the worker | Not in the spec's sketch, and added because without it a completion tool can close any task in the list — including one another worker is midway through. A release by anyone but the holder is refused, and the refusal does not say who the holder is. |
118
+
119
+ **A volatile store is refused**, the same way the durable slot is. A
120
+ half-finished task list that vanishes on a deploy is indistinguishable from a
121
+ finished one.
122
+
123
+ **An empty worker id or task id is refused, and nothing is trimmed first.**
124
+ `trim`, `strip` and `String.prototype.trim` each strip a different set of
125
+ codepoints, so trimming would have three ports disagreeing about whether the
126
+ same id is blank. A single space is a legal id. That is the `prism-human-plus`
127
+ G-36 lesson applied before it costs anything.
128
+
129
+ **An agent cannot mark its own task complete.** `release()` is the
130
+ application's call, made from evidence. A consumer who wants the agent to close
131
+ its own tasks builds `agentCompletionTool()` and authorizes it through the
132
+ existing `ToolAuthorizer` — which must be enabled, because an authorizer that is
133
+ off allows every call and would grant the authority while reading as a control.
134
+ If the model can declare its own work done, "run until the goal is met" becomes
135
+ "run until it decides it is met", and a run that has stalled ends by declaring
136
+ victory.
137
+
138
+ That tool **requires an explicit `outcome`** of `done` or `failed`. Anything
139
+ else — `"complete"`, `null`, or a missing argument — is refused rather than
140
+ guessed at, because `outcome` is the one value the model supplies and it decides
141
+ a terminal state. Guessing would let a malformed call produce the more
142
+ privileged result.
143
+
144
+ It also **checks the holder itself** rather than trusting the source to. It is
145
+ typed to the `AgentTaskSource` contract, and an interface cannot make an
146
+ implementation check anything — `release(task, worker, outcome)` reads like
147
+ "find it and set the state", which is what a third party will write. A task
148
+ whose holder cannot be established at all is refused too: `AgentTask` is three
149
+ methods, so a conforming source may expose no holder, and reading that silence
150
+ as permission is the same mistake as inferring `done` from a missing outcome.
151
+
152
+ Stopping is the existing `RunBudget` — cost, turns and wall-clock. Dependency
153
+ ordering is not here: drive [`fancy-flow`](https://github.com/Particle-Academy/fancy-flow)
154
+ for that. A task list that grows a scheduler has rebuilt a workflow engine
155
+ badly.
156
+
157
+ ## Drivers
158
+
159
+ - **`MemorySessionStore`** — volatile, and says so. Right for the ephemeral slot
160
+ in a test or a single-process tool. Its lock is real but process-local.
161
+ - **`FileSessionStore`** — durable. Atomic writes (write-then-rename) and a
162
+ cross-process lock built on exclusive file creation, which is the one
163
+ primitive that is atomic on every filesystem worth supporting. Two workers on
164
+ one machine genuinely exclude each other; two machines over a network
165
+ filesystem do not, and no file lock can promise that — use a database there.
166
+
167
+ Its **lockfile format is shared with the PHP and Python ports**, because a
168
+ worker in any of the three may be pointed at one store directory: the expiry
169
+ in milliseconds, then a newline, written in a single write. The terminator is
170
+ load-bearing. Without it there is nothing to tell a complete expiry from the
171
+ first half of one, and every prefix of a timestamp is a smaller, *older*
172
+ timestamp — so a torn write reads as a lock that expired decades ago and gets
173
+ deleted out from under the worker holding it. Anything unterminated is treated
174
+ as held, bounded by the file's own age so a process that died mid-write cannot
175
+ wedge the key either.
176
+
177
+ Implement `SessionStore` for anything else. Declare your own `durability()`:
178
+ only you know whether your Redis is persistent or a disposable cache, and that
179
+ declaration is an assertion about your infrastructure, not a preference.
@@ -0,0 +1,35 @@
1
+ import type { ModeRegistry } from './modes.js';
2
+ import type { SessionStoreManager } from './stores/store-manager.js';
3
+ import type { ToolRegistry } from './tools.js';
4
+ import type { ToolAuthorizer } from './tools.js';
5
+ export interface DoctorFinding {
6
+ check: string;
7
+ ok: boolean;
8
+ detail: string;
9
+ }
10
+ export interface DoctorReport {
11
+ findings: readonly DoctorFinding[];
12
+ problems: number;
13
+ ok: boolean;
14
+ summary(): string;
15
+ }
16
+ export interface DoctorSubjects {
17
+ modes?: ModeRegistry;
18
+ tools?: ToolRegistry;
19
+ stores?: SessionStoreManager;
20
+ authorizer?: ToolAuthorizer;
21
+ }
22
+ /**
23
+ * Check the harness configuration BEFORE a run does it for you.
24
+ *
25
+ * Every check here corresponds to a failure this package already refuses at
26
+ * runtime. Those refusals are correct and they are also LATE: a mode nobody has
27
+ * entered yet keeps its broken subagent reference until someone switches to it,
28
+ * and the first person to find out is a user mid-conversation.
29
+ *
30
+ * So this resolves EVERY mode rather than the default one, and reports what a
31
+ * run would have thrown. A CLI is left to the consumer — this returns the
32
+ * report as data, which is also what makes it testable and what lets a health
33
+ * endpoint serve it.
34
+ */
35
+ export declare function diagnose(subjects: DoctorSubjects): DoctorReport;
package/dist/doctor.js ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Check the harness configuration BEFORE a run does it for you.
3
+ *
4
+ * Every check here corresponds to a failure this package already refuses at
5
+ * runtime. Those refusals are correct and they are also LATE: a mode nobody has
6
+ * entered yet keeps its broken subagent reference until someone switches to it,
7
+ * and the first person to find out is a user mid-conversation.
8
+ *
9
+ * So this resolves EVERY mode rather than the default one, and reports what a
10
+ * run would have thrown. A CLI is left to the consumer — this returns the
11
+ * report as data, which is also what makes it testable and what lets a health
12
+ * endpoint serve it.
13
+ */
14
+ export function diagnose(subjects) {
15
+ const findings = [];
16
+ if (subjects.stores !== undefined) {
17
+ findings.push(checkStores(subjects.stores));
18
+ }
19
+ if (subjects.authorizer !== undefined) {
20
+ findings.push({
21
+ check: 'authorizer',
22
+ ok: true,
23
+ detail: subjects.authorizer.enabled
24
+ ? 'enabled — tools are filtered per run and per call'
25
+ : 'DISABLED — every registered tool is offered to every run',
26
+ });
27
+ }
28
+ if (subjects.modes !== undefined) {
29
+ findings.push(...checkModes(subjects.modes, subjects.tools));
30
+ }
31
+ const problems = findings.filter((finding) => !finding.ok).length;
32
+ return {
33
+ findings,
34
+ problems,
35
+ ok: problems === 0,
36
+ summary() {
37
+ const lines = findings.map((finding) => ` ${finding.ok ? 'ok ' : 'FAIL'} ${finding.check}: ${finding.detail}`);
38
+ lines.unshift(problems === 0
39
+ ? 'Harness configuration is consistent.'
40
+ : `${problems} problem(s) found in the harness configuration.`);
41
+ return lines.join('\n');
42
+ },
43
+ };
44
+ }
45
+ function checkStores(stores) {
46
+ try {
47
+ const durable = stores.durable();
48
+ stores.ephemeral();
49
+ return { check: 'stores', ok: true, detail: `durable slot is ${durable.durability()}` };
50
+ }
51
+ catch (error) {
52
+ return { check: 'stores', ok: false, detail: message(error) };
53
+ }
54
+ }
55
+ /**
56
+ * Resolve EVERY mode, and check the tools each one names.
57
+ *
58
+ * A mode listing a tool the registry cannot produce fails only when a run
59
+ * reaches for it — which is to say, in front of whoever is talking to the
60
+ * agent. Checked here against the registry's static names; a tool that only a
61
+ * provider can supply needs a session, so its absence is reported as unknown
62
+ * rather than missing.
63
+ */
64
+ function checkModes(modes, tools) {
65
+ const findings = [];
66
+ const names = modes.names();
67
+ if (names.length === 0) {
68
+ return [{ check: 'modes', ok: false, detail: 'no modes are configured' }];
69
+ }
70
+ const known = new Set(tools?.names() ?? []);
71
+ for (const name of names) {
72
+ try {
73
+ const mode = modes.resolve(name);
74
+ const detail = [
75
+ `${mode.tools.length} tool(s)`,
76
+ `${mode.maxSteps} step(s)`,
77
+ `${Object.keys(mode.subagents).length} subagent(s)`,
78
+ ];
79
+ if (mode.requiresApproval.length > 0) {
80
+ detail.push(`approval: ${mode.requiresApproval.join(', ')}`);
81
+ }
82
+ const missing = tools === undefined || mode.tools.includes('*')
83
+ ? []
84
+ : mode.tools.filter((tool) => !known.has(tool));
85
+ findings.push(missing.length > 0
86
+ ? {
87
+ check: `mode:${name}`,
88
+ ok: false,
89
+ detail: `names ${missing.length} tool(s) the registry cannot produce: ${missing.join(', ')}`,
90
+ }
91
+ : { check: `mode:${name}`, ok: true, detail: detail.join(', ') });
92
+ }
93
+ catch (error) {
94
+ findings.push({ check: `mode:${name}`, ok: false, detail: message(error) });
95
+ }
96
+ }
97
+ // The default has to resolve, or every session that does not name a mode
98
+ // fails — and that is the ordinary path, not an edge.
99
+ try {
100
+ modes.resolve(null);
101
+ findings.push({ check: 'default mode', ok: true, detail: modes.default() });
102
+ }
103
+ catch (error) {
104
+ findings.push({ check: 'default mode', ok: false, detail: message(error) });
105
+ }
106
+ return findings;
107
+ }
108
+ function message(error) {
109
+ return error instanceof Error ? error.message : String(error);
110
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Failures carry a stable CODE.
3
+ *
4
+ * The PHP reference identifies a failure by its exception class and an English
5
+ * sentence. A class name does not survive a port and a sentence is not a
6
+ * contract, so the code is what a consumer branches on here: treat
7
+ * `HarnessError.code` as stable and `HarnessError.message` as free to change in
8
+ * any release. Same decision, and the same reasoning, as `prism-ts`.
9
+ */
10
+ export type HarnessErrorCode =
11
+ /** A lock on a session key could not be acquired before the wait expired. */
12
+ 'session_locked'
13
+ /** A store that reports itself volatile was configured for durable state. */
14
+ | 'unsafe_state_configuration'
15
+ /** A store slot names a driver nothing is registered under. */
16
+ | 'unknown_store_driver'
17
+ /** A tool was asked for that this session cannot reach. */
18
+ | 'tool_not_available'
19
+ /** A run was refused — by budget, by depth, or by a cancelled ledger. */
20
+ | 'run_not_permitted'
21
+ /** A stored payload could not be mapped back into a value object. */
22
+ | 'unmappable_content'
23
+ /** A session was asked to do something that needs a runtime it does not have. */
24
+ | 'no_agent_runtime'
25
+ /** A mode was asked for that the application has not configured. */
26
+ | 'mode_not_configured'
27
+ /** A configured mode is malformed, or names something that does not exist. */
28
+ | 'mode_malformed'
29
+ /** A tool policy is defined but the authorizer that would consult it is off. */
30
+ | 'unsafe_authorization_configuration'
31
+ /** A tool call was refused by the call-time policy. */
32
+ | 'call_not_authorized'
33
+ /** A skill file was asked for that would resolve outside its own skill. */
34
+ | 'skill_path_refused'
35
+ /** A task was added under an id the source already holds. */
36
+ | 'duplicate_task_id'
37
+ /** A worker id or a task id was the empty string. */
38
+ | 'task_identifier_blank'
39
+ /** A lease or extension that was not a whole positive number of seconds. */
40
+ | 'task_lease_invalid'
41
+ /** A task id was named that its source does not hold. */
42
+ | 'task_not_found'
43
+ /** A task that is already `done` or `failed` was released again. */
44
+ | 'task_already_terminal'
45
+ /**
46
+ * A worker acted on a task it is not holding.
47
+ *
48
+ * ONE code for every shape of "not yours": nobody holds it, someone else
49
+ * does, or your own lease has expired. Decision 0020 would rather see those
50
+ * split, and the first draft here did split them -- but decision 0004 pins
51
+ * codes ACROSS LANGUAGES, and the Python port settled on one. A consumer
52
+ * branching on a code that exists in one port and not another is worse than a
53
+ * coarse code that means the same thing everywhere. The sentence still says
54
+ * which case it was.
55
+ */
56
+ | 'task_lease_not_held'
57
+ /**
58
+ * A completion tool was called with an outcome that is not `done` or `failed`.
59
+ *
60
+ * NOT in the set the Python port settled, and raised rather than dropped --
61
+ * see `taskOutcomeInvalid` for why coercing instead is a security defect.
62
+ */
63
+ | 'task_outcome_invalid';
64
+ export interface HarnessErrorOptions {
65
+ cause?: unknown;
66
+ }
67
+ export declare class HarnessError extends Error {
68
+ readonly code: HarnessErrorCode;
69
+ constructor(code: HarnessErrorCode, message: string, options?: HarnessErrorOptions);
70
+ static sessionLocked(key: string, waitSeconds: number): HarnessError;
71
+ /**
72
+ * The guard this package exists for.
73
+ *
74
+ * A cache is disposable by definition, and the durable slot holds pending
75
+ * tool approvals — a half-executed action waiting on a human. Losing one is a
76
+ * correctness failure, not a cache miss, so a store that reports itself
77
+ * volatile is refused here rather than accepted and discovered later.
78
+ */
79
+ static volatileDurableStore(slot: string, driver: string): HarnessError;
80
+ static unknownStoreDriver(slot: string, driver: string): HarnessError;
81
+ static toolNotAvailable(name: string, available: readonly string[]): HarnessError;
82
+ static runNotPermitted(reason: string): HarnessError;
83
+ static unmappableContent(description: string, options?: HarnessErrorOptions): HarnessError;
84
+ static modeNotConfigured(name: string): HarnessError;
85
+ static modeMalformed(name: string, detail: string): HarnessError;
86
+ /**
87
+ * Both at once is the one configuration not to leave in place.
88
+ *
89
+ * A defined policy that is never consulted looks like a control to every
90
+ * reader and is not one -- every registered tool is offered to every run
91
+ * while the code says otherwise.
92
+ */
93
+ static policyDefinedButDisabled(): HarnessError;
94
+ /**
95
+ * Thrown rather than returned as a tool result.
96
+ *
97
+ * A refusal handed back as a result reads to the model as a failure it might
98
+ * retry differently, and a denied action being retried is the opposite of
99
+ * what a guard is for.
100
+ */
101
+ static callNotAuthorized(tool: string): HarnessError;
102
+ /**
103
+ * A skill name or path that would leave the skill directory.
104
+ *
105
+ * Refused rather than sanitised. Silently rewriting a traversal to something
106
+ * safe teaches a caller — or a model — that the request was fine.
107
+ */
108
+ static skillPathRefused(detail: string): HarnessError;
109
+ /**
110
+ * A task list pointed at a store whose contents can vanish.
111
+ *
112
+ * The SAME code as `volatileDurableStore`, deliberately. It is the same
113
+ * misconfiguration reaching the same conclusion, and a consumer branching on
114
+ * "durable state was pointed somewhere it cannot live" should not have to
115
+ * know which feature noticed. Prose is free per prism-parity decision 0004;
116
+ * the code is not.
117
+ *
118
+ * A half-finished task list that vanishes on a deploy is indistinguishable
119
+ * from a finished one, which is why this refuses at construction rather than
120
+ * degrading to a default.
121
+ */
122
+ static volatileTaskSource(): HarnessError;
123
+ /**
124
+ * A lease that is not a whole positive number of seconds. REFUSED, not adjusted.
125
+ *
126
+ * Both halves were learned the hard way. Zero or less was CLAMPED to one
127
+ * second at first, because a clamp fails closed; a positive fraction was then
128
+ * still TRUNCATED. Both are a value quietly becoming a different value, and
129
+ * this repository has already shipped a configuration that did exactly that
130
+ * and stayed green throughout. A caller asking for a zero-second or
131
+ * fractional lease has a bug, and the useful thing to hand back is the bug.
132
+ *
133
+ * Takes `unknown` rather than `number` deliberately: the value that reaches
134
+ * this is whatever actually arrived, including a string out of a JSON config
135
+ * that the type annotation never saw.
136
+ */
137
+ static taskLeaseInvalid(seconds: unknown): HarnessError;
138
+ static taskNotFound(id: string): HarnessError;
139
+ static duplicateTaskId(id: string): HarnessError;
140
+ /**
141
+ * An empty identifier, compared against `""` EXACTLY.
142
+ *
143
+ * DELIBERATELY NOT TRIMMED, in any of the three languages. PHP's `trim`,
144
+ * JavaScript's `String.prototype.trim` and Python's `str.strip` each strip a
145
+ * different set of codepoints, so trimming here would produce three different
146
+ * answers for the same input -- which is precisely how `prism-human-plus`
147
+ * G-36 happened. A worker id of one space is accepted, and that is the
148
+ * correct behaviour rather than an oversight.
149
+ */
150
+ static taskIdentifierBlank(what: string): HarnessError;
151
+ /**
152
+ * An error, never a silent no-op.
153
+ *
154
+ * A second `release()` means two things believe they own the outcome of one
155
+ * task, and swallowing it leaves whichever wrote last as the answer with
156
+ * nothing recording that they disagreed.
157
+ */
158
+ static taskAlreadyTerminal(id: string, state: string): HarnessError;
159
+ /**
160
+ * "You are not holding this" -- and NOT WHO IS.
161
+ *
162
+ * The holder is deliberately left out of the sentence. This error can be
163
+ * raised inside a tool call, and a tool's failure comes back to the model as
164
+ * readable text: naming the other worker would hand an agent the identity of
165
+ * a peer it has no business knowing, from a refusal. The caller that needs to
166
+ * know who holds a task can ask the source.
167
+ *
168
+ * Covers three situations -- nobody holds it, someone else does, and your own
169
+ * lease expired -- because all three have the same answer: claim it again.
170
+ */
171
+ static taskLeaseNotHeld(id: string, worker: string, detail: string): HarnessError;
172
+ /**
173
+ * An outcome that is not `done` or `failed` was named.
174
+ *
175
+ * REFUSED, not coerced. The outcome decides a TERMINAL state; mapping
176
+ * `"complete"`, `"DONE"`, `null` or a MISSING argument onto `done` would mean
177
+ * a malformed call produces the MORE privileged result -- an agent declaring
178
+ * victory by typo. Failing closed here costs a retry with a valid value.
179
+ *
180
+ * Absent is covered by the same code deliberately. The argument for treating
181
+ * it as `done` is real -- calling a tool named `complete_task` looks like the
182
+ * declaration by itself -- and the reference settled against it.
183
+ *
184
+ * `where` names the door that refused rather than always a tool, because
185
+ * there are two: the completion tool a model can call, and
186
+ * `StoreTaskSource.release()`, which is reachable from any untyped caller.
187
+ */
188
+ static taskOutcomeInvalid(where: string, given: unknown): HarnessError;
189
+ /**
190
+ * A self-completion tool built against an authorizer that is switched off.
191
+ *
192
+ * The same code, and the same argument, as `policyDefinedButDisabled`: a
193
+ * control that is never consulted reads as a control to every reader and is
194
+ * not one. An agent closing its own tasks is exactly the authority that must
195
+ * not be granted by accident.
196
+ */
197
+ static completionToolWithoutAuthorizer(name: string): HarnessError;
198
+ static noAgentRuntime(action: string): HarnessError;
199
+ }