Repository navigation
[feat] Default workflows a team can override, starting with session-close #1388
Description
Example
teamai ships session-close. A team that writes workflows/session-close.yaml replaces it.
$ teamai list workflows
=== REPO WORKFLOWS ===
review-and-fix (team)
session-close (default)
# after the team adds workflows/session-close.yaml and runs teamai pull
$ teamai list workflows
=== REPO WORKFLOWS ===
review-and-fix (team)
session-close (team)A workflow from the user-scope team also shows team. For one name the order is project workflow > user-scope workflow > teamai default.
The default definition. It parses with parseWorkflow as written, on a teamai that reads invoke (#1304):
schemaVersion: v1alpha1
name: session-close
description: Close out this session with the person - share what it taught as a team learning, publish its transcript to the team, score it with the team's scorers. Use when the person asks to close the session, share a learning, publish or archive the session, or score or judge it.
invoke: ask
inputs: { session: string, source: string, publish: boolean, scorers: [string], judge: here | driver }
steps:
- id: learning
interactive: true
prompt: "Run `teamai skill get share` and follow it with the person for the {{ inputs.source }} session {{ inputs.session }}. If the person finds nothing worth sharing, return verdict skip. Otherwise write the learning to a file outside the repository and return verdict share, its path, its title, and the learnings namespace (an empty string for the shared root)."
output: { verdict: share | skip, title: string, file: string, namespace: string }
- id: contribute
needs: [learning]
if: steps.learning.outputs.verdict == 'share'
approve: Share "{{ steps.learning.outputs.title }}" with the team?
command: teamai contribute --file "$FILE" --title "$TITLE" --namespace "$NS" --session-id "$SESSION"
env: { FILE: "{{ steps.learning.outputs.file }}", TITLE: "{{ steps.learning.outputs.title }}", NS: "{{ steps.learning.outputs.namespace }}", SESSION: "{{ inputs.session }}" }
- id: publish
needs: []
if: inputs.publish
approve: { message: "Publish this session, scrubbed, to the team?", required: true }
command: teamai session push --scrub --session "$SESSION" --source "$SOURCE"
env: { SESSION: "{{ inputs.session }}", SOURCE: "{{ inputs.source }}" }
- id: score
needs: []
if: inputs.judge == 'here'
for_each: inputs.scorers
command: teamai score --session "$SESSION" --source "$SOURCE" --scorer "$SCORER"
env: { SESSION: "{{ inputs.session }}", SOURCE: "{{ inputs.source }}", SCORER: "{{ item }}" }
- id: score-driver
needs: []
if: inputs.judge == 'driver'
for_each: inputs.scorers
prompt: |
Judge the {{ inputs.source }} session {{ inputs.session }} with the scorer {{ item }}. Take the judge work with
teamai score --scorer {{ item }} --session {{ inputs.session }} --source {{ inputs.source }} --json '{"driver":{"kind":"{{ inputs.source }}","machine":"local","workspace":".","can":{"judge":{"isolation":"best-effort","access":"read","tools":["<your tool>"]}}}}'
Read only the lease's prompt file and evidence directory, answer with the lease's answer schema, and send the answer with the lease's submit command and the same driver. Return the label it stored.
In tools, declare the id of your own tool (claude, codex, cursor, …), not the session's platform. If teamai answers isolation_unmet, judge_model_unavailable or busy, return an empty label.
output: { label: string }judge picks how a session is scored, and the hook decides it when it offers the workflow:
a judge tool teamai runs is installed here (claude, codex, opencode, codebuddy) → judge: here → score: teamai's own judge, as today
none is installed (a member who only has Cursor, for example) → judge: driver → score-driver: the step's agent takes
#1386's lease and judges it, best-effort
publish and scorers are offers the person confirms in the start question. publish also asks once more, from the person's own terminal or the dashboard, because no one reviews a transcript after it is published. auto publishing and scoring never run here; they are #1385's.
The learning step ends in one of two answers. Only share reaches contribute:
person: nothing worth sharing → { verdict: skip } → contribute skipped
person: share it under k8s → { verdict: share, title: "Pod startup timeouts",
file: "<outside the repo>/pod-startup.md", namespace: "k8s" } → contribute asks, then
teamai contribute … --namespace "k8s"
person: share it at the root → { verdict: share, …, namespace: "" } → contribute … --namespace ""
What offers it, at the Stop hook of an attended session. The friction reminder decides; publishing and scoring only ride along with it:
launchedBy is set (#1384) → nothing # a workflow step or a judge; also stops session-close offering itself
nestedStart(env, cwd) is set (#1304) → nothing # a child in a workflow worktree or on a teamai/<run>/… branch
runMode is unattended → nothing # attended only
this session drives a skill-driven run not done → nothing # a not-done skill state on this machine has orchestrator.session = this session
adapter = the session's platform has a session adapter (#1221 F `sessions`, today platform-facts.ts)
resolved = session-close as this checkout resolves it (project > user > default)
offer = resolved.invoke is ask or allow ? sessionCloseOffer(config, {platform, sessionId}) : none
# sessionCloseOffer:
# judge = installedJudgeTools([]) is not empty ? here : driver
# publish = adapter and sessions.publish.attended is ask (#1385)
# scorers = adapter and scoring sessions.attended is ask (#1385) ? sampledScorers(config, {platform, sessionId}) : []
# with judge: driver, minus the scorers whose required isolation is verified: the strictest of
# scorers/scoring.yaml isolation, the scorer's isolation and config.yaml scoring.isolation, as the
# judge lease computes it (requiredIsolation, src/scorers/isolation.ts)
contributeCheckForSession(sessionId, …, offer)
# today's friction reminder, unchanged: contributeHintAllowed, smartScore, thresholds,
# sharing.contributeHint.enabled, contributeHintEnabled, share gate, TEAMAI_CONTRIBUTE_HINT_DISABLED, hinted, contributed
no friction reminder → nothing # no publish or score offer either
reminder fires, offer is none → today's reminder text (`skill get share`), no offered state
reminder fires → the reminder names session-close and carries no inputs
one state write sets hinted, offered: {publish, scorers} and, for a tool
whose Stop cannot print, pendingHint with that same text
sampledScorers is the per-session rule the background session judge already applies in entries (src/session-flow/session-work.ts), moved into one function both call:
for each scorer teamai pull cached (sessionScorers)
scorer has no `sessions: true` → dropped
scorer lives in a namespace the session was not delivered → dropped
isSampled(scorer, {platform, sessionId}, samplingRate) is false → dropped # a hash: same answer on every machine and every read
a label or a settled attempt exists for (scorer, rubric, session) → dropped
otherwise → returned with its state: open, or leased while a judge holds it
entries keeps open and leased; sessionCloseOffer offers only open
scorer samplingRate isSampled(…, cursor/7c1e…) offered
review-quality 25 true yes
style 25 false no
secure-review 100 true yes
So with samplingRate: 25 a scorer is offered on about a quarter of the sessions that reach the reminder, always the same ones, and those are the sessions the background judge would pick under auto. The 6 hours idle rule of the background judge does not apply: the offer comes at Stop, while the person is there.
So a team sees the reminder on exactly the Stops it sees it on today, at most once per session, and a person can always start session-close by hand to publish or score a session the reminder never fired on.
The reminder text:
This session hit friction worth sharing. Offer the person the session-close workflow: run
teamai workflow start --describe session-close --driver skill
and start it with the inputs it proposes, once the person confirms them.
describe proposes the inputs, computed for the calling session by the same sessionCloseOffer:
$ teamai workflow start --describe session-close --driver skill
{"data":{"describe":{"workflow":{…},"judging":{"kind":"none"},"ask":{"kind":"ask","question":{"text":"…","inputs":[
{"name":"session","type":{"type":"string"},"value":"7c1e…","why":"The session to close: this one."},
{"name":"source","type":{"type":"string"},"value":"cursor","why":"This session's tool, as teamai session list names it."},
{"name":"publish","type":{"type":"boolean"},"value":true,
"why":"Archive this session's transcript, scrubbed, for the team. Proposed because the team's attended publish policy is ask. You confirm it again from your own terminal or the dashboard before it runs."},
{"name":"scorers","type":{"type":"array","items":{"type":"string"}},"value":["review-quality"],
"why":"Scorers that judge this session now. review-quality samples this session (samplingRate 25). [] skips scoring."},
{"name":"judge","type":{"type":"enum","values":["here","driver"]},"value":"driver",
"why":"No judge tool teamai runs is installed here, so this session's agent judges, best-effort."}],"options":[…]}}}}}Each proposed input carries a why: what the input does, and why describe proposed that value or none. The agent shows the person every input with its value and why, and the person may change or empty any of them before confirming:
| Input | why with a value |
why with none, or false / [] |
|---|---|---|
publish |
the line above | "…Not proposed: the attended publish policy (<file that sets it>) is off." / "…Not proposed: the attended publish policy is auto, so teamai publishes this session, scrubbed, once it has been idle 6 hours." / "…teamai reads no sessions from this tool." |
scorers |
names each proposed scorer and its samplingRate; with judge: driver, names each left out and the file that asks for verified isolation: "secure-review is left out: scorers/secure-review.md asks for verified isolation, which this session's agent cannot give." |
"…None proposed: the attended scoring policy is off." / "…None proposed: the attended scoring policy is auto, so the background judge scores this session once it has been idle 6 hours." / "…no scorer samples this session." Each ends "Name any scorer to judge with it." |
judge |
here: " is installed here, so teamai's own judge scores it." driver: the line above |
— (always proposed while the caller has an agent session) |
session, source |
the lines above | "No agent session found: give the session and its tool." |
describe of the resolved session-close, called from an agent shell
agentSessionFromEnv() finds no agent session → no value on any input; the agent asks the person for each
for each declared input
name and type are in the contract below → value and why from sessionCloseOffer(config, {platform, sessionId})
otherwise → no value and no why; the person gives it
The contract: session: string, source: string, publish: boolean, scorers: [string], judge: here | driver. A declared input matches when its parsed type (OutputSchema) equals the contract's: [string] is {"type":"array","items":{"type":"string"}}, and here | driver is that enum in any order. describe keeps today's type encoding. A team override that keeps these names and types gets them filled:
| Team override | Values describe proposes |
|---|---|
| Same five inputs, other steps or prompts | all five |
Adds ticket: string |
the five; ticket is the person's |
Drops publish and judge (learnings and scoring only) |
session, source, scorers |
Renames session to sessionId, or makes scorers a string |
all but that one |
Sets invoke: deny |
not offered: the reminder is today's text |
Starting it by hand
A person can ask their agent to close the session at any time, for example with the friction reminder off. The agent finds it through the workflow-session-close skill that teamai pull generates from the definition's description (#1304), which names those requests, and through skill-data/share, which points at it. It runs the same describe and gets the same proposal; the person can change any value before confirming, for example to name a scorer sampling did not pick. An explicit choice is not sampled: the run judges the scorers it was started with.
person: close this session and score it with style
agent: teamai workflow start --describe session-close --driver skill
proposal {"session":"7c1e…","source":"cursor","publish":false,"scorers":[],"judge":"driver"}
person: add style
agent: starts with {"session":"7c1e…","source":"cursor","publish":false,"scorers":["style"],"judge":"driver"}
Sharing and publishing without scoring:
agent: proposal {"session":"7c1e…","source":"cursor","publish":true,"scorers":["review-quality"],"judge":"driver"}
person: share the learning and publish it, but don't score it
agent: starts with {"session":"7c1e…","source":"cursor","publish":true,"scorers":[],"judge":"driver"}
learning → share → contribute; publish → required approval → session push; score-driver over [] succeeds at once
Behavior
| Team today | After |
|---|---|
Friction reminder on, members share with skill get share |
Same reminder on the same Stop; it now offers session-close, whose learning step runs skill get share with the person. With ask policies (#1385) the same offer also proposes publishing and scoring |
| Friction reminder off | No automatic offer at all. A person who wants to publish or score a session starts session-close by hand |
A team file workflows/session-close.yaml |
Used instead of the default; pull validates it like any workflow. An invalid override with a last valid version keeps that version, as today. An invalid override with none leaves the default in effect, and pull says so |
A session teamai launched (a workflow step, a judge, session-close's own steps), or a child in a workflow place |
Never offered session-close |
A team override of session-close that sets invoke: deny |
The friction reminder as today (skill get share); session-close is not offered and no offered state is written |
unattended run mode |
Never offered session-close. A hook there inherits the tool-session variables, so it counts as a start from a tool session under invoke, and nested starts are refused: nothing could start it lawfully |
| Step | Kind | Who decides it runs |
|---|---|---|
learning |
interactive prompt: the current tool session works it with the person, so it has the session's context, and it is exempt from the read-only check. Returns verdict: share with the file, title and namespace, or verdict: skip |
the person, while working it |
contribute |
command, optional approve, run only on verdict: share. --namespace passes the namespace the person chose ("" is the shared root) |
the person, when they picked "ask me at optional approvals" at start; under the recommended start the tool session, recorded as the agent's answer; the engine (auto_approve) when they picked "answer optional approvals automatically" |
publish |
command, required: true |
only the person, from their own terminal or the dashboard. An answer from a tool session is refused |
score |
command, no approval, one per scorer: teamai score --session … --scorer … runs teamai's own judge, as today. Only with judge: here. With no scorers it succeeds at once |
the scorers the person confirmed at start |
score-driver |
prompt step, no approval, one per scorer: its agent takes #1386's lease for the session and scorer, judges from the lease's evidence only, and submits. Labels record isolation: best-effort; a scorer that requires verified answers isolation_unmet and is not judged. Only with judge: driver. With no scorers it succeeds at once |
as score |
learning, publish and score / score-driver have no needs between them, so they run side by side as the start's parallel answer allows. publish and score read the session as it stands when they run: the close-out turns after that are not published or judged, as with #1385's drain from inside a session.
Gates live in the definition, not in prompts, so the engine records each answer (choice, channel, whether an agent sent it) and the dashboard shows it. contribute keeps an optional approval: a learning still reaches the team's review, so the tool session may answer it under the recommended start, and a person who wants to answer it picks "ask me at optional approvals". The scorers are an input the person confirms at start and the run state keeps, so no approval asks again. Publishing a transcript leaves nothing for the team to review, so it alone is required: the person answers it from their own terminal or the dashboard, and an answer relayed from the tool session is refused (agent_in_path). workflows.auto_approve does not answer these approvals: under the skill driver, #1304's next ignores it for a driver with HITL. The third start describe offers, "Same, but answer optional approvals automatically" (--auto-approve), answers contribute yes on channel auto_approve. It never answers publish, which is required.
Acceptance criteria
- No team sees the friction reminder in more or fewer cases than today. Covered by tests that replay today's
contributeCheckcases, also withsessions.publish.attended: ask: the reminder fires on the same Stops, and a Stop where it does not fire offers nothing. session-closeis offered at most once per session within 30 days, on the Stop where the friction reminder fires, with every artifact that applies on that Stop. A session resumed after its offer record expired (below) can be offered again; that is the accepted bound.- The offer is computed before
contributeCheckForSessionand passed into it; the function builds the reminder text and writeshinted,offeredandpendingHintin its one state write (src/contribute-check.ts:611-650). A caller never post-processes its result: for a tool whose Stop hook cannot print (codex,codebuddy,workbuddy: the tools outsideSTOP_STDOUT_TOOLSinsrc/utils/tool-names.ts) it returns{ hint: null }even when the reminder fires. Covered by a hook test withcodex: the stashed reminder names the describe command and carries no inputs, andofferedis in the write that setshintedandpendingHint. readContributeState, which today drops every field it does not know, reads the newoffered: { publish: boolean, scorers: string[] }.- The offer facts (
hinted,offered,contributed) outlive the 24 hours cleanup of the state file:cleanupStaleSessionsreplaces a stale file with a record of only those facts, removed after 30 days. Resuming a session more than 24 hours later, after a cleanup, never repeats an offer. [feat] Show scorer results in the dashboard #1182 needs the same record for its reminder table; whichever of the two issues lands first adds it, and the other reuses it. - Use only
launchedBy([feat] Run mode: tell attended runs from unattended ones #1384) andnestedStart(env, cwd)(feat(workflow): add session discovery and managed public leases #1304) for where the session was launched; add no other launch-marker reader. Covered by hook tests withTEAMAI_LAUNCHED_BY=workflow, withTEAMAI_WORKFLOW_ATTEMPTset, and with ateamai/<run>/…branch. A host child with neither a marker nor a workflow place is not recognized, [feat] Run mode: tell attended runs from unattended ones #1384's documented limitation. The unattended and driving-run guards below are separate. - Never offered while this session drives a skill-driven run that is not done, its own
session-closeincluded. A session drives a run when a not-done skill state in this machine's data homes hasorchestrator.sessionequal to it. A state withoutorchestrator.sessionblocks nothing. - Never offered in an unattended session. An unattended
--describeofsession-closeanswersdeniedanyway, sinceinvoke: askneeds a person (feat(workflow): add session discovery and managed public leases #1304). - The offered scorers come from
sampledScorers, one function thatSessionWork.entriesalso calls, so the offer and the background session judge pick the same (scorer, session) pairs. Covered by a test: a scorer withsamplingRate: 0is never offered, one with100is offered unless already labelled, one withoutsessions: trueis never offered, and a scorer in a namespace the session was not delivered is not offered. skill-data/share, which points atsession-close(Definition of done), says to start it with describe's proposed inputs once the person confirms them.publishandscorersare offered only for a session whose platform has a session adapter, sincesession push --sessionandscore --sessioncannot read the others.skill get sharekeeps working on its own and stays the single source of the learning instructions; the default has noprompt_file.- One function,
sessionCloseOffer, computes the offer for the hook and the values describe proposes. Covered by a test: for one session, the reminder'sofferedand describe's proposedpublishandscorersare equal. - describe adds
valueandwhyto each entry ofask.question.inputs, and ofinputsonkind: start, whose name and type are in the contract, when the caller has an agent session (agentSessionFromEnv). Other inputs get neither.whyfollows the table above, also forpublish: falseandscorers: []; tests cover each row. Covered by tests: an override that addsticket: stringgets the five values and none forticket; one that renamessessiongets no value for it; a call with no agent session variable gets no values. - The reminder names
session-closeonly while the resolved definition'sinvokeisaskorallow. Withdenyit is today's text, and noofferedstate is written. Covered by a hook test with a project override that setsinvoke: deny. sessionCloseOffersetsjudge: herewheninstalledJudgeTools([])finds a judge tool on this machine, andjudge: driverotherwise. Covered by tests for both values:hererunsscore,driverrunsscore-driver, and the other step is skipped.- The default parses with
parseWorkflow, withinvoke: ask,interactive: trueonlearning, thelearningoutput{ verdict: share | skip, title, file, namespace },contributeguarded byif: steps.learning.outputs.verdict == 'share',required: trueon the publish approval, an optional approval oncontribute, and none onscoreandscore-driver. - A
learningstep that returnsverdict: skipskipscontributeand asks nothing more about the learning. Covered by a test. A run started withscorers: []runs no judge, and itsscoreorscore-driverstep succeeds with no results. A scorer whose required isolation isverified(from the scorer,scorers/scoring.yamlorconfig.yaml) is never proposed withjudge: driver; covered by a test withisolation: verifiedinscorers/scoring.yamlonly. A scorer whose leaseteamai scorerefuses withisolation_unmet,judge_model_unavailableorbusyends itsscore-driveritem with an empty label, and the run still succeeds; covered by a test with a model-pinned scorer whose alias has no mapping for the declared tool. Thewhyrows forpublish: falseandscorers: []coveroff,autoand a tool teamai reads no sessions from, each with a test.learningalways runs: a person who only wants to publish or score answers it with nothing to share. contributestays a read-only command step, in self mode too. It may callmigrateSelfModeGitignore, which rewrites.teamai/.gitignoreonly when the file is out of date;teamai pullalready runs it, so after a pull it writes nothing. Covered by a test: a self-modesession-closerun, afterteamai pull, shares a learning and ends withoutread_only_dirty.- Defaults resolve behind
loadCachedWorkflowand insidelistCachedWorkflows(feat(workflow): add session discovery and managed public leases #1304), the one seam feat(workflow): add session discovery and managed public leases #1304 keeps for listing and loading. For one name the order is project workflow > user-scope workflow > teamai default. Defaults are never written to the workflow cache or the team repo. Sostart, sub-workflows, bundles, handoff,review --branchand feat(workflow): add session discovery and managed public leases #1304's discovery see them, andinvoke: askgets a generatedworkflow-session-closeskill. readyStartConfig(src/workflow-commands.ts, feat(workflow): add session discovery and managed public leases #1304) picks the run's scope by asking the project scope'sloadCachedWorkflowfirst. Its project probe must not return the teamai default before the user scope is checked: resolve scope and definition in project > user > default order, then use that scope's configuration. Covered by a test with a project install and a user-scopesession-closeoverride.teamai list workflows, which lists throughscanTeamForPull, andworkflow start --describe(feat(workflow): add session discovery and managed public leases #1304) show each workflow's source (describe addssource: 'team' | 'default'todata.describe.workflow,WorkflowDescriptioninsrc/workflow/describe.ts):teamfor a valid cached team definition (project or user scope),defaultotherwise. No new command.- A run of a default records
definition: { source: 'default', teamaiVersion: '<version>' }on the run state, the snapshot and the run records, and noteamRevision. A team definition recordsdefinition: { source: 'team' }besideteamRevision, as today. A default run continued on another machine whose teamai version differs refuses withdefinition_mismatch, naming both versions. - A
session-closerun is recorded and listed like any run. Its record names the closed session ininputs.sessionandinputs.source.contributeruns inside the attempt with--session-id, so [feat] Record what a session, step or run delivered #1387 records the learning twice: on thecontributepass, and as alearningoutcome of the closed session, which is the member's own session and has nolaunchedBy. Covered by a test. - Learnings are shared with
teamai contribute --session-idtoteamai-learnings, as today.
Definition of done
docs/guide/workflow.md(default workflows and overriding them, with thesession-closeinput contract),docs/guide/knowledge.md(sharing a learning throughsession-close, and who answers each offer) and their zh-CN pages, with the examples above.skill-data/workflow/SKILL.md(feat(workflow): add session discovery and managed public leases #1304), theaskline: give the person each entry ofask.question.inputswith its proposedvalueandwhy, and say they may change or empty any of them.skill-data/share: points atsession-close, since the share workflow changes.skill-data/setup/references/setup-admin.md(the paragraph on the end-of-session prompt and theshareworkflow): the prompt now offerssession-close.skills/teamai/SKILL.mdkeeps feat(workflow): add session discovery and managed public leases #1304's lines and nothing more.- The friction reminder text names
session-close. CHANGELOG.md, under## [Unreleased]›### Changed: the friction reminder offers thesession-closeworkflow, which teams can override withworkflows/session-close.yaml.listhelp text says workflows show their source;skill-data/core/references/commands.mdregenerated withnpx vitest run commands-reference -u.- Real-CLI record: one attended session that triggers the reminder and shares a learning through
session-close.
Out of scope
session-to-task, a second default workflow that turns a session's bug fix into a benchmark task: [feat] session-to-task: turn a session's bug fix into a benchmark task #1402. It reuses this issue's default-workflow resolution and describevalueandwhyseam.- teamai ships no default workflow for MR learnings or learning promotion.
ci extract-mrmust keep running in a bare CI checkout, and a team can wrap it in its own workflow ([feat] One launcher for every AI call, from the tool descriptor #1389 shows how). autopublishing and scoring ([feat] Publish and score sessions by run mode #1385).- Learnings from unattended runs. Nothing starts
session-closethere. - Learnings through pull requests.
Notes
- Reminder:
src/contribute-check.ts(flow comment at the top),contributeCheckForSession(:509), handlercontributeCheckHandleratsrc/hook-handlers.ts:486-511,contributeHintAllowedinsrc/skill-content.ts:180. The handler's early return oncontributeHintAllowed(:494) stays: no offer goes out without the friction reminder. - Per-session state: the file
getSessionPathreturns (src/contribute-check.ts:120-128).readContributeStatereads only the fields it knows (:138-160).ContributeState:src/types.ts:2156. Cleanup:cleanupStaleSessionsdeletes state files older than 24 hours (STALE_SESSION_MS,:183-208) whenever another session writes its state. - Config keys:
sharing.contributeHint.enabled(team) andcontributeHintEnabled(member),src/types.ts:124-132,:850, resolved byisContributeHintEnabled(:282-290). - Guards:
readLaunch(src/launch-context.ts), which [feat] Run mode: tell attended runs from unattended ones #1384'slaunchedByextends to anyTEAMAI_LAUNCHED_BYvalue and to the skill driver'sTEAMAI_WORKFLOW_ATTEMPT.nestedStartis onmainsince feat(workflow): add session discovery and managed public leases #1304 (src/workflow/attempt-marker.ts); a start inside a workflow place is refusednested_workflowanyway, so the reminder must not offer one. The driving-run lookup readsorchestrator.sessionin the skill state (src/workflow/drivers/skill/skill-state.ts, feat(workflow): add session discovery and managed public leases #1304). invoke: the defaultdenyrefuses a start from any tool session, the Stop hook's included.askfolds the person's consent into the one question--describeasks, so a tool session never starts a close-out on its own from the generated skill.allowwould. A team that wants that overrides withallow. The host also asks once beforestart, since feat(workflow): add session discovery and managed public leases #1304 pre-approves onlystart --describe,next,reportandrespond. A team file that setsinvokeneeds a teamai that reads it.interactive: truesteps are worked by the current tool session with the person (feat(workflow): add session discovery and managed public leases #1304). A child session would get only a brief, none of the closed session's context, and a read-only step that leaves a file failsread_only_dirty.- Approvals under the skill driver: feat(workflow): add session discovery and managed public leases #1304's HITL choices at start are
person,recommendandauto-approve(src/workflow/drivers/skill/preflight.ts), asked only when a step has an optional gate (neededAnswers); withrecommend, the tool session answers those gates.contribute's issession-close's only one. A required gate is handed to the person, who answers withteamai workflow respondin their own terminal or in the dashboard;respondfrom the tool session's shell is refusedagent_in_path(feat(workflow): add session discovery and managed public leases #1304,src/workflow/run-controls.ts).nextignoresworkflows.auto_approvefor a driver with HITL (src/workflow/engine.ts, the comment aboveanswerOptionalGates);answerWithoutHitlapplies it only to drivers without HITL. - Workflow resolution:
loadCachedWorkflow(src/workflow/cache.ts), used byloadWorkflowTree,loadWorkflowBundle, the engine'sstartRun(src/workflow/engine.ts),review --branch(src/review-branch.ts:696) andreadyStartConfig(src/workflow-commands.ts, feat(workflow): add session discovery and managed public leases #1304).dropUnsettled(src/workflow/cache.ts) would delete a default written into the cache. feat(workflow): add session discovery and managed public leases #1304 addedlistCachedWorkflows, aliases,advertisedWorkflows(src/workflow/discovery/workflow-skills.ts) and--describe. Precedent for package-served content:src/builtin-skills.ts. - Listing:
teamai list workflowsexists (workflowsinRESOURCE_TYPES,src/types.ts:1649-1664). It lists the team repo's files through the handler'sscanTeamForPull(src/status.ts:419-432), valid or not, so it needs the cache's view to tellteamfromdefault. - Run state fields:
BodySchema(src/workflow/snapshot.ts) holdsteamRevisiontoday. A claimed run without a team revision:definitionsOf(src/workflow/handoff.ts) today tells the member to runteamai pull. - This issue lands after [feat] Add the session skill driver and invocable workflows #1086. A tool whose Stop hook cannot print gets the reminder stashed for its next prompt, as today (
stopStdoutUnsupported,src/hook-handlers.ts:505-508). session push --session <id>comes from [feat] Publish and score sessions by run mode #1385.teamai score --session --source --scorerexists today and runs teamai's own judge (src/session-flow/scoring.ts);--scoreris repeatable, andfor_eachgives one call per scorer. Its choice of judge tool isinstalledJudgeTools([])andchooseJudgeModel(src/session-flow/scoring.ts:124-132); the hook'sjudgeuses the same list.score --sessionwith--jsonandcan.judge, whichscore-driverruns, comes from [feat] Judge a session with the driver's own tool #1386.- Input types:
TYPE_NAMESandparseOutputinsrc/workflow/parse.ts([string]is an array of strings; a barearrayis not a type;share | skipis an enum).approvewithoutrequiredis optional (parseHitl,src/workflow/parse-hitl.ts). A step that needs a skipped step is skipped (skipUnreachable,src/workflow/engine.ts), socontributeis skipped withlearning.contributeis also skipped when itsifis false, onverdict: skip. Afor_eachover an empty list succeeds at once with results[](docs/guide/workflow-advanced.md), soscorers: []needs no guard. - In self mode
contributecallsmigrateSelfModeGitignore(src/contribute.ts:219-225,src/init.ts:1057). It writes only whenmigrateSelfModeGitignoreContentreports a change, andteamai pullruns it first (src/pull.ts:187-188), so thecontributestep leaves the checkout as it found it. - describe's input list:
inputsOf(src/workflow/drivers/skill/preflight.ts, feat(workflow): add session discovery and managed public leases #1304), used forask.question.inputsandstart.inputs. This issue addsvalueandwhy, keyed by the resolved workflow's name. Today the question shows only each input's name and type (factsOf), and the workflow'sdescriptionis not in it. The caller's session:agentSessionFromEnv(src/utils/session-id.ts) gives its id and agent family;sourceis the platform nameteamai session listuses (src/session-flow/adapters/platform-facts.ts), mapped from that family. - No step uses
choose: each offer is a yes or no on an artifact the hook already picked, so options would only restate the inputs.
Activity
SaulMoro commentedon Oct 25, 2026
Rewrote the body. There is no
workflow list: defaults show inteamai list workflowsand--describe(#1304), resolve behindloadCachedWorkflowandlistCachedWorkflows, and are never written to the cache.The default sets
invoke: ask, since the defaultdenyrefuses any start from an agent session.learningis interactive, because a child agent lacks the session's context;scoreis a command step that runs teamai's own judge; the publish approval isrequired: true.It runs attended only. The Stop hook offers each artifact at most once per session, never in a session teamai launched or inside a workflow place, and never while the session drives a run. The new flags go into
readContributeState, which drops unknown fields (src/contribute-check.ts:138-160).The shipped YAML parses with #1304's parser.
session-to-taskmoves here as a second slice, after #1180's task format.SaulMoro commentedon Oct 25, 2026
session-closeis offered only on a Stop where the friction reminder already fires. Publishing and scoring ride along and are never offered alone, so no team sees a reminder it does not see today. A person can still start it by hand.workflows.auto_approveanswers these approvals: feat(workflow): add session discovery and managed public leases #1304'snextignores it for a driver with HITL. Optional approvals are answered by the person, or by the tool session when the person choserecommend.src/contribute-check.ts:183-208), so a resumed session is not offered twice.readyStartConfigscope probe, the run'sdefinitionfield, and theorchestrator.sessionlookup for a session that drives a run.SaulMoro commentedon Oct 25, 2026
The
learningstep now returns{ verdict: share | skip, title, file, namespace }.contributeruns only onshareand passes--namespace, so the person can share nothing, or share under a namespace. The default still parses withparseWorkflow.Settled the self-mode question.
contributestays a read-only command step:migrateSelfModeGitignorewrites only when.teamai/.gitignoreis out of date, andpullalready runs it. A test covers a self-mode run that ends withoutread_only_dirty.Noted why no step uses
choose, and updated thesrc/types.tsline numbers.Offers are computed before
contributeCheckForSessionand written in its one state write, so tools whose Stop cannot print (Codex, CodeBuddy, WorkBuddy) get the same offer in the stashed reminder. The once-per-session promise now states its 30-day bound.SaulMoro commentedon Oct 25, 2026
Updated: members without a local judge tool can now score through
session-close.judge: here | driver, set by the hook when it offers the workflow:herewheninstalledJudgeTools([])finds a judge tool on this machine,driverotherwise.scoreruns only withjudge: here, as before. Newscore-driverprompt step forjudge: driver: its agent takes [feat] Judge a session with the driver's own tool #1386's lease for the session and scorer, judges from the lease's evidence only, and submits. Labels recordbest-effort; a scorer that requiresverifiedanswersisolation_unmet.judge. The "Scoring a session whose tool teamai cannot launch as a judge" out-of-scope item is removed.parseWorkflow. The existing dependency on [feat] Judge a session with the driver's own tool #1386 now also coversscore-driver.SaulMoro commentedon Oct 25, 2026
Updated:
sampledScorers, the per-session rule the background session judge already applies (sessions: true, the session's namespaces,isSampledonsamplingRate, not already labelled or tried), moved into one function both call. AsamplingRate: 25scorer is offered on about a quarter of the sessions that reach the reminder, always the same ones. The 6 hours idle rule does not apply at Stop.SaulMoro commentedon Oct 25, 2026
session-to-tasknow tries the task's check before it opens the pull request: it must fail atstartingCommitand pass at the new optionalfixinput, the commit with the session's fix. A check that goes the wrong way opens no pull request. Withoutfix, the pull request says the check was not tried on a fix. The task file stays in #1180's format, and the results go in the pull request body. Added acceptance for the slice.SaulMoro commentedon Oct 26, 2026
Correction to the
session-to-taskchecks: atfix, the check now runs after theprotectpaths are put back asstartingCommithas them, as a trial's check runs. Without that, a check whose test only the fix added passes atfix, yet every trial fails it, since #1180 restores protected paths and deletes files the agent added there. Added criterion 5.SaulMoro commentedon Oct 26, 2026
Updated:
teamai workflow start --describe session-close --driver skill, and describe proposes the inputs for the calling session fromsessionCloseOffer, the same function the hook uses for its offer. Starting it by hand gets the same proposal; the person can change any value before confirming.session,source,publish,scorers,judge. describe fills each input an override declares with one of those names and types, and leaves the rest to the person.learningis no longer an input: thelearningstep always runs, and a person with nothing to share answers it withskip.invoke: denykeeps today's reminder (skill get share) and is not offered.session-to-taskedits made since the last update are kept as they were.SaulMoro commentedon Oct 26, 2026
Moved the "Second slice:
session-to-task" to its own issue, #1402, which replaces it with its examples and acceptance. The design changed on the way: the session that fixed the bug drafts the task interactively,teamai push --file --pathproposes it as a pull request, and the team's CI tries the check on that pull request with #1180's--try, since a nested benchmark start inside a step is refusednested_workflow. Out of scope now points at #1402.SaulMoro commentedon Oct 26, 2026
Removed
score-ok:The scorers are an input the person confirms in the start question, and the run state keeps it, so
score-okasked the same question a second time.The gates that matter stay in the definition, where the engine records each answer and the dashboard shows it:
contributekeeps its optional approval. Under the recommended start the tool session answers it, and "ask me at optional approvals" makes it the person's.publishstaysrequired, because no one reviews a transcript after it is published.Updated the step table, the paragraph on who decides, the acceptance criteria and the Notes to match.
SaulMoro commentedon Oct 26, 2026
describe now explains what it proposes. Each input in the contract gets a
whynext to itsvalue: what the input does, and why that value was proposed, or why none was (publish policy, sampled scorers with their rate, judge tool installed or not). The workflow skill shows each input with its value andwhy, and says the person may change or empty any of them. Until now the question showed only names and types.Also added:
learning,publishandscorerun side by side, andpublishandscoreread the session as it stands when they run, so the close-out turns after that are not published or judged.scorers: []).SaulMoro commentedon Oct 26, 2026
The default
descriptionnow says when to use it: "Use when the person asks to close the session, share a learning, publish or archive the session, or score or judge it."teamai pullturns it into the description of the generatedworkflow-session-closeskill, which is how an agent finds the workflow when the person asks by hand. "Starting it by hand" now says so.SaulMoro commentedon Oct 26, 2026
Fixes:
contributeyes on channelauto_approve, but neverpublish. Thecontributerow and the paragraph now say so.data.describe), and eachtypeis the parsed schema ({"type":"string"}, …). The contract match is structural, and describe keeps today'stypeencoding.judge: driver, such a scorer is no longer proposed, and itswhysays it was left out. If the person adds one by hand, itsscore-driveritem returns an empty label onisolation_unmet, so the run does not fail after the learning was shared.score-driverprompt. It declares the agent's own tool id, not the session's platform.sampledScorers. A live lease stays with the background judge and is not offered.main.SaulMoro commentedon Oct 26, 2026
Updated the references to #1304, which is now merged: what it brought is on
main.SaulMoro commentedon Oct 26, 2026
Changes:
judge: driverusing the required isolation, which is the strictest of the scorer,scorers/scoring.yamlandconfig.yaml(requiredIsolation). The scorer's own field is not enough on its own. Thewhynames the file that asks for it.score-driverreturns an empty label forisolation_unmet,judge_model_unavailableorbusy, so a refused lease never fails the run after the learning was shared.whyfor an unproposedpublishorscorersnow coversauto: teamai publishes or scores the session after 6 hours idle. It no longer says "the team's" policy, since a member's config can set it.contributepass and as an outcome of the closed session, which has nolaunchedBy.sampledScorersreturns each scorer with its state,openorleased. The offer takes onlyopenones.source: team | defaulttodata.describe.workflow.feat(workflow): default workflows a team can override, starting with …