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
17 changes: 17 additions & 0 deletions .agentic/artifacts/01KTC4V2WZ5SKTS4H7E84RDD8H/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"id": "01KTC4V2WZ5SKTS4H7E84RDD8H",
"type": "design",
"title": "Issue #16 Composition Object Design",
"body_ref": "01KTC4V2WZ5SKTS4H7E84RDD8H/v2.md",
"version": 2,
"finalized": true,
"tags": [
"dogfood",
"design",
"scope",
"v0.2",
"issue-16"
],
"created_at": "2026-06-05T15:01:19.391Z",
"updated_at": "2026-06-05T15:02:38.592Z"
}
90 changes: 90 additions & 0 deletions .agentic/artifacts/01KTC4V2WZ5SKTS4H7E84RDD8H/v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Issue #16 Composition Object Design

## Dogfood Trigger

The v0.5.0 dogfood run started exactly where the current workspace tells an agent to start:

1. Activate `spores-maintainer`.
2. Check GitHub ready issues.
3. Run `task next`.

That exposed two useful facts:

- GitHub currently has no `ready` issues; the local task mirror still returns #16 as `ready`, so the mirror can drift.
- The persona's `task_filter` is visible only as metadata. The CLI does not apply it to `task next`, memory recall, skill foregrounding, or workflow defaults.

This is the concrete pressure behind #16.

## Converged Design

### 1. What is the thing called?

Use `Scope`.

Do not grow workflow `Runtime` into a multi-primitive object. `Runtime` already means graph state machine. Do not use `Agentic` as a god-object. `Scope` is the bounded working context created by binding adapters plus an optional activated persona.

### 2. What does Scope own?

`Scope` owns defaults, not identity and not execution.

It composes:

- memory adapter plus optional default memory tag filter
- task adapter plus optional default task filter
- skill source plus optional foregrounded skill list
- workflow adapter/source plus optional default graph
- active persona metadata and rendered body

It does not own model calls, transport, scheduling, approvals, auth, sessions, or `observed_by`.

### 3. How do explicit arguments interact with persona defaults?

Explicit call arguments win.

Persona metadata supplies defaults when the caller omits a filter or graph. It is never a hard restriction. Example: a persona can default task queries to `{ status: "ready", tags: ["spores"] }`, but `scope.tasks.next({ tags: ["release"] })` should use the explicit release filter.

### 4. How does hat switching work?

One active persona at a time.

`scope.activatePersona(name)` returns a new scope value or mutates a single active binding, but there is no stack and no merge semantics. Switching hats replaces the active binding. Deactivation returns to adapter-only defaults.

### 5. What should the first API surface be?

Keep it small and high-signal:

- `Scope.fromConfig(baseDir)`
- `scope.activatePersona(name)`
- `scope.memory.recall(query, options?)`
- `scope.tasks.next(options?)`
- `scope.skills.list(options?)`
- `scope.workflows.defaultGraph()` or `scope.workflow.defaultGraph()`

The first implementation should prove default injection across primitives, not introduce a session layer.

### 6. What ordering problem did dogfood reveal?

Filters are not enough.

The original dogfood pain was not just "apply persona task_filter". Inside matching tasks, `task next` uses highest ULID/newest-first behavior. For real queues, callers need an ordering hint such as `created_at asc`, `updated_at desc`, or explicit priority. This should be designed separately from persona binding so `Scope` does not become a hidden scheduler.

### 7. What should happen next?

File implementation issues only after the design is accepted:

1. Add task query ordering options to the task adapter/CLI.
2. Add `Scope` as a composition layer over existing adapters and sources.
3. Teach persona activation to produce a binding usable by `Scope`.
4. Update the dogfood on-ramp to use scoped calls once available.

## Non-Goals

- No session primitive.
- No identity field.
- No runtime hosting.
- No automatic GitHub task adapter in this issue.
- No persona stacking or inheritance.

## Dogfood Notes

The run also found a rename-era hook bug: artifact command hooks still populated only `SPORES_ARTIFACT_*` variables even though docs promised `AGENTIC_ARTIFACT_*` plus legacy mirrors. That fix belongs in v0.5.x because it affects the preferred Agentic hook contract directly.
91 changes: 91 additions & 0 deletions .agentic/artifacts/01KTC4V2WZ5SKTS4H7E84RDD8H/v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Issue #16 Composition Object Design

## Dogfood Trigger

The v0.5.0 dogfood run started exactly where the current workspace tells an agent to start:

1. Activate `spores-maintainer`.
2. Check GitHub ready issues.
3. Run `task next`.

That exposed three useful facts:

- GitHub currently has no `ready` issues; the local task mirror still returns #16 as `ready`, so the mirror can drift.
- The persona's `task_filter` is visible only as metadata. The CLI does not apply it to `task next`, memory recall, skill foregrounding, or workflow defaults.
- Artifact hook docs promised preferred `AGENTIC_ARTIFACT_*` variables, but the artifact command path was still passing only legacy names before this run.

This is the concrete pressure behind #16.

## Converged Design

### 1. What is the thing called?

Use `Scope`.

Do not grow workflow `Runtime` into a multi-primitive object. `Runtime` already means graph state machine. Do not use `Agentic` as a god-object. `Scope` is the bounded working context created by binding adapters plus an optional activated persona.

### 2. What does Scope own?

`Scope` owns defaults, not identity and not execution.

It composes:

- memory adapter plus optional default memory tag filter
- task adapter plus optional default task filter
- skill source plus optional foregrounded skill list
- workflow adapter/source plus optional default graph
- active persona metadata and rendered body

It does not own model calls, transport, scheduling, approvals, auth, sessions, or `observed_by`.

### 3. How do explicit arguments interact with persona defaults?

Explicit call arguments win.

Persona metadata supplies defaults when the caller omits a filter or graph. It is never a hard restriction. Example: a persona can default task queries to `{ status: "ready", tags: ["spores"] }`, but `scope.tasks.next({ tags: ["release"] })` should use the explicit release filter.

### 4. How does hat switching work?

One active persona at a time.

`scope.activatePersona(name)` returns a new scope value or mutates a single active binding, but there is no stack and no merge semantics. Switching hats replaces the active binding. Deactivation returns to adapter-only defaults.

### 5. What should the first API surface be?

Keep it small and high-signal:

- `Scope.fromConfig(baseDir)`
- `scope.activatePersona(name)`
- `scope.memory.recall(query, options?)`
- `scope.tasks.next(options?)`
- `scope.skills.list(options?)`
- `scope.workflows.defaultGraph()` or `scope.workflow.defaultGraph()`

The first implementation should prove default injection across primitives, not introduce a session layer.

### 6. What ordering problem did dogfood reveal?

Filters are not enough.

The original dogfood pain was not just "apply persona task_filter". Inside matching tasks, `task next` uses highest ULID/newest-first behavior. For real queues, callers need an ordering hint such as `created_at asc`, `updated_at desc`, or explicit priority. This should be designed separately from persona binding so `Scope` does not become a hidden scheduler.

### 7. What should happen next?

File implementation issues only after the design is accepted:

1. Add task query ordering options to the task adapter/CLI.
2. Add `Scope` as a composition layer over existing adapters and sources.
3. Teach persona activation to produce a binding usable by `Scope`.
4. Update the dogfood on-ramp to use scoped calls once available.

## Non-Goals

- No session primitive.
- No identity field.
- No runtime hosting.
- No automatic GitHub task adapter in this issue.
- No persona stacking or inheritance.

## Dogfood Notes

The run found and fixed a rename-era hook contract bug: artifact command hooks now pass preferred `AGENTIC_ARTIFACT_*` variables and rely on the central hook bridge to provide legacy `SPORES_ARTIFACT_*` mirrors. The project `artifact.written` dogfood hook was updated to use `AGENTIC_*` first with legacy fallback.
34 changes: 18 additions & 16 deletions .agentic/hooks/artifact.written
Original file line number Diff line number Diff line change
@@ -1,39 +1,41 @@
#!/usr/bin/env bash
#
# .spores/hooks/artifact.written
# .agentic/hooks/artifact.written
#
# Fires after `spores artifact write <id>` or `spores artifact edit <id>`
# Fires after `agentic artifact write <id>` or `agentic artifact edit <id>`
# has persisted a new version to disk.
# Indexes the artifact reference into memory so it can be recalled by type,
# title, or version via `spores memory recall`.
# title, or version via `agentic memory recall`.
#
# Contract — env vars set by spores:
# SPORES_EVENT = "artifact.written" | "artifact.edited"
# SPORES_BIN = path to the spores CLI entry (for callbacks)
# SPORES_ARTIFACT_ID = the artifact ULID
# SPORES_ARTIFACT_VERSION = the new version number (as a string)
# SPORES_ARTIFACT_MODE = write mode: "iterate" | "replace"
# Contract — env vars set by Agentic:
# AGENTIC_EVENT = "artifact.written" | "artifact.edited"
# AGENTIC_BIN = path to the Agentic CLI entry (for callbacks)
# AGENTIC_ARTIFACT_ID = the artifact ULID
# AGENTIC_ARTIFACT_VERSION = the new version number (as a string)
# AGENTIC_ARTIFACT_MODE = write mode: "iterate" | "replace"
# Legacy SPORES_* mirrors are also available during the compatibility window.
#
# Stdout from this script is appended to the command output.
# Non-zero exit is surfaced as a warning — it does not fail the write.

set -euo pipefail

ID="${SPORES_ARTIFACT_ID:-}"
VERSION="${SPORES_ARTIFACT_VERSION:-}"
MODE="${SPORES_ARTIFACT_MODE:-}"
ID="${AGENTIC_ARTIFACT_ID:-${SPORES_ARTIFACT_ID:-}}"
VERSION="${AGENTIC_ARTIFACT_VERSION:-${SPORES_ARTIFACT_VERSION:-}}"
MODE="${AGENTIC_ARTIFACT_MODE:-${SPORES_ARTIFACT_MODE:-}}"

# Exit quietly if no artifact info to work with
[[ -z "$ID" ]] && exit 0

# Build memory content
CONTENT="Artifact written: ${ID} v${VERSION} (mode: ${MODE})"

# SPORES_BIN may be a path to main.ts (dev) or the installed `spores` binary.
if [[ "$SPORES_BIN" == *.ts ]]; then
invoke() { bun "$SPORES_BIN" "$@"; }
# AGENTIC_BIN may be a path to main.ts (dev) or the installed `agentic` binary.
BIN="${AGENTIC_BIN:-${SPORES_BIN:-agentic}}"
if [[ "$BIN" == *.ts ]]; then
invoke() { bun "$BIN" "$@"; }
else
invoke() { "$SPORES_BIN" "$@"; }
invoke() { "$BIN" "$@"; }
fi

# Store with a stable key so iterate writes update rather than duplicate
Expand Down
13 changes: 9 additions & 4 deletions .agentic/tasks/01KNRZBRK1S3WAB2DTYG1TNTB5.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
{
"description": "Investigate v0.2 composition object: Runtime vs Scope vs Spores top-level",
"status": "ready",
"status": "blocked",
"tags": [
"spores",
"v0.2",
"design"
],
"id": "01KNRZBRK1S3WAB2DTYG1TNTB5",
"annotations": [],
"annotations": [
{
"text": "2026-06-05 dogfood run posted the design artifact to GitHub issue #16; waiting on review/acceptance before implementation issues are filed.",
"timestamp": "2026-06-05T15:03:10.000Z"
}
],
"created_at": "2026-04-09T11:17:12.929Z",
"updated_at": "2026-04-09T11:17:12.929Z"
}
"updated_at": "2026-06-05T15:03:10.000Z"
}
8 changes: 4 additions & 4 deletions src/cli/commands/artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe("artifact CLI commands", () => {
const hookScript = join(hooksDir, "artifact.created")
await writeFile(
hookScript,
`#!/usr/bin/env sh\necho "hook ran: $SPORES_ARTIFACT_ID"`,
`#!/usr/bin/env sh\necho "hook ran: $AGENTIC_ARTIFACT_ID / $SPORES_ARTIFACT_ID"`,
)
await chmod(hookScript, 0o755)

Expand All @@ -139,7 +139,7 @@ describe("artifact CLI commands", () => {
const result = JSON.parse(out)
expect(result.hook).toBeDefined()
expect(result.hook.ran).toBe(true)
expect(result.hook.stdout).toContain("hook ran:")
expect(result.hook.stdout).toMatch(/hook ran: [0-9A-HJKMNP-TV-Z]{26} \/ [0-9A-HJKMNP-TV-Z]{26}/)
})
})

Expand Down Expand Up @@ -361,7 +361,7 @@ describe("artifact CLI commands", () => {
const hookScript = join(hooksDir, "artifact.finalized")
await writeFile(
hookScript,
`#!/usr/bin/env sh\necho "finalized: $SPORES_ARTIFACT_ID v$SPORES_ARTIFACT_FINAL_VERSION"`,
`#!/usr/bin/env sh\necho "finalized: $AGENTIC_ARTIFACT_ID v$AGENTIC_ARTIFACT_FINAL_VERSION / $SPORES_ARTIFACT_ID v$SPORES_ARTIFACT_FINAL_VERSION"`,
)
await chmod(hookScript, 0o755)

Expand All @@ -371,7 +371,7 @@ describe("artifact CLI commands", () => {
const result = JSON.parse(out)
expect(result.hook).toBeDefined()
expect(result.hook.ran).toBe(true)
expect(result.hook.stdout).toContain("finalized:")
expect(result.hook.stdout).toMatch(/finalized: [0-9A-HJKMNP-TV-Z]{26} v1 \/ [0-9A-HJKMNP-TV-Z]{26} v1/)
})
})
})
22 changes: 11 additions & 11 deletions src/cli/commands/artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,10 @@ export const artifactCreateCommand: Command = async (ctx, args, flags) => {
const hook = await fireHook(
"artifact.created",
{
SPORES_ARTIFACT_ID: record.id,
SPORES_ARTIFACT_TYPE: record.type,
SPORES_ARTIFACT_TITLE: record.title,
SPORES_ARTIFACT_TAGS: record.tags.join(","),
AGENTIC_ARTIFACT_ID: record.id,
AGENTIC_ARTIFACT_TYPE: record.type,
AGENTIC_ARTIFACT_TITLE: record.title,
AGENTIC_ARTIFACT_TAGS: record.tags.join(","),
},
ctx.baseDir,
)
Expand Down Expand Up @@ -160,9 +160,9 @@ export const artifactWriteCommand: Command = async (ctx, args, flags) => {
const hook = await fireHook(
"artifact.written",
{
SPORES_ARTIFACT_ID: record.id,
SPORES_ARTIFACT_VERSION: String(record.version),
SPORES_ARTIFACT_MODE: mode,
AGENTIC_ARTIFACT_ID: record.id,
AGENTIC_ARTIFACT_VERSION: String(record.version),
AGENTIC_ARTIFACT_MODE: mode,
},
ctx.baseDir,
)
Expand Down Expand Up @@ -196,8 +196,8 @@ export const artifactEditCommand: Command = async (ctx, args, flags) => {
const hook = await fireHook(
"artifact.edited",
{
SPORES_ARTIFACT_ID: record.id,
SPORES_ARTIFACT_VERSION: String(record.version),
AGENTIC_ARTIFACT_ID: record.id,
AGENTIC_ARTIFACT_VERSION: String(record.version),
},
ctx.baseDir,
)
Expand Down Expand Up @@ -268,8 +268,8 @@ export const artifactFinalizeCommand: Command = async (ctx, args, _flags) => {
const hook = await fireHook(
"artifact.finalized",
{
SPORES_ARTIFACT_ID: record.id,
SPORES_ARTIFACT_FINAL_VERSION: String(record.version),
AGENTIC_ARTIFACT_ID: record.id,
AGENTIC_ARTIFACT_FINAL_VERSION: String(record.version),
},
ctx.baseDir,
)
Expand Down