@modelprofile.com/flexharness 3.7.0 → 4.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.
package/readme.md CHANGED
@@ -32,6 +32,10 @@ interface IProjectScope {
32
32
  projectRoot: string;
33
33
  }
34
34
 
35
+ const stores = new JsonFileFlexHarnessStores({
36
+ directory: '/var/lib/my-app/model-sessions',
37
+ });
38
+
35
39
  const harness = new FlexHarness<IProjectScope>({
36
40
  scopeResolver: {
37
41
  async resolveScope(scopeId) {
@@ -70,9 +74,13 @@ const harness = new FlexHarness<IProjectScope>({
70
74
  };
71
75
  },
72
76
  },
73
- stores: new JsonFileFlexHarnessStores({
74
- directory: '/var/lib/my-app/model-sessions',
75
- }),
77
+ stores,
78
+ builtInTools: {
79
+ renameSession: true,
80
+ projectManagement: {
81
+ // task, goal, and scratchpad default to true when this block exists.
82
+ },
83
+ },
76
84
  toolOutputLimits: {
77
85
  maxDepth: 12,
78
86
  maxBytes: 256 * 1024,
@@ -148,16 +156,109 @@ The resolver accepts at most 128 descriptors per run. `resourceId` must be non-e
148
156
 
149
157
  Resource permission requests are scoped with the complete 64-character `resourceIdentity`, not the shortened tool namespace. FlexHarness rewrites `kind` to `resource.<resourceIdentity>.<providerKind>` and an optional `rememberKey` to `resource:<resourceIdentity>:<providerRememberKey>`. Harness-owned metadata contains `resourceId`, `attachmentRevision`, `resourceIdentity`, and `toolNamespace`; provider metadata is nested under `providerMetadata`, so it cannot override attachment identity.
150
158
 
151
- FlexHarness owns every acquired handle. Normal close and partial-failure cleanup run in reverse acquisition order, attempt every handle, aggregate multiple failures, and retain failed cleanup for retirement or disposal retry. Cancellation uses the same path. If model resolution fails while a resource provider is still settling, a late returned handle remains tracked and disposal waits for its closure.
159
+ FlexHarness owns every acquired handle. Normal close and partial-failure cleanup run in reverse acquisition order, attempt every handle, aggregate multiple failures, and retain failed cleanup for retirement or disposal retry. Cancellation uses the same path. If model resolution fails while a resource provider is still settling, a late returned handle remains tracked and disposal waits for its closure. Application and resource providers may not define a harness built-in name while that built-in is enabled for the current run. Disabled names are not reserved.
160
+
161
+ ## Project Management Tools
162
+
163
+ Harness-owned project tools are opt-in and session-local:
164
+
165
+ ```typescript
166
+ builtInTools: {
167
+ renameSession: true,
168
+ projectManagement: {
169
+ task: true,
170
+ goal: true,
171
+ scratchpad: true,
172
+ },
173
+ },
174
+ ```
175
+
176
+ `renameSession` enables `rename_session`. The `projectManagement` block enables the public project-management APIs and contains the model-tool flags; `task`, `goal`, and `scratchpad` each default to enabled unless explicitly set to `false`. Without that block, the public project-management APIs reject with `FlexHarnessValidationError`, while the required `stores.projectManagement` domain still participates in session cleanup. With no `builtInTools` configuration, none of these four tools is present. Constructor options are copied and frozen.
177
+
178
+ Project-management records use `FLEX_PROJECT_MANAGEMENT_SCHEMA_VERSION`, currently `1`, and form a strict live-or-tombstone union:
179
+
180
+ ```typescript
181
+ interface IFlexProjectManagementSnapshot {
182
+ schemaVersion: 1;
183
+ revision: number;
184
+ sessionGenerationId: string;
185
+ sessionGenerationSequence: number;
186
+ goal?: string;
187
+ scratchpad: string;
188
+ tasks: Array<{
189
+ id: string;
190
+ content: string;
191
+ status: 'pending' | 'in_progress' | 'completed' | 'cancelled';
192
+ priority: 'high' | 'medium' | 'low';
193
+ createdAt: string;
194
+ updatedAt: string;
195
+ }>;
196
+ }
197
+
198
+ interface IFlexProjectManagementTombstone {
199
+ schemaVersion: 1;
200
+ revision: number;
201
+ sessionGenerationId: string;
202
+ sessionGenerationSequence: number;
203
+ deletedAt: string;
204
+ }
205
+
206
+ type TFlexProjectManagementRecord =
207
+ | IFlexProjectManagementSnapshot
208
+ | IFlexProjectManagementTombstone;
209
+ ```
210
+
211
+ The tools use strict action-discriminated inputs:
212
+
213
+ - `task`: `list`, `create`, `update`, `delete`, or `clear`. Create defaults to `pending` and `medium`.
214
+ - `goal`: `get`, `set`, or `clear`.
215
+ - `scratchpad`: `get`, `set`, `append`, or `clear`. Append concatenates the supplied content exactly.
216
+ - `rename_session`: sets the active session title and returns the authoritative session.
217
+
218
+ Every project action returns the authoritative revision and state; task mutations also return the affected task, and clear returns the removed tasks. Reads never save. A set, clear, append, update, idempotent create, or empty task clear that makes no state change returns the current revision without writing. Mutations load once, apply once, validate the complete next snapshot, and issue one compare-and-swap save at `revision + 1`. FlexHarness never retries or merges an external conflict.
219
+
220
+ Tool task creation accepts an optional `id`. When omitted, FlexHarness requires the stable SmartAgent `toolCallId` and derives `task_` plus the SHA-256 of `JSON.stringify(['flexharness-project-task-v1', storageKey, sessionId, runId, toolCallId])`. Repeating an explicit or deterministic ID with identical content, status, and priority is idempotent; different creation data conflicts. Application callers must supply an explicit `id` to `createProjectTask()` because no tool-call identity exists at that boundary.
221
+
222
+ The same engine is available to applications:
223
+
224
+ ```typescript
225
+ await harness.getProjectState(scopeId, sessionId);
226
+ await harness.listProjectTasks(scopeId, sessionId);
227
+ await harness.createProjectTask(scopeId, sessionId, { id, content, status, priority });
228
+ await harness.updateProjectTask(scopeId, sessionId, { id, content, status, priority });
229
+ await harness.deleteProjectTask(scopeId, sessionId, id);
230
+ await harness.clearProjectTasks(scopeId, sessionId);
231
+ await harness.getProjectGoal(scopeId, sessionId);
232
+ await harness.setProjectGoal(scopeId, sessionId, goal);
233
+ await harness.clearProjectGoal(scopeId, sessionId);
234
+ await harness.getProjectScratchpad(scopeId, sessionId);
235
+ await harness.setProjectScratchpad(scopeId, sessionId, content);
236
+ await harness.appendProjectScratchpad(scopeId, sessionId, content);
237
+ await harness.clearProjectScratchpad(scopeId, sessionId);
238
+ ```
239
+
240
+ Public writes use `{ actor: 'application' }`. Tool writes use `{ actor: 'agent', runId, toolCallId, agent? }`, allowing custom stores to preserve attribution. Project side effects commit independently of the later model outcome and are intentionally outside transcript undo/redo.
241
+
242
+ `FLEX_PROJECT_MANAGEMENT_LIMITS` exports the hard UTF-8 and aggregate limits: goal 8 KiB, scratchpad 64 KiB, task content 8 KiB, task ID 512 bytes, title 2048 bytes, 512 tasks, and a 96 KiB serialized snapshot. Loaded snapshots reject extra fields, duplicate IDs, invalid status/priority/timestamps, non-JSON data, wrong schema/revision, and every exceeded bound before use.
243
+
244
+ `IFlexProjectManagementStore` is exact per `(storageKey, sessionId)`: `load`, CAS `save`, CAS `tombstoneSession`, and `purgeNamespace` must not collapse multiple sessions or storage namespaces. `load()` returns `TFlexProjectManagementRecord | undefined`. Same-generation live saves use normal revision CAS, and a same-generation tombstone permanently rejects later live saves. A higher `sessionGenerationSequence` with a different strong `sessionGenerationId` may replace only an older tombstone using expected revision `0`; it cannot replace a live record. This resets the PM revision for a recreated core session while stale saves and tombstones from older generations remain fenced. Deleting a recreated session that made no PM writes still replaces the prior-generation tombstone with a revision-1 tombstone for the new generation.
245
+
246
+ Every newly created core session exposes and persists a strong random `sessionGenerationId` plus its monotonic `sessionGenerationSequence`. A legacy scope session without those fields is assigned a deterministic bounded ID derived from its immutable `storageKey`, `sessionId`, and `createdAt`; FlexHarness persists the repaired scope snapshot before accepting work. Grouped core deletion tombstones retain both fields after live metadata is removed.
247
+
248
+ Normal Flex session cleanup always waits in-flight local project operations, then loads and CAS-tombstones `stores.projectManagement`, regardless of whether PM tools are enabled in that harness. If a concurrent same-generation save wins first, cleanup reloads and retries within a bounded attempt count; unresolved conflict or store failure retains the core Flex session cleanup tombstone for a later retry. The durable PM tombstone is not physically removed during normal session cleanup.
249
+
250
+ `purgeNamespace(storageKey)` is the explicit destructive reclamation operation and physically removes every live record and tombstone in that exact PM namespace. Applications may call it only after serializing every scope alias, preventing new admission, awaiting `retireScope()` on every harness owner, and deleting or purging the application-owned core scope namespace. `retireScope()` itself remains non-destructive and never calls `purgeNamespace()`. Purging PM first, purging only one alias, or racing a stale harness can remove the fence that makes session-generation reuse safe.
251
+
252
+ `InMemoryFlexProjectManagementStore` is the standalone in-memory implementation. `InMemoryFlexHarnessStores` and `JsonFileFlexHarnessStores` include `projectManagement` as a required bundle member. `assertFlexProjectManagementSnapshot()` validates live records, `assertFlexProjectManagementTombstone()` validates tombstones, and `assertFlexProjectManagementRecord()` validates the union. `createEmptyFlexProjectManagementSnapshot(sessionGenerationId, sessionGenerationSequence)` returns a revision-0 live state for the supplied current generation.
152
253
 
153
254
  ## Foreground Subagents
154
255
 
155
- `subagents` enables a harness-owned built-in tool named `task`. It is available only when at least one definition exists and the current session depth is below `maxSubagentDepth`. An application `toolProvider` must not return its own `task` tool when subagents are configured. The built-in is foreground-only: the parent tool call does not complete until the child prompt reaches a terminal outcome.
256
+ `subagents` enables a harness-owned built-in tool named `delegate`. It is available only when at least one definition exists and the current session depth is below `maxSubagentDepth`. An application or resource `toolProvider` must not return its own `delegate` tool while the built-in is enabled for that run. The built-in is foreground-only: the parent tool call does not complete until the child prompt reaches a terminal outcome.
156
257
 
157
258
  The model calls it with this exact input shape:
158
259
 
159
260
  ```typescript
160
- interface ITaskInput {
261
+ interface IDelegateInput {
161
262
  description: string;
162
263
  prompt: string;
163
264
  subagentType: string;
@@ -167,9 +268,9 @@ interface ITaskInput {
167
268
 
168
269
  Before creating or resuming a child, FlexHarness requests permission on the parent run with `kind: 'subagent.start'`, the parent `toolCallId`, and bounded agent/task metadata. The controller answers it through the normal permission APIs. This request has no `rememberKey`, so `always` is invalid; controllers use `once` or `reject`.
169
270
 
170
- Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` remains limited to `sessionId` and `title`. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the foreground `task` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
271
+ Each new invocation creates a durable child `IFlexSession` with immutable `parentSessionId`, origin `parentRunId`, origin `parentToolCallId`, `agent`, and `depth`. New public roots persist `depth: 0`; legacy schema-1 roots may omit it. These fields are harness-owned; public `createSession()` remains limited to `sessionId` and `title`. Child sessions reject direct `prompt()`, `startPrompt()`, `enqueuePrompt()`, and `schedulePrompt()` calls and run only through the foreground `delegate` tool. The model and tool resolver contexts receive optional immutable `parentSessionId` and `agent` values so integrations can apply agent-specific model and tool policy. Child prompts use the definition's `modelHint`, `system`, and `maxSteps`.
171
272
 
172
- The parent tool part receives `childSessionId` in a cumulative `part.updated` event as soon as the child is acquired. If child model resolution completes, a later cumulative update adds `model`; failures before model resolution leave it absent. The terminal tool part retains every value that became available. A successful task always has model identity and returns bounded JSON:
273
+ The parent tool part receives `childSessionId` in a cumulative `part.updated` event as soon as the child is acquired. If child model resolution completes, a later cumulative update adds `model`; failures before model resolution leave it absent. The terminal tool part retains every value that became available. A successful delegate call always has model identity and returns bounded JSON:
173
274
 
174
275
  ```typescript
175
276
  {
@@ -182,9 +283,9 @@ The parent tool part receives `childSessionId` in a cumulative `part.updated` ev
182
283
 
183
284
  Omitting `taskId` creates a deterministic child for the parent session, run, and tool call. Repeating that same invocation does not create another child. If the deterministic child already has messages, FlexHarness reports an uncertain prior execution and never silently reruns it. This preserves SmartAgent's durable parent tool intent as crash authority; controllers use `listUncertainToolExecutions()` and `reconcileToolExecution()` for uncertain parent calls.
184
285
 
185
- Supplying `taskId` deliberately resumes an idle, live child from a later run of the same immutable parent session and the same configured agent. It starts a new child prompt while retaining the child's original parent run and tool-call origin. A child owned by another parent or agent, a deleted child, an active child, a same-run resume, or a second acquisition of the same child within one later parent run is rejected. Parent cancellation propagates only to the exact child run started by that task call.
286
+ Supplying `taskId` deliberately resumes an idle, live child from a later run of the same immutable parent session and the same configured agent. It starts a new child prompt while retaining the child's original parent run and tool-call origin. A child owned by another parent or agent, a deleted child, an active child, a same-run resume, or a second acquisition of the same child within one later parent run is rejected. Parent cancellation propagates only to the exact child run started by that delegate call.
186
287
 
187
- Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional `maxSteps` a positive safe integer. `maxSubagentDepth` defaults to 1 and must be a positive safe integer at most 8. `maxSubagentCallsPerRun` defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid task execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start task execution and do not consume a slot. After successful semantic validation, the child ID is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting `taskId` reserves a deterministic new child ID; supplying `taskId` reserves and resumes that existing child after permission. Task descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
288
+ Limits are validated and frozen at construction: at most 32 unique definitions; names are non-empty and at most 128 UTF-8 bytes; descriptions 2048 bytes; optional model hints 512 bytes; optional system prompts 64 KiB; and optional `maxSteps` a positive safe integer. `maxSubagentDepth` defaults to 1 and must be a positive safe integer at most 8. `maxSubagentCallsPerRun` defaults to 32 and must be a positive safe integer at most 128. A call slot is consumed synchronously at the start of every schema-valid delegate execution, before semantic bounds, subagent type/depth validation, permission, or child work. Inputs rejected by the tool schema never start delegate execution and do not consume a slot. After successful semantic validation, the child ID is reserved for the rest of the parent run, including after permission rejection or later failure. Permission rejection creates no child session. Omitting `taskId` reserves a deterministic new child ID; supplying `taskId` reserves and resumes that existing child after permission. Delegate descriptions are non-empty and at most 256 UTF-8 bytes, prompts non-empty and at most 64 KiB, subagent types at most 128 bytes, and task IDs at most 512 bytes.
188
289
 
189
290
  ## Sessions And Prompts
190
291
 
@@ -253,6 +354,10 @@ if (command.type === 'prompt-admission') {
253
354
  }
254
355
  await harness.updateSession(scopeId, sessionId, { title: 'Renamed', archived: true });
255
356
  await harness.updateSession(scopeId, sessionId, { title: null, archived: false });
357
+ await harness.getProjectState(scopeId, sessionId);
358
+ await harness.createProjectTask(scopeId, sessionId, { id: 'tests', content: 'Add tests' });
359
+ await harness.setProjectGoal(scopeId, sessionId, 'Ship the next release');
360
+ await harness.appendProjectScratchpad(scopeId, sessionId, 'One durable note.');
256
361
  await harness.deleteSession(scopeId, sessionId);
257
362
  await harness.prompt(scopeId, sessionId, prompt, options);
258
363
  const queued = await harness.enqueuePrompt(scopeId, sessionId, prompt, options);
@@ -282,6 +387,8 @@ await harness.reconcileToolExecution(scopeId, sessionId, intentId, {
282
387
  resolution: 'executed',
283
388
  output: { committed: true },
284
389
  });
390
+ const reversion = await harness.getSessionReversionInfo(scopeId, sessionId);
391
+ console.log(reversion.undoAvailable, reversion.redoAvailable, reversion.groups);
285
392
  const undone = await harness.undoSession(scopeId, sessionId);
286
393
  console.log(undone.revertedRunId);
287
394
  const redone = await harness.redoSession(scopeId, sessionId);
@@ -309,7 +416,7 @@ The reservation save is the admission point. A save failure produces no start ev
309
416
 
310
417
  `listMessagePage()` returns the newest contiguous page in chronological order. `limit` must be an integer from 1 through 50 and defaults to 50. `nextCursor` is opaque, limited to 4096 UTF-8 bytes, bound to the resolved storage namespace and session, and remains stable when newer messages are appended. Mismatched and stale cursors fail validation. `getMessage()` performs an exact lookup. Transfer identifiers are limited to 512 bytes, text and reasoning parts to 96 KiB, complete messages to 480 KiB, and complete page envelopes to 512 KiB. A page may therefore contain fewer messages than requested. Oversized text is truncated and an otherwise oversized parts collection is replaced with an explicit elision marker; metadata that still cannot fit fails validation. Canonical private Agent events are unchanged.
311
418
 
312
- `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
419
+ `updateSession()` supports title replacement, explicit title clearing with `null`, and archive state through `archived`. Title-only updates remain available while prompts are queued or running, while permission is pending, and after archival. Requests containing `archived` are rejected while the session has any outstanding prompt or pending permission; a mixed title-and-archive request is rejected atomically without changing the title. Archived sessions expose `archivedAt`. Deleting a session cascades through its complete descendant subtree. One durable root-keyed tombstone group hides every newly affected live session, and the delete also joins any already-separate descendant cleanup groups without rewriting their roots. FlexHarness then cancels queued and active subtree work, emits terminal queue events, waits for admitted initialization, and purges runtime queue status while cleaning runtime and persisted domains child-first. The requested root tombstone is removed last after every domain confirms cleanup; project-management cleanup confirmation is a retained durable project tombstone rather than physical removal. A successful live `deleteSession()` call emits `session.deleted` for each session it newly tombstoned; retries of an existing tombstone and automatic load, retirement, or disposal cleanup emit no deletion events. Direct deletion of a descendant cascades only through that descendant's subtree. Cleanup authority follows the resolved storage namespace, so scope aliases share the same groups. A partial failure retains durable ownership for retry by a later `deleteSession()`, namespace load, `retireScope()`, or `dispose()` call.
313
420
 
314
421
  `abort()` returns `true` only while cancellation is still accepted. Terminal persistence is the run's commit point; once it starts, `abort()` returns `false` and the already-fixed terminal outcome completes while the session remains busy.
315
422
 
@@ -351,12 +458,14 @@ const harness = new FlexHarness({
351
458
 
352
459
  At most 128 custom commands may be registered. Every registration must be a plain object with exactly one of `template` or `handler`; names must match `[a-z][a-z0-9-]{0,63}`, be unique, and not use a reserved name. Optional descriptions must be non-empty and at most 2048 UTF-8 bytes. Templates must be non-empty and at most 768 KiB, and the expanded prompt must also fit 768 KiB. Registrations are copied and frozen during construction.
353
460
 
354
- `/undo` and `/redo`, plus `undoSession()` and `redoSession()`, move a durable transcript cursor. The direct methods return `{ revertedRunId }` and `{ restoredRunId }`; the slash forms return `{ type: 'operation', name: 'undo' | 'redo' }`. Each committed cursor move emits one `session.history.changed` event with `direction`, `runId`, and the selected session identity. A committed branch emits the same event with `direction: 'branch'` and no `runId`, so controllers should refresh the complete selected session.
461
+ `/undo` and `/redo`, plus `undoSession()` and `redoSession()`, move a durable history cursor. The direct methods return `{ revertedRunId }` and `{ restoredRunId }`; the slash forms return `{ type: 'operation', name: 'undo' | 'redo' }`. Each committed cursor move emits one `session.history.changed` event with `direction`, `runId`, and the selected session identity. A committed branch emits the same event with `direction: 'branch'` and no `runId`, so controllers should refresh the complete selected session. Capture finalization, cleanup, and metadata-only changes do not emit this event.
355
462
 
356
- A completed root-session turn defines an undo boundary. Failed and cancelled turns after it belong to that unit; failed or cancelled turns before the first completion belong to the first completed unit. Undo applies the unit's segments in reverse order and redo applies them in forward order. Without `turnReversionProvider`, only transcript and future model context move. Hidden messages disappear from `getMessages()`, message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact.
463
+ A completed root-session turn defines an operation-group boundary. Failed and cancelled turns after it belong to that group; leading failed or cancelled turns belong to the first completed group. No completed boundary means there is nothing to undo. Undo applies selected segments in reverse order and redo applies them in forward order. Without `turnReversionProvider`, only transcript and future model context move. Hidden messages disappear from `getMessages()`, message pages, exact message lookup, and future model context. Starting a new prompt, template, handler, or compaction from an undone position commits a branch: hidden messages and segments are removed durably and cannot be redone. Successful event archival also commits hidden redo history; a missing compaction or failed archive leaves it intact.
357
464
 
358
465
  Two horizons bound undo. Retention pruning removes the oldest complete visible units when `reversionLimits` is exceeded. Explicit event archival marks covered turns context-unavailable and prunes complete prefixes that can no longer be rebuilt; FlexHarness never crosses that archive horizon. Manual compaction without archival retains the original events and remains undoable. Schema-1 projection history and sessions migrated from `2.x` have no reversion segments, so historical turns are not retroactively undoable; newly written turns are tracked normally.
359
466
 
467
+ Session metadata archival through `updateSession(..., { archived: true })` only sets `archivedAt`. It does not archive Agent events, retire captures, or remove undo history.
468
+
360
469
  `executeSlashCommand()` is the authoritative parser and lookup boundary. Its result distinguishes `not-command`, `malformed`, `unknown`, completed `operation`, bounded `handler-result`, and `prompt-admission`. A prompt admission contains the normal `{ queueId, runId, completion }`; await `admission.completion` for the model result. Unknown commands are never admitted as literal prompts. Known unavailable commands and invalid arguments throw typed FlexHarness errors. Options accept `modelHint`, `system`, `maxSteps`, and `signal`; commands do not accept attachments. Aborting a template or `init` execution cancels its exact queued or started prompt without affecting another queue entry.
361
470
 
362
471
  Templates replace every `$ARGUMENTS` with untouched raw argument text. `$1` through the highest referenced positional placeholder use tokenized arguments, with the highest position receiving all remaining tokens joined by spaces. Missing positions become empty. A template with no placeholders appends non-empty raw arguments after a blank line. `/init` uses the OpenCode 1.18.15 `AGENTS.md` initialization prompt with provider-neutral active-workspace wording.
@@ -365,7 +474,9 @@ Handler context is frozen and contains only the resolved scope identity, session
365
474
 
366
475
  ### Workspace Reversion Provider
367
476
 
368
- Applications that also need workspace rollback provide all six `IFlexTurnReversionProvider` operations:
477
+ `reversionPolicy` defaults to `transcript-optional`, preserving the V1 behavior described above. Set it to `workspace-required` when transcript and workspace traversal must move together. This policy requires an `IFlexTurnReversionProviderV2` at construction.
478
+
479
+ Applications using the original protocol can continue to provide all six unchanged `IFlexTurnReversionProvider` operations:
369
480
 
370
481
  ```typescript
371
482
  import type { IFlexTurnReversionProvider } from '@modelprofile.com/flexharness';
@@ -380,17 +491,85 @@ const turnReversionProvider: IFlexTurnReversionProvider<IProjectScope> = {
380
491
  };
381
492
  ```
382
493
 
494
+ Protocol 2 adds the `protocolVersion` discriminant and a tagged finalized outcome. The prepare, apply, apply-inspection, and release contexts remain the V1 shapes:
495
+
496
+ ```typescript
497
+ import type {
498
+ IFlexTurnReversionProviderV2,
499
+ } from '@modelprofile.com/flexharness';
500
+
501
+ const turnReversionProvider: IFlexTurnReversionProviderV2<IProjectScope> = {
502
+ protocolVersion: 2,
503
+ prepare: (context) => workspaceHistory.prepare(context),
504
+ inspectCapture: (context) => workspaceHistory.inspectCapture(context),
505
+ async finalize(context) {
506
+ const capture = await workspaceHistory.finalize(context);
507
+ if (capture.changedPaths.length === 0) {
508
+ return {
509
+ disposition: 'no-change',
510
+ reference: capture.cleanupReference,
511
+ };
512
+ }
513
+ if (!capture.revertible) {
514
+ return {
515
+ disposition: 'nonrevertible',
516
+ reference: capture.cleanupReference,
517
+ reasonCode: 'git.unmerged',
518
+ affectedWorkspaces: [{ id: capture.workspaceId, label: capture.workspaceLabel }],
519
+ };
520
+ }
521
+ return {
522
+ disposition: 'revertible',
523
+ reference: capture.reference,
524
+ affectedWorkspaces: [{ id: capture.workspaceId, label: capture.workspaceLabel }],
525
+ };
526
+ },
527
+ inspectApply: (context) => workspaceHistory.inspectApply(context),
528
+ apply: (context) => workspaceHistory.apply(context),
529
+ release: (context) => workspaceHistory.release(context),
530
+ };
531
+
532
+ const harness = new FlexHarness<IProjectScope>({
533
+ // scopeResolver, modelResolver, stores, and other options...
534
+ turnReversionProvider,
535
+ reversionPolicy: 'workspace-required',
536
+ });
537
+ ```
538
+
539
+ V1 `finalize()` returns a JSON reference, and a finalized V1 `inspectCapture()` result is `{ status: 'finalized', reference }`. V2 `finalize()` returns `revertible`, `no-change`, or `nonrevertible`, and a finalized V2 inspection is `{ status: 'finalized', outcome }` with the same complete tagged outcome. Every V2 outcome carries a normalized cleanup reference. A revertible outcome also carries `affectedWorkspaces`. A no-change outcome omits that list or supplies an empty list. A nonrevertible outcome carries a stable `reasonCode` and may carry affected workspaces.
540
+
541
+ Affected workspace descriptors contain only stable, non-whitespace `id` and display `label` strings. A result accepts at most 64 unique descriptors; IDs are limited to 512 UTF-8 bytes, labels to 2048 bytes, and reason codes to 128 bytes matching `[A-Za-z0-9][A-Za-z0-9._-]*`. References use the configured JSON normalization depth and byte limit with an absolute 256 KiB cap.
542
+
543
+ Controllers query safe history metadata with one method:
544
+
545
+ ```typescript
546
+ const info = await harness.getSessionReversionInfo(scopeId, sessionId);
547
+ for (const group of info.groups) {
548
+ console.log(
549
+ group.runId,
550
+ group.kind,
551
+ group.visibility,
552
+ group.affectedWorkspaces,
553
+ group.affectedWorkspacesTruncated,
554
+ );
555
+ }
556
+ ```
557
+
558
+ The immutable result exposes `undoAvailable`, `redoAvailable`, and groups classified as `candidate`, `barrier`, or `no-change`. Group metadata contains at most 64 unique affected workspaces; `affectedWorkspacesTruncated` is `true` when additional unique descriptors were omitted. It never includes capture IDs or provider references.
559
+
383
560
  `workspaceSnapshots` is application-owned. Every context contains `scopeId`, `scope`, `storageKey`, `sessionId`, `runId`, deterministic `captureId`, and an `AbortSignal`. Apply contexts additionally contain the normalized `reference`, deterministic per-segment `operationId`, and `direction`; release contexts contain the reference.
384
561
 
385
562
  The protocol is durable and inspectable:
386
563
 
387
564
  1. FlexHarness persists a `preparing` capture intent before calling `prepare()`, before model or tool execution. The provider must establish exclusive capture ownership for that `storageKey` and retain it until `release()` succeeds.
388
- 2. `inspectCapture()` returns `missing`, `prepared`, `finalized`, or `unknown`. `finalized` includes the reference. `missing` is safe only while the durable state is still `preparing`; a missing prepared or finalizing capture, an `unknown` result, or an inspection failure fences the namespace.
389
- 3. `finalize()` closes the capture and returns its JSON-safe reference. FlexHarness may call it during normal finalization or recovery after `inspectCapture()` reports `prepared`. Failed and cancelled root turns are captured too. A root capture spans foreground subagent effects, although child transcript records remain separate.
390
- 4. Before undo or redo, FlexHarness persists an apply write-ahead record. `inspectApply()` returns `not-applied`, `applied`, or `unknown`. FlexHarness calls `apply()` only for `not-applied`; after an apply error it inspects again before deciding whether to retry or fence. Progress is persisted after each segment, and the transcript cursor moves only after the complete unit succeeds. Known zero-progress failures leave the cursor unchanged and can be retried; partial progress retains the write-ahead record and resumes after restart. Providers should treat `operationId` idempotently.
391
- 5. `release()` relinquishes the capture after branch commitment, retention or archive pruning, session deletion, or other durable removal. It must be idempotent: an unacknowledged release remains persisted and is retried before FlexHarness discards the reference.
565
+ 2. `inspectCapture()` returns `missing`, `prepared`, `finalized`, or `unknown`. A finalized V1 result includes `reference`; a finalized V2 result includes the complete tagged `outcome`. `missing` is safe only while the durable state is still `preparing`; a missing prepared or finalizing capture, an `unknown` result, or an inspection failure fences the namespace.
566
+ 3. `finalize()` closes the capture and returns its JSON-safe V1 reference or complete V2 outcome. FlexHarness may call it during normal finalization or recovery after `inspectCapture()` reports `prepared`. Failed and cancelled root turns are captured too. A root capture spans foreground subagent effects, although child transcript records remain separate.
567
+ 4. Before undo or redo, FlexHarness persists an apply write-ahead record. `inspectApply()` must report the exact durable outcome for the supplied `operationId`: `not-applied` means no effect occurred, `applied` means the complete effect occurred, and `unknown` means the provider cannot prove either result. FlexHarness calls `apply()` only for `not-applied`; after an apply error it inspects again, and an `unknown` result or inspection failure fences the namespace. Progress is persisted after each segment, and the transcript cursor moves only after the complete unit succeeds. Known zero-progress failures leave the cursor unchanged and can be retried; partial progress retains the write-ahead record and resumes after restart. Caller cancellation is honored until the first workspace segment makes progress; recovery then continues with fresh bounded maintenance signals until the unit and cursor commit. Providers must treat `operationId` idempotently.
568
+ 5. `release()` relinquishes the capture after no-change or nonrevertible V2 finalization, branch commitment, retention or archive pruning, session deletion, or other durable removal. It must be idempotent: an unacknowledged release remains persisted and is retried before FlexHarness discards the reference.
392
569
 
393
- Inspection, recovery, finalization, and release use fresh maintenance signals bounded by `agentSessionPolicy.generationLeaseCleanupTimeoutMs`, which defaults to 30 seconds. Providers must observe every supplied signal and must serialize ownership for a storage namespace. The same provider capability must remain configured whenever a capture-backed session is reopened, retired, disposed, or deleted. Capture-backed recovery and deletion fail closed without it.
570
+ Under `workspace-required`, a group is a barrier when any segment is pending, nonrevertible, or legacy transcript-only history. It is a candidate when at least one segment is revertible and none is a barrier; otherwise it is no-change. Only candidates can be traversed. No-change groups after a candidate travel with that candidate until the next candidate or barrier. Leading no-change groups remain visible. A retained barrier blocks older groups, while later candidates remain undoable. A mixed revertible/nonrevertible group is a barrier.
571
+
572
+ Inspection, recovery, finalization, and release use fresh maintenance signals bounded by `agentSessionPolicy.generationLeaseCleanupTimeoutMs`, which defaults to 30 seconds. Providers must observe every supplied signal and must serialize ownership for a storage namespace. A provider with the matching persisted `protocolVersion` must remain configured whenever a capture-backed session is reopened, retired, disposed, or deleted. Capture-backed recovery and deletion fail closed without it.
394
573
 
395
574
  Workspace reversion is generic and application-defined. It does not reverse network, database, billing, or other side effects unless the provider deliberately captures them. References are normalized with `toolOutputLimits` and have an absolute 256 KiB encoded cap.
396
575
 
@@ -510,19 +689,20 @@ Events are discriminated, sequenced, deeply immutable snapshots. Listener except
510
689
 
511
690
  ## Stores
512
691
 
513
- FlexHarness `3.x` separates persistence by trust and lifecycle domain through `IFlexHarnessStores`:
692
+ Current FlexHarness persistence is separated by trust and lifecycle domain through `IFlexHarnessStores`:
514
693
 
515
694
  - `scopes`: session metadata and deletion tombstones for a resolved storage namespace.
516
695
  - `projections`: public audit messages and hidden terminal stages per session.
517
696
  - `permissions`: remembered permission keys per session.
697
+ - `projectManagement`: generation-fenced task, goal, and scratchpad state per session.
518
698
  - `agentEvents`: canonical private SmartAgent events and archives per session.
519
699
  - `jobs`: private background execution state per session.
520
700
 
521
- `InMemoryFlexHarnessStores` implements all five domains with revision-based compare-and-swap behavior for tests and ephemeral processes. It is the default when `stores` is omitted.
701
+ `InMemoryFlexHarnessStores` implements all six required domains with revision-based compare-and-swap behavior for tests and ephemeral processes. It is the default when `stores` is omitted. Custom `IFlexHarnessStores` implementations must provide `projectManagement` even when project-management tools are disabled, because deletion cleanup always writes the generation fence.
522
702
 
523
703
  Custom Agent event and job providers may implement `releaseSession(storageKey, sessionId)` to release session-bound wrappers, handles, or caches without deleting durable data. FlexHarness calls these hooks only after the corresponding AgentSession or execution context has released runtime ownership. A failed release remains owned for a later retirement or disposal retry. `deleteSession()` remains the separate destructive operation for durable session data.
524
704
 
525
- `JsonFileFlexHarnessStores` stores the domains in separate `scopes`, `projections`, `permissions`, `events`, `archives`, and `jobs` directories. Storage and session identifiers are SHA-256 hashed for filenames. It provides:
705
+ `JsonFileFlexHarnessStores` stores the domains in separate `scopes`, `projections`, `permissions`, `projectManagement`, `events`, `archives`, and `jobs` directories. Storage and session identifiers are SHA-256 hashed for filenames. Passing the store bundle supplies lifecycle persistence but does not enable any built-in tool. It provides:
526
706
 
527
707
  - Strict domain-specific schema validation and optimistic revisions.
528
708
  - Static process-wide queues shared by all store instances for the same absolute file.
@@ -533,13 +713,15 @@ Custom Agent event and job providers may implement `releaseSession(storageKey, s
533
713
 
534
714
  After every harness using a `JsonFileFlexHarnessStores` instance has been disposed and no store operation remains active, call `await stores.dispose()` to retry and drain any file handle whose earlier close failed. A failed store disposal retains that handle so the call can be retried.
535
715
 
536
- The JSON stores are explicitly not cross-process safe. Use a custom `IFlexHarnessStores` implementation backed by a database or another cross-process CAS mechanism when several processes write the same storage namespace.
716
+ The JSON stores are explicitly not cross-process safe. When several processes can access the same storage namespace, every core `IFlexHarnessStores` domain and `stores.projectManagement` must use database-backed or equivalent cross-process CAS. Process-local CAS for the core stores or for PM alone is insufficient: session generation creation, cleanup tombstones, PM replacement, and stale-writer rejection must all retain their respective atomic preconditions across processes.
537
717
 
538
718
  Direct store operations and non-run session mutations surface conflicts as `FlexHarnessStoreConflictError` or the corresponding SmartAgent store conflict. Malformed, wrong-schema, or non-JSON snapshots are surfaced as `FlexHarnessStoreFormatError`. A write or deletion that changed its target but cannot confirm parent-directory durability surfaces `FlexHarnessStoreCommitUncertainError` with the affected path, operation, and cause. Run persistence failures cross the external error boundary and therefore become `FlexHarnessExternalError`. FlexHarness does not merge conflicts.
539
719
 
540
720
  Each domain serializes its own mutations. A successful run first persists a hidden completed projection, then finalizes the canonical Agent generation as `accepted`, then promotes the hidden projection publicly. Recovery uses the canonical generation outcome to promote an accepted stage or publish a failed/cancelled projection. Failed and cancelled generations remain auditable but never enter future model context.
541
721
 
542
- Projection stores accept schema version 1 or 2 from `load()`, but every `save()` receives the current schema-2 shape. A loaded schema-1 projection is treated as having no reversion segments, cursor, exclusions, pending operation, or pending releases; its next projection mutation upgrades it to schema 2. Custom stores must preserve strict compare-and-swap revisions across that read-upgrade-write cycle. Existing schema-1 messages remain visible but are not retroactively made undoable.
722
+ Projection stores accept schema version 1, 2, or 3 from `load()`, but every `save()` receives the current schema-3 shape. Schema 3 records explicit reversion protocol, transcript/workspace provenance, and V2 disposition. A terminal V2 segment must be conclusively `revertible`, `no-change`, or `nonrevertible`; a pending V2 capture remains owned by its capture WAL and is never treated as transcript history.
723
+
724
+ A loaded schema-1 projection has no reversion state. Schema-2 `workspaceCaptured` segments migrate as protocol-1 workspace/revertible history without discarding references; transcript-only segments migrate as protocol-1 transcript provenance. Under `workspace-required`, that legacy transcript history is a barrier. The next projection mutation writes schema 3. Custom stores must preserve strict compare-and-swap revisions across schema-1 and schema-2 read-upgrade-write cycles, including uncertain-save reconciliation.
543
725
 
544
726
  ## Migrating From 2.x
545
727
 
@@ -563,7 +745,7 @@ const stores = new JsonFileFlexHarnessStores({
563
745
  await migrateLegacyFlexHarnessSnapshot(storageKey, legacySnapshot, stores);
564
746
  ```
565
747
 
566
- `loadLegacySnapshot()` is application-owned access to the snapshot written by the `2.x` store. The migration validates the complete source and every public run before writing. A run left streaming by a process crash is deterministically repaired to the same cancelled state that the `2.x` loader produced in memory. The migration then converts private model messages into generationless canonical Agent conversation events, records terminal SmartAgent transactions for completed, failed, and cancelled public runs, and writes schema-2 projections with empty reversion state. Migrated history therefore remains visible and auditable but is not retroactively undoable; turns created after migration receive normal reversion segments and optional workspace captures. The migration preflights the scope, projection, permission, Agent event, and job destinations before any write, applies missing per-session domains first, and publishes scope discovery last. It is safe to rerun after no work, a completed prefix, or a complete migration when existing destination content is identical. It fails closed when a destination contains conflicting content or non-empty jobs. Keep the legacy snapshot until the migrated application has loaded and verified every storage namespace.
748
+ `loadLegacySnapshot()` is application-owned access to the snapshot written by the `2.x` store. The migration validates the complete source and every public run before writing. A run left streaming by a process crash is deterministically repaired to the same cancelled state that the `2.x` loader produced in memory. The migration then converts private model messages into generationless canonical Agent conversation events, records terminal SmartAgent transactions for completed, failed, and cancelled public runs, and writes schema-3 projections with empty reversion state. Migrated history therefore remains visible and auditable but is not retroactively undoable; turns created after migration receive normal reversion segments and optional workspace captures. The migration preflights the scope, projection, permission, Agent event, and job destinations before any write, applies missing per-session domains first, and publishes scope discovery last. It is safe to rerun after no work, a completed prefix, or a complete migration when existing destination content is identical. It fails closed when a destination contains conflicting content or non-empty jobs. Keep the legacy snapshot until the migrated application has loaded and verified every storage namespace.
567
749
 
568
750
  ## Shutdown
569
751
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@modelprofile.com/flexharness',
6
- version: '3.7.0',
6
+ version: '4.0.0',
7
7
  description: 'Provider-neutral model-session runtime with durable history, permissions, typed events, and pluggable local or remote tool execution.'
8
8
  }