Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions default/skills/alice-workspace/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,34 @@ For Issue/report keys, `--workspace-id` defaults to your current Workspace.
`resumeId` is the follow-up handle; `taskId` is only execution evidence. A
missing origin is not permission to pick an arbitrary old Session.

**Ask who was responsible** — resolve the business target, then dispatch a
headless follow-up without leaving the embedded Workspace CLI:

```bash
alice-workspace conversation ask \
--target '{"kind":"issue","workspaceId":"<ws>","issueId":"<id>"}' \
--prompt 'Why did you create this issue?'
alice-workspace conversation ask \
--target '{"kind":"inbox","inboxEntryId":"<id>","workspaceId":"<ws>"}' \
--prompt 'Why did you send this?'
alice-workspace conversation ask \
--target '{"kind":"trade-decision","accountId":"<account>","decisionId":"<commit>"}' \
--prompt 'What thesis justified this decision?'
alice-workspace conversation read --task-id <taskId>
```

Inspect `resolution.mode` on the ask result:

- `exact` continues the attributable product Session;
- `reconstructed` starts a fresh worker only in the target's known Workspace,
records it against the artifact, and reuses it on later questions without
letting it impersonate the original author;
- `unavailable` means an attributed Session cannot resume, or no safe
Workspace target exists. Do not work around it by picking another old
Session. Poll `conversation read` until `status` leaves `running`; a runtime
may still return usable `assistantText` alongside error blocks, so preserve
both the status and the answer.

> **Editing a peer is interactive-only.** Reading another workspace is always OK.
> *Editing* one means reaching outside your own workspace — only do that in an
> interactive session where a person is present to approve it. An autonomous /
Expand Down
38 changes: 28 additions & 10 deletions docs/conversation-provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ type ArtifactRef =

interface ProvenanceEdge {
artifact: ArtifactRef
action: 'created' | 'updated' | 'commented' | 'sent' | 'decided'
action: 'created' | 'updated' | 'commented' | 'sent' | 'decided' | 'reconstructed'
origin: SessionOrigin | { kind: 'human' } | { kind: 'external'; system: string }
at: number
}
Expand Down Expand Up @@ -427,13 +427,29 @@ Phase 2 consumes Phase 1; it does not infer provenance independently. It owns:
- business-level CLI and UI actions for Inbox, Issues, reports, and trades;
- labeling reconstructed answers so they cannot impersonate an original author.

Existing collaboration experiments remain candidates for Phase 2. They should
be rebased onto the Phase 1 resolver/index instead of becoming a second source
of origin rules.
The embedded generic entry point is:

```bash
alice-workspace conversation ask --target '<typed JSON target>' --prompt '<question>'
alice-workspace conversation read --task-id <taskId>
```

`target.kind` supports `resume`, `workspace`, `inbox`, `issue`, `report`, and
`trade-decision`. Artifact targets always consult the Phase 1 index. The ask
result reports `resolution.mode`; read returns normalized assistant text,
compact tool/error activity, and the real runtime status. A response may carry
usable assistant text and runtime errors together; consumers preserve both
rather than rewriting failure into success.

The first fresh reconstruction appends a `reconstructed` occurrence to the
artifact. Later questions about that same otherwise-unattributed artifact
continue the reconstruction Session instead of recruiting another worker, but
the resolution mode remains `reconstructed`: continuity does not turn that
worker into the historical author.

## Phase 2 Feature Design Skeleton

All future convenience features should delegate to one provenance resolver:
All future convenience wrappers should delegate to the same shipped resolver:

```text
inbox ask <entry> -> sender Session or reconstructed Workspace Session
Expand All @@ -445,7 +461,7 @@ trade ask <order> --decision -> initiating Session
trade ask <order> --execution -> UTA/broker evidence, not an AI conversation
```

Cross-Session collaboration uses the same contract: an agent asks a known peer
Cross-Session collaboration uses one contract: an agent asks a known peer
by `resumeId`; without one, it asks the peer Workspace to create a fresh Session.
No feature should invent its own meaning of “the agent who made this.”

Expand All @@ -460,10 +476,10 @@ No feature should invent its own meaning of “the agent who made this.”
| Report | Workspace path and git repository | Revision-level creation/update attribution | Ask writer of the selected revision |
| Trade | UTA operation/order authority | Alice Session decision correlation across the UTA boundary | Ask initiator; route execution questions to UTA evidence |

Complete the Phase 1 trail/index before layering collaboration or one-click
features on individual surfaces. This document is the semantic owner; surface
code and API shapes should point back here rather than restating the rules
differently.
Phase 1 remains the only source of attribution. Business-specific one-click
features may wrap the generic conversation command, but must not reimplement
origin selection. This document is the semantic owner; surface code and API
shapes should point back here rather than restating the rules differently.

## Load-Bearing Paths

Expand All @@ -473,6 +489,8 @@ differently.
| `src/workspaces/headless-task-registry.ts` | Per-turn history and Session lineage |
| `src/workspaces/session-registry.ts` | Interactive materializations and resume indexes |
| `src/workspaces/service.ts` | Dispatch, resume, and per-Session concurrency |
| `src/workspaces/conversation-control.ts` | Provenance resolution plus exact/reconstructed headless dispatch |
| `src/tool/conversation.ts` | Embedded business-target ask/read CLI tools |
| `src/core/inbox-store.ts` | Immutable notification records and sender provenance |
| `src/server/inbox-origin.ts` | Server-side run/session attribution |
| `src/workspaces/issues/declaration.ts` | Workspace-local Issue declaration |
Expand Down
9 changes: 9 additions & 0 deletions docs/workspace-issues-and-scheduling.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ remain valid and are never renamed. `taskId` remains one execution, while
`parentTaskId` records direct turn lineage. Runs and their logs are retained
rather than silently pruned.

Internal agents use the same product handle through the embedded collaboration
path. For example, `alice-workspace conversation ask --target
'{"kind":"issue","workspaceId":"<ws>","issueId":"<id>"}' --prompt '<question>'`
queries Issue provenance first: it resumes the exact attributable Session,
reconstructs with a fresh worker only when the Workspace is known and no
Session origin exists, or returns unavailable without substituting another
agent. `alice-workspace conversation read --task-id <id>` exposes normalized
assistant text plus tool/error activity.

Scheduling never bypasses trading approval. A headless agent may research or
stage a trade, but execution remains behind UTA/Trading-as-Git permission and
human approval boundaries.
Expand Down
20 changes: 20 additions & 0 deletions src/core/provenance-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ describe('ArtifactProvenanceStore', () => {
expect(value.list()).toHaveLength(1)
})

it('persists a reconstruction Session without changing historical authorship', async () => {
const { value } = await store()
await value.append({
artifact: { kind: 'report', workspaceId: 'ws-1', path: 'legacy.md' },
action: 'reconstructed',
origin: {
kind: 'session', workspaceId: 'ws-1', resumeId: 'resume-reconstruction', agent: 'pi',
},
at: 30,
})
expect(value.latest({
artifact: { kind: 'report', workspaceId: 'ws-1', path: 'legacy.md' },
action: 'reconstructed',
})).toMatchObject({ origin: { resumeId: 'resume-reconstruction' } })
expect(value.latest({
artifact: { kind: 'report', workspaceId: 'ws-1', path: 'legacy.md' },
action: 'created',
})).toBeNull()
})

it('derives a safe Session origin without exposing native ids', () => {
expect(sessionOriginFromInboxOrigin('ws-1', {
kind: 'headless',
Expand Down
2 changes: 1 addition & 1 deletion src/core/provenance-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const artifactRefSchema = z.discriminatedUnion('kind', [
])
export type ArtifactRef = z.infer<typeof artifactRefSchema>

export const provenanceActions = ['created', 'updated', 'commented', 'sent', 'decided'] as const
export const provenanceActions = ['created', 'updated', 'commented', 'sent', 'decided', 'reconstructed'] as const
export type ProvenanceAction = (typeof provenanceActions)[number]

const originSchema = z.union([
Expand Down
91 changes: 91 additions & 0 deletions src/core/workspace-tool-center.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,99 @@ import type { Tool } from 'ai'
import type { IInboxStore, InboxEntry, InboxOrigin } from './inbox-store.js'
import type { IEntityStore } from './entity-store.js'
import type { IProvenanceStore } from './provenance-store.js'
import type { ArtifactRef, SessionOrigin } from './provenance-store.js'
// TYPE-ONLY: the global-issue-board shapes. Importing them as types keeps
// core/ free of any runtime dependency on the workspaces/ module (no
// core→workspaces coupling), while letting the board reader below be typed.
import type { IssuesSnapshot, IssueDetail, WikilinkIssueRef } from '../workspaces/issues/board.js'
import type { WorkspaceSessionDirectory } from '../workspaces/session-directory.js'
import type { HeadlessStructuredOutput } from '../workspaces/headless-output.js'
import type { HeadlessTaskStatus } from '../workspaces/headless-task-registry.js'

export type WorkspaceConversationTarget =
| { kind: 'resume'; resumeId: string }
| { kind: 'workspace'; workspaceId: string }
| { kind: 'inbox'; inboxEntryId: string; workspaceId?: string }
| {
kind: 'issue'
workspaceId: string
issueId: string
action?: 'created' | 'updated' | 'commented'
}
| {
kind: 'report'
workspaceId: string
path: string
revision?: string
action?: 'created' | 'updated' | 'sent'
}
| {
kind: 'trade-decision'
accountId: string
decisionId: string
workspaceId?: string
}

export type WorkspaceConversationResolution =
| {
mode: 'exact'
origin: SessionOrigin
artifact?: ArtifactRef
}
| {
mode: 'reconstructed'
workspaceId: string
reason: 'explicit-workspace' | 'missing-origin' | 'non-session-origin' | 'prior-reconstruction' | 'unavailable-reconstruction'
/** Present when continuing a previously recruited reconstruction worker. */
origin?: SessionOrigin
artifact?: ArtifactRef
}
| {
mode: 'unavailable'
reason: 'missing-session' | 'missing-native-session' | 'deleted-workspace' | 'missing-workspace'
attributedOrigin?: SessionOrigin
artifact?: ArtifactRef
}

export interface WorkspaceConversationTask {
readonly taskId: string
readonly resumeId: string
readonly parentTaskId?: string
readonly workspaceId: string
readonly issueId?: string
readonly agent: string
readonly status: HeadlessTaskStatus
readonly startedAt: number
readonly finishedAt?: number
readonly durationMs?: number
readonly error?: string
readonly structured: HeadlessStructuredOutput | null
}

export type WorkspaceConversationAskResult =
| {
readonly status: 'dispatched'
readonly taskId: string
readonly resumeId: string
readonly workspaceId: string
readonly workspace: string
readonly agent: string
readonly resolution: Exclude<WorkspaceConversationResolution, { mode: 'unavailable' }>
}
| {
readonly status: 'unavailable'
readonly resolution: Extract<WorkspaceConversationResolution, { mode: 'unavailable' }>
}

export interface WorkspaceConversationControl {
ask(input: {
readonly prompt: string
readonly timeoutMs: number
readonly target: WorkspaceConversationTarget
readonly agent?: string
}): Promise<WorkspaceConversationAskResult>
read(taskId: string): Promise<WorkspaceConversationTask | null>
}

// ==================== Context handed to factories ====================

Expand Down Expand Up @@ -75,6 +163,9 @@ export interface WorkspaceToolContext {
agent: string
resumable: boolean
} | null
/** Embedded provenance-aware headless conversation control; never routes
* through the public HTTP API. */
conversation?: WorkspaceConversationControl
/** Agent-INVISIBLE run provenance, resolved server-side from the
* `x-openalice-run` header by the MCP / CLI route (never supplied by the
* agent). Factories pass it through to call sites (e.g. inbox_push →
Expand Down
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { entityUpsertFactory } from './tool/entity-upsert.js'
import { entitySearchFactory } from './tool/entity-search.js'
import { issueToolFactories } from './tool/issue-tools.js'
import { provenanceShowFactory } from './tool/provenance-show.js'
import { conversationToolFactories } from './tool/conversation.js'
import { createToolCallLog } from './core/tool-call-log.js'
import { NewsCollectorStore, NewsCollector } from './domain/news/index.js'
import { createNewsArchiveTools } from './tool/news.js'
Expand Down Expand Up @@ -116,6 +117,7 @@ async function main() {
workspaceToolCenter.register(entitySearchFactory)
for (const f of issueToolFactories) workspaceToolCenter.register(f)
workspaceToolCenter.register(provenanceShowFactory)
for (const f of conversationToolFactories) workspaceToolCenter.register(f)

// ==================== UTA SDK (HTTP boundary) ====================
//
Expand Down
2 changes: 2 additions & 0 deletions src/server/cli-commands.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { entityUpsertFactory } from '../tool/entity-upsert.js'
import { entitySearchFactory } from '../tool/entity-search.js'
import { issueToolFactories } from '../tool/issue-tools.js'
import { provenanceShowFactory } from '../tool/provenance-show.js'
import { conversationToolFactories } from '../tool/conversation.js'
import { createTradingTools } from '../tool/trading.js'

/**
Expand Down Expand Up @@ -91,6 +92,7 @@ describe('CLI_EXPORTS — workspace export (scoped collaboration tools)', () =>
wtc.register(entitySearchFactory)
for (const f of issueToolFactories) wtc.register(f)
wtc.register(provenanceShowFactory)
for (const f of conversationToolFactories) wtc.register(f)
const built = wtc.build({
workspaceId: 'ws-test',
workspaceLabel: 'test',
Expand Down
4 changes: 4 additions & 0 deletions src/server/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,10 @@ export const CLI_EXPORTS: Record<string, CliExport> = {
provenance: {
show: 'provenance_show',
},
conversation: {
ask: 'conversation_ask',
read: 'conversation_read',
},
},
},
uta: {
Expand Down
2 changes: 2 additions & 0 deletions src/server/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js'
import { type CliExport, getExport, mappedToolNames } from './cli-commands.js'
import { resolveInboxOrigin } from './inbox-origin.js'
import { extractTradeDecisionRefs } from './trade-provenance.js'
import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js'

export interface CliGatewayDeps {
toolCenter: ToolCenter
Expand Down Expand Up @@ -89,6 +90,7 @@ export function registerCliRoutes(app: Hono, deps: CliGatewayDeps): void {
inboxStore,
entityStore,
...(svc ? { provenanceStore: svc.provenanceStore } : {}),
...(svc ? { conversation: createWorkspaceConversationControl(svc) } : {}),
// Lets workspace_path resolve ANY peer's dir (not just the caller) —
// the in-workspace cross-workspace addressing path. Shared with the
// mcp.ts build site so the two never drift.
Expand Down
2 changes: 2 additions & 0 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type { InboxOrigin } from '../core/inbox-store.js'
import { extractMcpShape, wrapToolExecute } from '../core/mcp-export.js'
import { registerCliRoutes } from './cli.js'
import { resolveInboxOrigin } from './inbox-origin.js'
import { createWorkspaceConversationControl } from '../workspaces/conversation-control.js'

/**
* MCP Plugin — exposes OpenAlice tools via Streamable HTTP, plus the CLI gateway.
Expand Down Expand Up @@ -103,6 +104,7 @@ export class McpPlugin implements Plugin {
inboxStore,
entityStore,
...(svc ? { provenanceStore: svc.provenanceStore } : {}),
...(svc ? { conversation: createWorkspaceConversationControl(svc) } : {}),
// Parity with the CLI gateway so external MCP consumers get the same
// workspace_path resolution — shared helper, so the two can't drift.
resolveWorkspace: makeWorkspaceResolver(getWorkspaceService),
Expand Down
Loading
Loading