SLOPSHOPPER

stipulate

Stip spec lifecycle in Claude Code: approved-contract workers, ownership guards, approval and apply gates.

newpanebandguardcommandprompt
★ 7v0.1.0Apache-2.0updated 2026-10-09BRYANN2K/stipulate-skills/packages/claude-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stipulate
│ ┃ Stip ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Stip /work/app │ ┃ ╭─────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ │ Stip isn't set up here yet ⎿ Read 6 lines │ ┃ │ Set up writes a .workflow/ folder: a short ⏺ Update(src/auth.ts) │ ┃ │ map, your settings, and room for changes a ⎿ Added 2 lines, removed 1 line │ ┃ │ accepted specs. Nothing else in the projec ⏺ Bash(bun test) │ ┃ │ touched. ⎿ 3 pass, 1 fail │ ┃ │ [ Set up Stip ] starts /stip-bootstrap in │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Found in this project ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ refreshed 0s ago [ Refresh ] › /stip │ ⎿ stipulate: Stip pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Stip
◆ Stip /work/app ╭───────────────────────────────────────────────────────╮ │ Stip isn't set up here yet │ │ Set up writes a .workflow/ folder: a short project │ │ map, your settings, and room for changes and │ │ accepted specs. Nothing else in the project is │ │ touched. │ │ [ Set up Stip ] starts /stip-bootstrap in your prompt │ ╰───────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────╮ │ Found in this project scanning │ ╰──────────────────────────────────────────────────────╯ refreshed 0s ago [ Refresh ]
README

<img src="assets/banner.png" alt="stip, spec-driven development: from intent to evidence. The workflow stages Explore, Validate, Apply, Check, Docs and Archive, next to a crew of pixel crabs: orchestrator, research, build, security, review and docs." width="100%">

Core skills Domain extensions Python License X — @bryann2k_dev

Explore in natural language. Agree on a spec. Build with evidence.

Seven core skills, optional domain extensions, and a shared contract that stays with your project. OpenCode v2 adds native subagent orchestration, a workflow sidebar, and model settings; the Claude Code mod adds worker orchestration, a live pane, and approval and ownership guards. Uses the Agent Skills format for Codex, Claude Code, Grok Build, and OpenCode v2.

Get started · See the workflow · OpenCode plugin · Claude Code mod · Browse extensions · Read the guide


Why Stip?

A conversation can move quickly. The decisions it produces should remain clear when implementation starts, requirements change, or a new session picks up the work.

Stipulate Skills, or Stip, connects that conversation to a durable development workflow:

  • Prompt freely. Explore an idea, ask questions, and revise the direction in plain language.
  • Make the agreement explicit. Review the specification before the agent builds.
  • Check the actual result. Tie acceptance criteria to current, inspectable evidence.
  • Keep what you learned. Update documentation and archive the accepted behavior with a local commit.

Start a new project or adopt an existing repository. Bootstrap preserves useful conventions and maps what already exists. Extensions add relevant expertise without loading every domain into every change.

Quick start

You need Node.js 22.20+/npm, Python 3.10+, Git, and a supported coding client. The native OpenCode v2 plugin is developed against OpenCode 2 beta-19296. The pinned skills installer also requires Node.js 22.20+, including skill-only installations through this npx entrypoint.

1. Install the complete package

For OpenCode v2, run from your project directory:

npx github:BRYANN2K/stipulate-skills

This installs **all seven skills, all 28 bundled extensions, the seven /stip-* commands, and the native OpenCode plugin** in one run. The plugin adds the workflow sidebar, native worker tracking, and /stip-settings. For installation across projects:

npx github:BRYANN2K/stipulate-skills --global

Using OpenCode through Orca? Run the global installation command in a shell terminal inside Orca. The installer follows OPENCODE_CONFIG_DIR, so the plugin and commands land in the same configuration directory as Orca's OpenCode sessions.

In an already open OpenCode v2 session, run /restart. If the sidebar or settings command has not loaded, restart the CLI. Then use /stip-settings to choose models for worker roles; unset roles inherit the coordinator model.

The launcher defaults to OpenCode. To include Codex and Claude Code:

npx github:BRYANN2K/stipulate-skills --agent opencode codex claude-code

Use --dry-run to preview or --yes for unattended installation. This command runs the package directly from GitHub; no separately published npm package is required. It copies the skills, installs the commands, and prepares the plugin with its locked dependencies in a durable project or global directory. Existing customized commands or plugin source block an update. Your OpenCode configuration files are preserved. Use --no-opencode-plugin to install only skills and commands. Installation, updates, and removal →

For skill-only installation, the standard command remains available:

npx skills add BRYANN2K/stipulate-skills --skill '*' --agent codex

npx skills add does not run Stip's command or native plugin installers. For OpenCode's complete setup, use the Stip launcher above. Extensions travel inside stip-bootstrap and are selected per change. Client setup and verification →

2. Open your project in your coding client

Open the repository you want to work on. In Codex, send this in the chat composer:

$stip-bootstrap Adopt this repository. Preserve its conventions, map the project,
and identify the relevant domain extensions.

For a new project, explain its intent and initialize Git first if necessary. Bootstrap prepares AGENTS.md and .workflow/; it makes all 28 extensions available locally without choosing your stack or selecting domains for a change.

In Claude Code, Grok Build, and OpenCode v2, invoke /stip-bootstrap instead. For explicit OpenCode command files, follow slash-command setup. Bootstrap also creates or extends CLAUDE.md with an import of AGENTS.md, so Claude uses the same project guidance.

3. Explore your first change

$stip-explore Let's add a sign-in flow. Reuse the existing foundations and help
me define the scope, user journey, and acceptance criteria.

The $stip-* examples are Codex chat prompts, not terminal commands. Use /stip-* in Claude Code, Grok Build, and OpenCode v2. The agent uses the bundled Python runtime for lifecycle operations.

Walk through a complete feature, from idea to archive →

How it works

<img src="assets/workflow.png" alt="Bootstrap once, then explore with only the extensions the change needs, validate with your approval, apply with workers in isolated worktrees, check with a separate reviewer, document, and archive with the accepted spec and one local commit." width="100%">

You own the agreement; the agent carries out the approved work. During validation, read the Markdown spec, edit it directly or ask for revisions, and explicitly approve its current version. Implementation follows that contract.

A failed check returns to implementation. New requirements return to validation. After a successful check, documentation and archive close the change. Start the next feature at explore; bootstrap is not a repeated feature audit.

Native orchestration in OpenCode v2

Explore with your main agent. During validation, agree on an execution plan alongside the spec. The coordinator delegates bounded tasks to native OpenCode subagents, reviews their contributions, and takes the change through check, docs, and archive. An optional research helper can answer a specific question while exploration stays with the main agent.

See where the work stands

The STIPULATE panel sits below the MCP section. It shows the selected change, its seven stages, relevant extensions, and tracked workers. Click a stage to read its associated document. Click the change name to switch between active work and archived changes.

Workflow sidebarChange selector
<img src="assets/screenshots/opencode-sidebar.png" alt="Stipulate sidebar showing Bootstrap and Explore complete, Validate awaiting approval, two selected extensions, and no tracked workers yet." width="280"><img src="assets/screenshots/opencode-change-picker.png" alt="Change selector listing active documented and draft changes, followed by an archived change." width="440">

The selected contract is awaiting approval. Once workers are dispatched, the panel shows their activity and lets you open their native sessions. Returned work stays pending until the coordinator reviews and accepts it; completion alone does not pass the check.

Give each role the right model

Open /stip-settings to choose a default worker profile or assign models to backend, frontend, API, security, verification, documentation, and other roles. Inherit uses the coordinator's effective model. The picker lists models available through your OpenCode configuration; effort and Fast choices follow the capabilities of the selected model.

Worker settingsModel selector
<img src="assets/screenshots/opencode-settings.png" alt="Stipulate project settings with inherited specialist profiles, phase overrides, concurrent worker settings, and configuration scope." width="360"><img src="assets/screenshots/opencode-model-picker.png" alt="Backend model selector offering inheritance from the coordinator and models from configured OpenCode providers." width="360">

Use phase overrides when a role needs different settings during implementation or review. Save shared choices at project scope, or keep personal overrides local. Concurrency limits apply to workers; the coordinator is separate, and shared-checkout writes are serialized. The screenshots show a user's configuration, so the model list and concurrency value can differ from yours.

Execution plans belong to the approved specification version. Existing v1 changes continue to work; adding an execution plan requires explicit migration and renewed approval.

The plugin uses OpenCode subagents. Codex uses its own native agent files, and so does Claude Code without the mod; see native agent configuration. There is no runtime selector in OpenCode settings.

Panel and settings guide → · Orchestration contract and compatibility →

Claude Code mod

In Claude Code, the stipulate mod brings the same orchestration to the terminal and the desktop Code tab. The simplest way to install it is one command in your project folder:

npx github:BRYANN2K/stipulate-skills --agent claude-code

This installs the skills and the mod for the current project. Add --global to install the mod for every project, and --dry-run to see the exact command first. If the claude CLI is not on your PATH, the launcher prints the install line below instead.

Alternatively, type this at the prompt of a Claude Code terminal session:

/plugin install stipulate --marketplace BRYANN2K/stipulate-skills

Answer y to add the marketplace, then pick a scope. The desktop Code tab was observed loading a project-scope install; user-scope loading follows the mod API reference and has not yet been exercised. In the desktop app, /stip is available in the projects where the plugin is installed; bump the plugin version (or reinstall) to see an update.

  • A band above the prompt. It shows the bound change, its stage and progress, approval and non-zero counts, with Approve (only when approvable), Plan and Settings (hotkeys a, p, s).
  • A /stip pane. One view per lifecycle moment, from setup to archive: a change picker with + New, the stages, tasks with their model, effort and live steps, the plan graph, and a ruler of criteria you can open.
  • Workers per role. Choose their model and effort in /stip-settings, along with limits, the apply gate and motion (auto, calm, off); the coordinator keeps the session's /model and /effort.
  • Enforced boundaries. Workers are denied edits outside their owned paths and lifecycle commands. Approval comes only from you, and source edits wait for approval by default.
  • Parallel writers. They run in isolated Git worktrees and are integrated all-or-nothing, with a conflict report when needed.

See it work

One small change in a real Claude Code terminal session, from the first conversation to the archive commit.

1. Explore2. Validate
<img src="assets/screenshots/claude-code-1-explore.jpg" alt="Stip pane in Explore: the change greet-shout with its cli-tooling extension chip, the problem and five recorded decisions, next to the conversation with the orchestrator." width="440"><img src="assets/screenshots/claude-code-2-validate.jpg" alt="Stip pane in Validate: Ready for your approval with the contract digest, 11 criteria and 5 tasks, the Approve contract button, and the plan graph of three writers feeding verify and docs." width="440">
3. Apply4. Archive
<img src="assets/screenshots/claude-code-3-apply.jpg" alt="Stip pane in Apply: two Haiku workers running in isolated worktrees with their live steps, a third task ready to delegate, the plan graph and the criteria ruler at 0 of 11." width="440"><img src="assets/screenshots/claude-code-4-archive.jpg" alt="Stip pane in Archive: every stage done, 11 of 11 criteria passed, 5 tasks with no correction, 6 minutes from explore to archive, the models used per task and the archive commit." width="440">

The guards are coordination inside Claude Code, not an operating-system sandbox: Bash keeps your file access. The mod API is early access. The mod was qualified on Claude Code 2.1.293, 2.1.294 and 2.1.296, so re-qualify after an update.

Choose extensions for a change

The pane's header shows the bound change's extensions as chips. While the change is exploring, ✕ removes one and [ + extension ] adds one of the project's enabled extensions; either press withdraws any approval. The Settings tab's Extensions card turns extensions on or off for the project; turning one off is refused while a change not yet archived has selected it.

Chips while exploringExtensions card
<img src="docs/assets/claude-code-explore-extensions.png" alt="Claude Code terminal with the Stip pane in Explore: the header shows a cli-tooling chip with a remove button and an add extension button, and the band sits below the prompt." width="480"><img src="docs/assets/claude-code-settings-extensions.png" alt="Stip pane Settings tab with the cli-tooling chip under the header and the Extensions card: cli-tooling On, selected by greet-strip, and security-engineering Off." width="300">

Install, use, settings, limits and re-qualification →

The seven skills

SkillWhat it doesWhat you get
stip-bootstrapAdopts a new or existing project.Project map, configuration, and workflow guidance.
stip-exploreExplores intent with relevant available extensions.A bounded idea and recorded decisions.
stip-validateTurns the discussion into a contract for user review.Specification and acceptance criteria.
stip-applyBuilds and corrects within the approved scope.Implementation and relevant tests.
stip-checkReconciles every criterion with current evidence.Passed, failed, or unverified results.
stip-docsUpdates documentation affected by the change.Documentation grounded in the implementation.
stip-archivePromotes the accepted spec and closes the change.Archived records and a scoped local commit.

Bring the right expertise

28 optional extensions. Seven entry points stay seven. Extensions contribute domain questions, requirements, and verification guidance to the same workflow. They are local reference packages, not extra top-level skills or executable plugins.

DomainAvailable extensions
Product and experienceproduct-strategy, user-research, storytelling, ux-design, visual-design, design-system, content-design, accessibility
Application engineeringfrontend-engineering, backend-engineering, api-integrations, database-engineering, mobile-engineering, desktop-engineering, cli-tooling
Infrastructure and operationscloud-engineering, devops-delivery, sre-operations, release-management
Data, AI, and assurancedata-engineering, ai-engineering, analytics-experimentation, quality-engineering, security-engineering, privacy-engineering
Reach and supportbuild-in-public, seo-discoverability, customer-support

The complete catalog is included in the installation. $stip-bootstrap copies it into .workflow/extensions/ and registers available domains in the project configuration. Existing configured packages, customizations, and disabled entries are preserved.

Selection happens during stip-explore for each change. The agent reads only the relevant phase references for selected domains. A cloud-only change does not need design; existing domain work can be reused instead of repeated. Build-in-public guidance does not authorize posting on your behalf.

Extension setup, selection, and updates →

Decisions live with your project

AGENTS.md                    # Project instructions and workflow guidance
.workflow/
  project.md                 # Intent, project map, commands, known gaps
  config.json                # Available extensions and settings
  specs/                     # Currently accepted behavior
  extensions/                # Optional installed domain packages
  changes/
    add-login/
      proposal.md            # Context and decisions
      spec.md                # Desired behavior and acceptance criteria
      tasks.md               # Optional implementation breakdown
      evidence.md            # Verification evidence
      state.json             # Lifecycle state and approved version
  archive/                   # Closed changes

The example change appears only when you explore it. Bootstrap preserves existing useful files and adds a bounded guidance block to AGENTS.md; it does not fabricate a sample feature.

What the workflow enforces

  • Approval belongs to a version. Changes to the bound contract or selected extension guidance invalidate it.
  • Completion needs evidence. Missing or failed criteria block progression; check is tied to the source snapshot.
  • Commits stay scoped. Archive rejects stale evidence and conflicting Git state rather than absorbing unrelated work.
  • Publishing is separate. Archive creates a local commit. It does not push or deploy.

The Python workflow runtime is local and offline, with no third-party Python dependencies or telemetry. Installing the optional OpenCode plugin downloads locked JavaScript dependencies; native worker calls use the providers configured in OpenCode. Your coding agent still requires its normal setup. Recorded evidence is an attestation, not proof that its author was truthful; passing the workflow does not certify a product as production-ready.

The native OpenCode integration has been exercised in an isolated terminal project through archive, including native subagents, model/effort settings and visual UI checks. See the qualification report for evidence and tested boundaries.

Detailed guarantees, limits, and migration →

Documentation

Start hereFor
Installer referencePlugin installation, scope, updates, and removal.
Claude Code modMod installation, use, settings, enforcement limits, and re-qualification.
Native orchestrationPlans, tools, worker records, runtime commands, and rebase.
Installation and first-feature guideSetup, prompt examples, updates, removal, and troubleshooting.
Workflow referenceLifecycle commands, contracts, state, and evidence.
Domain extensionsPackage installation, selection, and contribution rules.
ContributingEngine changes and validation commands.
SecurityBoundaries and security reporting.

License

Apache License 2.0. See LICENSE and NOTICE.md.

Source 19 files
hooks/register.tsx 10 lines
1import type { Register } from 'claude-code'
2
3import { registerCore } from './core/index.ts'
4import { registerUi } from './ui/index.tsx'
5
6export const register: Register = (on, options) => {
7  registerCore(on, options)
8  registerUi(on, options)
9}
10
hooks/core/index.ts 3386 lines
1// Stipulate core: tools, agent types, dispatch and tracking, guards, approval and settings.
2// The interface (hooks/ui) reads the state below and calls the exported actions.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type {
7  StipArchiveSummary,
8  StipCheckView,
9  StipCriterion,
10  StipPlannedProfile,
11  StipSetup,
12  StipStart,
13  StipTamper,
14  StipTicket,
15  StipBinding,
16  StipChangeRef,
17  StipClientSettings,
18  StipScope,
19  StipSettingsView,
20  StipSnapshot,
21} from '../../types'
22import {
23  TOOL_NAMES,
24  agentTypeOf,
25  buildBrief,
26  buildHelperBrief,
27  criteriaText,
28  describeHelper,
29  describeTask,
30  helperAgentSpec,
31  parseDescription,
32  workerAgentSpec,
33  type PlanTask,
34} from './brief.ts'
35import {
36  PLUGIN,
37  TOOL_PREFIX,
38  WORKER_FORBIDDEN_TOOLS,
39  absolute,
40  applyGate,
41  deny,
42  helperBash,
43  inside,
44  mainBash,
45  protectedState,
46  reentryVerdict,
47  storeWrite,
48
49  workerBash,
50  workerEnvPrefix,
51  type WorkerMirror,
52  workerWrite,
53  writeTarget,
54} from './guards.ts'
55import {
56  sha256,
57  EXTENSION_ID,
58  EXTENSION_SCRIPT,
59  SAVE_SCRIPT,
60  SCOPES,
61  SettingsError,
62  disableRefusal,
63  effective,
64  enabledOf,
65  extensionPath,
66  personalPath,
67  projectExtensionsOf,
68  resolveProfile,
69  validateClient,
70  viewOf,
71  withClient,
72} from './settings.ts'
73import { ACTIVE, activityOf, buildSnapshot, emptySnapshot, pathsByTask, stepPhrase, type Job } from './snapshot.ts'
74import {
75  SETUP_FILES,
76  acceptedSpec,
77  approvedContract,
78  archiveSummary,
79  contractDiff,
80  criterionIn,
81  exploreOf,
82  exploreTitle,
83  followUpsOf,
84  pushStep,
85  reportOf,
86  setupSummary,
87  withProof,
88  type ApprovedContract,
89} from './ledger.ts'
90import { TOASTS, coalesce, noticeText, toastText, type CoalesceState } from './notify.ts'
91import { LABEL_PROMPT, cleanLabel, fallbackLabel, parseCriteria, parseEvidence } from './criteria.ts'
92import {
93  canon,
94  freshLifecycle,
95  hmac,
96  identityKey,
97  legalTransition,
98  lifecycleIdentity,
99  owedWork,
100  parseIdentityKey,
101  registryWriters,
102  staleWorktrees,
103  predates,
104  registryPredates,
105  restoreGate,
106  sha256Sync,
107  verifyRebase,
108  parseRegistry,
109  registryKey,
110  verifyRegistry,
111  type Identity,
112  type Registry,
113  type RestoreRecord,
114  type Verdict,
115} from './integrity.ts'
116
117export * from './settings.ts'
118export { applyGate, isApproved, workerBash, workerWrite, helperBash, mainBash, protectedState } from './guards.ts'
119export { buildBrief, criteriaText, describeTask, parseDescription, TOOL_NAMES } from './brief.ts'
120
121// --- The bundled lifecycle engine (assets/workflow.py), run through $.process.run ---
122// Every lifecycle and registry mutation goes through it; the mod never edits that state.
123
124type Dollar = EngineInterface
125
126export type Conflict = { path: string; reason: string }
127
128export class EngineError extends Error {
129  conflicts: Conflict[] | undefined
130  constructor(message: string, conflicts?: Conflict[]) {
131    super(message)
132    this.conflicts = conflicts
133  }
134}
135
136export type Context = { root: string; engine: string }
137
138let cached: { key: string; ctx: Context } | null = null
139
140/** The project root (physical path, as the engine requires) and the bundled engine path. */
141async function context($: Dollar): Promise<Context> {
142  const session = await $.session.root()
143  const engine = `${$.plugin.root.replace(/\/+$/, '')}/assets/workflow.py`
144  const key = `${session}\u0000${engine}`
145  if (cached?.key === key) return cached.ctx
146  const stat = await $.fs.stat(session, { resolve: true }).catch(() => undefined)
147  const root = stat?.realPath ?? session
148  cached = { key, ctx: { root, engine } }
149  cachedRoot = root
150  return cached.ctx
151}
152
153/** True when the project uses Stip (a .workflow folder at its root). */
154async function hasWorkflow($: Dollar, root: string): Promise<boolean> {
155  return $.fs.exists(`${root}/.workflow`).catch(() => false)
156}
157
158function parseError(stderr: string, exitCode: number): EngineError {
159  const text = stderr.trim()
160  const last = text.split('\n').filter(Boolean).at(-1) ?? ''
161  try {
162    const value = JSON.parse(last) as { error?: string; conflicts?: Conflict[] }
163    if (value && typeof value.error === 'string') return new EngineError(value.error, value.conflicts)
164  } catch {}
165  return new EngineError(text ? text.slice(-2000) : `Engine exited with status ${exitCode}`)
166}
167
168/** Runs one engine command and parses its JSON stdout; refusals throw EngineError. */
169async function engine(
170  $: Dollar,
171  args: readonly string[],
172  options: { stdin?: string; timeoutMs?: number } = {},
173): Promise<any> {
174  const ctx = await context($)
175  const result = await $.process.run(['python3', ctx.engine, '--root', ctx.root, ...args], {
176    cwd: ctx.root,
177    env: { PYTHONDONTWRITEBYTECODE: '1', STIP_WORKER: '' },
178    ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
179    timeoutMs: options.timeoutMs ?? 60_000,
180  })
181  if (result.exitCode !== 0) throw parseError(result.stderr, result.exitCode)
182  try {
183    return JSON.parse(result.stdout)
184  } catch {
185    throw new EngineError('The engine returned output that is not JSON')
186  }
187}
188
189let queue: Promise<unknown> = Promise.resolve()
190
191/**
192 * Runs a mutating engine command, serialized within this module and retried briefly
193 * while another process holds .workflow/.lock.
194 */
195function mutate($: Dollar, args: readonly string[], options: { stdin?: string; timeoutMs?: number } = {}): Promise<any> {
196  const run = async () => {
197    for (let attempt = 0; ; attempt++) {
198      try {
199        return await engine($, args, options)
200      } catch (e) {
201        if (!(e instanceof EngineError) || !/Workflow is locked/.test(e.message) || attempt >= 4) throw e
202        await $.clock.sleep(150 * (attempt + 1))
203      }
204    }
205  }
206  const lifecycle = args[0] !== 'runtime' || args[1] === 'revoke-approval'
207  const guarded = async () => {
208    if (lifecycle) lifecycleWrites++
209    engineInFlight++
210    await markBusy($, true)
211    try {
212      const result = await run()
213      // Known-good registry state comes only from this call's own report of what it wrote.
214      await adoptOwnWrite($, args, result).catch(e => log($, `adopt: ${errorText(e)}`))
215      return result
216    } finally {
217      if (lifecycle) lifecycleWrites++
218      engineInFlight--
219      if (engineInFlight === 0) {
220        await markBusy($, false)
221        if (tickDeferred) {
222          tickDeferred = false
223          void integrityTick($, true).catch(err => log($, `watch: ${errorText(err)}`))
224        }
225      }
226    }
227  }
228  const next = queue.then(guarded, guarded)
229  queue = next.catch(() => undefined)
230  return next
231}
232
233/** Bumped around every lifecycle write the core makes itself. */
234let lifecycleWrites = 0
235let engineInFlight = 0
236let mainBashInFlight = 0
237
238// --- Integrity watch (AC-11, AC-14) ---
239// Registry files (.workflow/.runtime/<change>/state.json, engine-helpers.json): known-good comes ONLY
240// from the mod's own engine calls (each verified against the job the call reports); nothing else may
241// change them. Lifecycle state.json: changes inside a coordinator Bash call are accepted when no worker
242// call overlapped it and the phase move is legal; anything else is restored.
243
244/**
245 * This instance's cache of known-good text, with the lifecycle identity it belongs to (null for files
246 * outside a change); the shared journal (below) is authoritative.
247 */
248const localKnown = new Map<string, { text: string; identity: string | null }>()
249
250// Known-good state is shared by every Stip instance on the project (another session, a backgrounded
251// one, the module after a hot reload): each own engine write records the resulting text in a journal
252// beside the registry, signed with a key kept in the plugin store. A change on disk that matches the
253// journal is another instance's legitimate write, never tampering.
254//
255// AC-32: a journal entry is bound to the lifecycle it was written for (change id + the lifecycle's
256// `created_at`) and to the instance that wrote it, both signed. An entry, a cached copy or a registry
257// from an earlier lifecycle of the same change id (sessions left from an earlier project at the same
258// path) is never used to restore this lifecycle: it is set aside (renamed with a timestamp) once, with
259// one notice, and the current files become the baseline.
260
261/** `v: 2` entries sign `by` and `identity` too; entries without `v` are the earlier format. */
262type Journal = { text: string; sha: string; by: string; at: number; mac: string; v?: 2; identity?: string | null }
263
264function journalPath(root: string, path: string): string {
265  if (path === `${root}/.workflow/.runtime/engine-helpers.json`) return `${root}/.workflow/.runtime/stip-known-helpers.json`
266  const reg = /\/\.workflow\/\.runtime\/([a-z0-9-]+)\/state\.json$/.exec(path)
267  if (reg) return `${root}/.workflow/.runtime/${reg[1]}/stip-known.json`
268  const life = /\/\.workflow\/changes\/([a-z0-9-]+)\/state\.json$/.exec(path)
269  if (life) return `${root}/.workflow/.runtime/${life[1]}/stip-known-lifecycle.json`
270  return `${path}.stip-known.json`
271}
272
273/** The change a watched file belongs to (its lifecycle or its worker registry), or null. */
274function changeOfPath(path: string): string | null {
275  return /\/\.workflow\/(?:changes|\.runtime)\/([a-z0-9]+(?:-[a-z0-9]+)*)\/state\.json$/.exec(path)?.[1] ?? null
276}
277const isLifecyclePath = (path: string) => /\/\.workflow\/changes\/[a-z0-9-]+\/state\.json$/.test(path)
278const lifecyclePathOf = (root: string, id: string) => `${root}/.workflow/changes/${id}/state.json`
279const registryPathOf = (root: string, id: string) => `${root}/.workflow/.runtime/${id}/state.json`
280
281const fingerprint = async (path: string, text: string) =>
282  sha256Sync(path.includes('/.workflow/.runtime/') ? canon(parseRegistry(text)) : text)
283
284let macKey: Uint8Array | null = null
285async function journalKey($: Dollar): Promise<Uint8Array> {
286  if (macKey) return macKey
287  let raw = await $.store.get('journalKey')
288  if (typeof raw !== 'string' || raw.length < 32) {
289    const bytes = crypto.getRandomValues(new Uint8Array(32))
290    raw = [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
291    await $.store.set('journalKey', raw)
292  }
293  macKey = new TextEncoder().encode(raw as string)
294  return macKey
295}
296
297async function mac($: Dollar, path: string, sha: string, at: number, bound?: { by: string; identity: string | null }): Promise<string> {
298  const message = bound ? `v2\n${path}\n${sha}\n${at}\n${bound.by}\n${bound.identity ?? ''}` : `${path}\n${sha}\n${at}`
299  return hmac(await journalKey($), message)
300}
301
302/** Journal entries already verified, by their raw text (a tick re-verifies only what changed). */
303const verifiedJournal = new Map<string, Journal>()
304
305async function readJournal($: Dollar, path: string): Promise<Journal | null> {
306  const { root } = await context($)
307  const text = await readOr($, journalPath(root, path)).catch(() => '')
308  if (!text) return null
309  const hit = verifiedJournal.get(text)
310  if (hit) return hit
311  try {
312    const j = JSON.parse(text) as Journal
313    if (typeof j.text !== 'string' || typeof j.sha !== 'string' || typeof j.at !== 'number') return null
314    // A journal entry not signed with the project's key, or whose text does not match, is ignored.
315    const bound = j.v === 2 ? { by: String(j.by), identity: typeof j.identity === 'string' ? j.identity : null } : undefined
316    if (j.mac !== (await mac($, path, j.sha, j.at, bound)) || j.sha !== (await fingerprint(path, j.text))) return null
317    if (verifiedJournal.size > 64) verifiedJournal.clear()
318    verifiedJournal.set(text, j)
319    return j
320  } catch {
321    return null
322  }
323}
324
325/** The lifecycle identity a journal entry belongs to: signed in v2, else read from a lifecycle's own text. */
326function entryIdentity(path: string, j: Journal): Identity | null {
327  if (j.v === 2) return typeof j.identity === 'string' ? parseIdentityKey(j.identity) : null
328  return isLifecyclePath(path) ? lifecycleIdentity(j.text) : null
329}
330
331/** Whether a journal entry belongs to the lifecycle this session is bound to (`want`, an identity key). */
332function entryMatches(path: string, j: Journal, want: string): boolean {
333  const id = entryIdentity(path, j)
334  if (id) return identityKey(id) === want
335  // An earlier-format registry entry names no lifecycle: usable only when written after this one was created.
336  return !predates(null, j.at, parseIdentityKey(want))
337}
338
339/** Known-good text: the shared journal's when valid for this lifecycle, else this instance's own. */
340async function known($: Dollar, path: string): Promise<string | undefined> {
341  const change = changeOfPath(path)
342  if (change && !pinned.has(change)) await settleIdentity($, change).catch(e => log($, `identity ${change}: ${errorText(e)}`))
343  const want = change ? pinned.get(change) : undefined
344  const j = await readJournal($, path)
345  if (j && (want === undefined || entryMatches(path, j, want))) {
346    localKnown.set(path, { text: j.text, identity: want ?? null })
347    return j.text
348  }
349  const mine = localKnown.get(path)
350  if (!mine) return undefined
351  return want === undefined || mine.identity === null || mine.identity === want ? mine.text : undefined
352}
353
354async function setKnown($: Dollar, path: string, text: string) {
355  const change = changeOfPath(path)
356  // A lifecycle names its own identity; a registry belongs to the lifecycle this session is bound to.
357  const own = isLifecyclePath(path) ? lifecycleIdentity(text) : null
358  const identity = own ? identityKey(own) : change ? (pinned.get(change) ?? null) : null
359  localKnown.set(path, { text, identity })
360  const { root } = await context($)
361  const at = await $.clock.now()
362  const sha = await fingerprint(path, text)
363  const entry: Journal = { text, sha, by: instanceId, at, v: 2, identity, mac: await mac($, path, sha, at, { by: instanceId, identity }) }
364  const raw = JSON.stringify(entry)
365  verifiedJournal.set(raw, entry)
366  await $.fs.write(journalPath(root, path), raw)
367}
368
369/** Change id -> the identity key of the lifecycle this session is bound to (first sight, or an accepted re-creation). */
370const pinned = new Map<string, string>()
371/** Set-aside notices already given, by change and identity (one notice per lifecycle). */
372const setAsideNoticed = new Set<string>()
373
374/** Renames a file aside (`<name>.stale-<stamp>`); answers the new path, or null when it could not. */
375async function setAside($: Dollar, path: string, stamp: string): Promise<string | null> {
376  if (!(await $.fs.exists(path).catch(() => false))) return null
377  const dest = `${path}.stale-${stamp}`
378  const r = await $.process.run(['mv', '-n', path, dest], { timeoutMs: 10_000 }).catch(() => null)
379  if (r && r.exitCode === 0 && !(await $.fs.exists(path).catch(() => true))) return dest
380  // A journal can be emptied in place (an empty journal is no entry); an engine file never is.
381  if (path.endsWith('.json') && /\/stip-known[^/]*\.json$/.test(path)) {
382    await $.fs.write(dest, await readOr($, path))
383    await $.fs.write(path, '')
384    return dest
385  }
386  log($, `set aside ${path}: ${r ? r.stderr.trim() || `exit ${r.exitCode}` : 'mv unavailable'}`)
387  return null
388}
389
390const asideStamp = async ($: Dollar) => new Date(await $.clock.now()).toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z')
391
392/** Active workers this session runs for a change (a re-creation under them is not accepted). */
393async function ownActive($: Dollar, changeId: string): Promise<boolean> {
394  return Object.values(await read($, workersAtom)).some(b => b.kind === 'task' && b.changeId === changeId && ACTIVE_WORKER.includes(b.status))
395}
396
397/** What one setting-aside moved, and whose records they were (for one accurate notice). */
398type Aside = { paths: string[]; worktrees: string[]; writers: Set<string>; earlier: string | null }
399const noAside = (): Aside => ({ paths: [], worktrees: [], writers: new Set(), earlier: null })
400
401/**
402 * Binds this session to the change's current lifecycle (AC-32). On first sight, records left for an
403 * older lifecycle of the same id are set aside; later, a different lifecycle is accepted only as a
404 * re-creation: newer, fresh (exploring, never approved), with no active job of the old lifecycle and
405 * none of this session's work owed a review or an integration, unless the person confirmed it
406 * (/stip-resync). Anything else is left to the watch, which restores it as tampering.
407 */
408async function settleIdentity($: Dollar, changeId: string, options: { confirmed?: boolean } = {}): Promise<string[]> {
409  const { root } = await context($)
410  const life = lifecyclePathOf(root, changeId)
411  const reg = registryPathOf(root, changeId)
412  const disk = await readOr($, life)
413  const current = lifecycleIdentity(disk)
414  if (!current || current.change !== changeId) return []
415  const key = identityKey(current)
416  const pin = pinned.get(changeId)
417  if (pin === key) return []
418  const aside = noAside()
419  if (pin !== undefined) {
420    const before = parseIdentityKey(pin)
421    aside.earlier = before?.created ?? null
422    if (!options.confirmed) {
423      if (!predates(before, null, current) || !freshLifecycle(disk) || (await ownActive($, changeId))) return []
424      const trusted = (await known($, reg)) ?? (await readOr($, reg))
425      if (owedWork(trusted, instanceId).length) return []
426    }
427  } else {
428    // First sight: a signed lifecycle entry newer than the file is the truth (the file was put back).
429    const j = await readJournal($, life)
430    const jid = j ? entryIdentity(life, j) : null
431    if (jid && jid.change === changeId && predates(current, null, jid)) {
432      pinned.set(changeId, identityKey(jid))
433      return []
434    }
435  }
436  const stamp = await asideStamp($)
437  for (const path of [life, reg]) {
438    const j = await readJournal($, path)
439    if (j && predates(entryIdentity(path, j), j.at, current)) {
440      aside.earlier ??= entryIdentity(path, j)?.created ?? null
441      if (j.by) aside.writers.add(j.by)
442      const moved = await setAside($, journalPath(root, path), stamp)
443      if (moved) aside.paths.push(moved)
444    }
445    const mine = localKnown.get(path)
446    if (mine && mine.identity !== key) localKnown.delete(path)
447    rejected.delete(path)
448    restoreLog = Object.fromEntries(Object.entries(restoreLog).filter(([p]) => p !== path))
449    held.delete(path)
450  }
451  pinned.set(changeId, key)
452  const r = await setAsideRegistry($, changeId, stamp)
453  aside.paths.push(...r.paths)
454  aside.worktrees.push(...r.worktrees)
455  for (const w of r.writers) aside.writers.add(w)
456  await noticeSetAside($, changeId, current, aside, pin !== undefined ? (options.confirmed ? 'confirmed' : 'recreated') : 'first')
457  return [...aside.paths, ...aside.worktrees]
458}
459
460/**
461 * Sets aside a worker registry whose records predate the lifecycle this session is bound to, and the
462 * git worktrees its old jobs left (moved aside, then `git worktree prune`), so a new reserve can create
463 * its own. Worktrees recorded by jobs of the current lifecycle are never touched.
464 */
465async function setAsideRegistry($: Dollar, changeId: string, stamp?: string): Promise<Aside> {
466  const { root } = await context($)
467  const pin = pinned.get(changeId)
468  const current = pin ? parseIdentityKey(pin) : null
469  const reg = registryPathOf(root, changeId)
470  const text = await readOr($, reg)
471  const out = noAside()
472  if (!text || !registryPredates(text, current)) return out
473  // Never under a running engine command (it holds the lock and may be writing this file).
474  if (engineInFlight > 0 || (await $.fs.exists(`${root}/.workflow/.lock`).catch(() => true))) return out
475  const when = stamp ?? (await asideStamp($))
476  const moved = await setAside($, reg, when)
477  if (!moved) return out
478  out.paths.push(moved)
479  for (const w of registryWriters(text)) out.writers.add(w)
480  for (const rel of staleWorktrees(text, changeId, current)) {
481    const dir = await setAside($, `${root}/${rel}`, when)
482    if (dir) out.worktrees.push(dir)
483  }
484  if (out.worktrees.length)
485    await $.process.run(['git', 'worktree', 'prune'], { cwd: root, timeoutMs: 15_000 }).catch(e => log($, `worktree prune: ${errorText(e)}`))
486  localKnown.delete(reg)
487  rejected.delete(reg)
488  held.delete(reg)
489  await setKnown($, reg, '')
490  return out
491}
492
493/** Who wrote the set-aside records, in words. */
494function writtenBy(writers: Set<string>): string {
495  const others = [...writers].filter(w => w !== instanceId && w !== 'unknown')
496  const mine = writers.has(instanceId)
497  const named = others.length ? `another session (${others.slice(0, 2).map(w => w.slice(0, 8)).join(', ')}${others.length > 2 ? ', …' : ''})` : ''
498  if (mine && named) return `this session and ${named}`
499  if (mine) return 'this session'
500  return named || 'an earlier session'
501}
502
503async function noticeSetAside($: Dollar, changeId: string, current: Identity, aside: Aside, how: 'first' | 'recreated' | 'confirmed') {
504  const all = [...aside.paths, ...aside.worktrees]
505  if (!all.length) return
506  const once = `${changeId}@${identityKey(current)}`
507  const { root } = await context($)
508  const rel = (list: string[]) => list.map(p => p.slice(root.length + 1)).join(', ')
509  log($, `set aside for ${changeId}: ${rel(all)}`)
510  if (setAsideNoticed.has(once)) return
511  setAsideNoticed.add(once)
512  const earlier = `its earlier lifecycle (created ${aside.earlier ?? 'earlier'})`
513  const lead =
514    how === 'first'
515      ? `records of an earlier lifecycle of ${changeId} (created ${aside.earlier ?? 'earlier'}), written by ${writtenBy(aside.writers)}, were set aside, not restored`
516      : `${changeId} was re-created (new lifecycle created ${current.created ?? 'now'})${how === 'confirmed' ? ', as you confirmed' : ''}; the records of ${earlier}, written by ${writtenBy(aside.writers)}, were set aside`
517  await notify(
518    $,
519    `stale:${once}`,
520    TOASTS.staleSetAside(),
521    `${lead}: ${rel(aside.paths) || 'none'}` +
522      (aside.worktrees.length ? `; ${aside.worktrees.length} old worktree${aside.worktrees.length === 1 ? '' : 's'} moved aside (${rel(aside.worktrees)}), git worktrees pruned` : '') +
523      `. The current lifecycle (created ${current.created ?? 'at an unknown time'}) is the baseline.`,
524  )
525}
526
527/** Keeps every watched change on its lifecycle: identity first, then stale registries. */
528async function guardIdentities($: Dollar, changes: Iterable<string>) {
529  for (const id of new Set(changes)) {
530    try {
531      await settleIdentity($, id)
532      if (pinned.has(id)) {
533        const r = await setAsideRegistry($, id)
534        const current = parseIdentityKey(pinned.get(id)!)
535        if (current) await noticeSetAside($, id, current, r, 'first')
536      }
537    } catch (e) {
538      log($, `identity ${id}: ${errorText(e)}`)
539    }
540  }
541}
542
543// --- Restore loop guard: never fight another writer forever ---
544
545let restoreLog: Record<string, RestoreRecord> = {}
546/** Files the watch stopped restoring (path -> change id or null), until /stip-resync or the file is good again. */
547const held = new Map<string, string | null>()
548
549/**
550 * Whether the watch may restore `path` (holding `disk`) once more; on the first refusal, one notice.
551 * A held file blocks dispatch for its change until the person runs /stip-resync.
552 */
553async function mayRestore($: Dollar, path: string, disk: string): Promise<boolean> {
554  const gate = restoreGate(restoreLog, path, sha256Sync(disk), await $.clock.now())
555  restoreLog = gate.log
556  if (gate.allowed) return true
557  held.set(path, changeOfPath(path))
558  if (gate.notify) {
559    const { root } = await context($)
560    const rel = path.slice(root.length + 1)
561    const text = `${rel} keeps being changed back outside this Claude Code session; it was restored ${restoreLog[path]?.total ?? 0} time(s) and is no longer restored, to avoid a restore loop. Dispatch for this change is paused. ${RESYNC_HINT}`
562    await notify($, `held:${path}`, TOASTS.restoreStopped(), text, 20_000)
563    statusLine($, TOASTS.restoreStopped())
564    log($, text)
565  }
566  return false
567}
568
569/** This instance (session id; the same across hot reloads of one session). */
570let instanceId = 'unknown'
571
572/** Each instance's own busy marker (one file per instance, so none overwrites another's). */
573const busyFile = (root: string, id: string) => `${root}/.workflow/.runtime/stip-busy-${id.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80)}.json`
574
575/** Another instance is writing (its engine call is in flight): skip judging until it is done. */
576async function otherBusy($: Dollar): Promise<boolean> {
577  const { root } = await context($)
578  const mine = busyFile(root, instanceId).split('/').pop()
579  const now = await $.clock.now()
580  for (const entry of await $.fs.list(`${root}/.workflow/.runtime`).catch(() => [])) {
581    if (!/^stip-busy-.*\.json$/.test(entry.name) || entry.name === mine) continue
582    try {
583      const b = JSON.parse(await readOr($, `${root}/.workflow/.runtime/${entry.name}`)) as { until?: number }
584      if (typeof b.until === 'number' && b.until > now) return true
585    } catch {}
586  }
587  return false
588}
589
590async function markBusy($: Dollar, on: boolean) {
591  const { root } = await context($)
592  const until = on ? (await $.clock.now()) + 15_000 : 0
593  await $.fs.write(busyFile(root, instanceId), JSON.stringify({ by: instanceId, until })).catch(() => undefined)
594}
595
596/** A tick that fell during one of this instance's own engine calls runs right after it. */
597let tickDeferred = false
598/** Worker tool calls started, and worker Bash calls in flight (for attributing coordinator Bash windows). */
599let workerCalls = 0
600let workerBashInFlight = 0
601/** When a worker last returned or ended; the watch keeps its fast cadence 30 s after it. */
602let lastWorkerEnd = 0
603const GRACE_MS = 30_000
604/** Idle cadence: with no worker active, the watch compares every 10 s (every 3 s otherwise). */
605const IDLE_MS = 10_000
606let lastIdleCheck = 0
607/** What was on disk when the watch restored a file, for /stip-resync to show and re-apply. */
608const rejected = new Map<string, string>()
609const RESYNC_HINT = 'If you made this change yourself (for example in another terminal), run /stip-resync to review and adopt it.'
610
611const readOr = async ($: Dollar, path: string) => ((await $.fs.exists(path)) ? ((await $.fs.read(path)) as string) : '')
612const registryText = (r: Registry | null) => (r ? JSON.stringify(r, null, 2) + '\n' : '')
613
614async function watchedFiles($: Dollar): Promise<{ registries: string[]; lifecycles: string[] }> {
615  const { root } = await context($)
616  const changes = new Set<string>()
617  for (const b of Object.values(await read($, workersAtom))) if (b.changeId) changes.add(b.changeId)
618  const bound = await read($, bindingAtom)
619  if (bound) changes.add(bound)
620  return {
621    registries: [...[...changes].map(id => `${root}/.workflow/.runtime/${id}/state.json`), `${root}/.workflow/.runtime/engine-helpers.json`],
622    lifecycles: [...changes].map(id => `${root}/.workflow/changes/${id}/state.json`),
623  }
624}
625
626/** Takes the files as they are now as known-good (session start, idle periods, first sight). */
627/**
628 * Takes disk as known-good only where no instance has recorded a known-good state yet (first
629 * activation in a project, or a file first seen). A session start or hot reload keeps the journal's.
630 */
631async function baselineWatched($: Dollar) {
632  const { registries, lifecycles } = await watchedFiles($)
633  await guardIdentities($, lifecycles.map(p => changeOfPath(p)!).filter(Boolean))
634  for (const p of [...registries, ...lifecycles]) if ((await known($, p)) === undefined) await setKnown($, p, await readOr($, p))
635}
636
637/** After the mod's own engine call: verify the registry against the job the call reports, then adopt it. */
638async function adoptOwnWrite($: Dollar, args: readonly string[], result: any) {
639  const { root } = await context($)
640  if (args[0] === 'runtime' && args[1] !== 'show' && args[1] !== 'revoke-approval') {
641    const helper = args.includes('--helper')
642    const path = helper ? `${root}/.workflow/.runtime/engine-helpers.json` : `${root}/.workflow/.runtime/${args[2]}/state.json`
643    const disk = await readOr($, path)
644    if (!((await known($, path)) !== undefined)) {
645      await setKnown($, path, disk)
646      return
647    }
648    if (args[1] === 'rebase') {
649      // A rebase retires removed tasks and reopens changed ones: exactly what it reports, nothing else.
650      const before = parseRegistry((await known($, path))!)
651      const after = parseRegistry(disk)
652      if (verifyRebase(before, after, result ?? {})) await setKnown($, path, disk)
653      else await onRegistryTamper($, path, verifyRegistry(before, after), after)
654      return
655    }
656    const verdict = verifyRegistry(parseRegistry((await known($, path))!), parseRegistry(disk), result?.job ?? null, args[1] === 'reserve' && args.includes('--correction'), args[1])
657    if (verdict.ok) await setKnown($, path, disk)
658    else await onRegistryTamper($, path, verdict, parseRegistry(disk))
659    return
660  }
661  // A lifecycle call of the mod's own (approve, validate, plan, revoke): adopt that change's state.
662  const id = args[0] === 'runtime' ? args[2] : args[1]
663  if (id && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) {
664    const path = `${root}/.workflow/changes/${id}/state.json`
665    await setKnown($, path, await readOr($, path))
666  }
667}
668
669const ACTIVE_WORKER = ['starting', 'running', 'waiting', 'unknown']
670
671async function watching($: Dollar): Promise<boolean> {
672  const bound = Object.values(await read($, workersAtom)).some(b => ACTIVE_WORKER.includes(b.status))
673  if (bound) return true
674  for (const path of (await watchedFiles($)).registries)
675    if (parseRegistry((await known($, path)) ?? '')?.jobs.some(j => ACTIVE.includes(j.status))) return true
676  return (await $.clock.now()) - lastWorkerEnd < GRACE_MS
677}
678
679/**
680 * Cancels the active workers of a change (or all helpers) after tampering. The targets come from the
681 * known-good registry (the trusted state), not from bindings a refresh may have synced from the tampered file.
682 */
683async function cancelActive($: Dollar, changeId: string | null, reason: string, registry?: Registry | null): Promise<string[]> {
684  const { root } = await context($)
685  const trusted = registry ?? parseRegistry((await known($, changeId ? `${root}/.workflow/.runtime/${changeId}/state.json` : `${root}/.workflow/.runtime/engine-helpers.json`)) ?? '')
686  const targets = new Map<string, { helperId?: string; taskId: string; attempt: number }>()
687  for (const j of trusted?.jobs ?? [])
688    if (ACTIVE.includes(j.status)) targets.set(registryKey(j), typeof j.helper_id === 'string' ? { helperId: j.helper_id, taskId: j.helper_id, attempt: 1 } : { taskId: j.task_id, attempt: j.attempt })
689  for (const b of Object.values(await read($, workersAtom))) {
690    if (!ACTIVE_WORKER.includes(b.status)) continue
691    if (changeId !== null ? b.changeId !== changeId || b.kind !== 'task' : b.kind !== 'helper') continue
692    targets.set(b.helperId ?? `${b.taskId}#${b.attempt}`, b.helperId ? { helperId: b.helperId, taskId: b.taskId, attempt: 1 } : { taskId: b.taskId, attempt: b.attempt })
693  }
694  const out: string[] = []
695  for (const t of targets.values()) {
696    try {
697      if (t.helperId) await cancel($, { helperId: t.helperId, reason })
698      else if (changeId) await cancel($, { changeId, taskId: t.taskId, reason })
699    } catch (e) {
700      log($, `integrity cancel ${t.taskId}: ${errorText(e)}`)
701    }
702    // Deny the agent's further calls whatever the registry said.
703    for (const b of Object.values(await read($, workersAtom)))
704      if ((t.helperId ? b.helperId === t.helperId : b.changeId === changeId && b.taskId === t.taskId && b.attempt === t.attempt) && b.agentId)
705        await patchBinding($, b.agentId, { status: 'cancelled' })
706    out.push(`${t.taskId}#${t.attempt}`)
707  }
708  return out
709}
710
711/** Paths under write_paths that differ from HEAD in the main checkout. */
712async function dirtyPaths($: Dollar, writePaths: string[]): Promise<string[]> {
713  if (!writePaths.length) return []
714  const { root } = await context($)
715  const r = await $.process.run(['git', 'status', '--porcelain', '--untracked-files=all', '--', ...writePaths], { cwd: root, timeoutMs: 15_000 }).catch(() => null)
716  if (!r || r.exitCode !== 0) return []
717  return r.stdout
718    .split('\n')
719    .filter(Boolean)
720    .map(l => l.slice(3).replace(/^.* -> /, ''))
721}
722
723/** Restores known-good registry state, cancels that change's workers and flags tampered attempts. */
724async function onRegistryTamper($: Dollar, path: string, verdict: Verdict, disk: Registry | null) {
725  const { root } = await context($)
726  if (!(await mayRestore($, path, disk ? canon(disk) : ''))) return
727  const text = registryText(verdict.restored)
728  rejected.set(path, disk ? registryText(disk) : await readOr($, path))
729  if (text) await $.fs.write(path, text)
730  await setKnown($, path, text)
731  const rel = path.slice(root.length + 1)
732  const changeId = /\.runtime\/([a-z0-9-]+)\/state\.json$/.exec(path)?.[1] ?? null
733  const flagged: string[] = []
734  if (changeId) {
735    for (const key of verdict.tampered) {
736      const job = disk?.jobs.find(j => registryKey(j) === key) ?? verdict.restored?.jobs.find(j => registryKey(j) === key)
737      if (!job || typeof job.task_id !== 'string') continue
738      const paths = await dirtyPaths($, Array.isArray(job.write_paths) ? job.write_paths : [])
739      const integratedClaim = verdict.integrated.includes(key)
740      if (!integratedClaim && !paths.length) continue
741      const tamper: StipTamper = {
742        changeId,
743        taskId: job.task_id,
744        attempt: job.attempt,
745        integratedClaim,
746        paths,
747        at: await $.clock.now(),
748        note: `The registry record of ${job.task_id}#${job.attempt} was changed outside Stip${integratedClaim ? ' (it claimed an integration)' : ''}. Inspect git diff${paths.length ? ` of ${paths.join(', ')}` : ''} before accepting; source files were not reverted.`,
749      }
750      await update($, tamperedAtom, m => ({ ...m, [`${changeId}/${job.task_id}#${job.attempt}`]: tamper }))
751      flagged.push(`${job.task_id}#${job.attempt}`)
752    }
753  }
754  const cancelled = await cancelActive($, changeId, `The worker registry (${rel}) was changed outside this Claude Code session; restored and cancelled.`, verdict.restored)
755  const text2 =
756    `${PLUGIN}: ${rel} was changed outside this Claude Code session; restored the last good state` +
757    (cancelled.length ? `, cancelled ${cancelled.join(', ')}` : '') +
758    (flagged.length ? `. Tampered: ${flagged.join(', ')}; inspect git diff before accepting.` : '.') +
759    ` ${RESYNC_HINT}`
760  await notify($, flagged.length ? `tampered:${rel}` : `registry:${rel}`, flagged.length ? TOASTS.tampered(flagged) : TOASTS.registryRestored(cancelled.length), text2, 20_000)
761  statusLine($, flagged.length ? TOASTS.tampered(flagged) : 'Registry restored')
762  log($, text2)
763  await refresh($, { force: true }).catch(() => undefined)
764}
765
766/** Restores an unexpected lifecycle change and cancels that change's workers. */
767async function onLifecycleTamper($: Dollar, path: string, why: string) {
768  const { root } = await context($)
769  const good = (await known($, path)) ?? ''
770  const disk = await readOr($, path)
771  if (!(await mayRestore($, path, disk))) return
772  const changeId = /changes\/([a-z0-9-]+)\/state\.json$/.exec(path)?.[1] ?? null
773  const archived = !disk && changeId !== null && (await $.fs.exists(`${root}/.workflow/archive/${changeId}/state.json`))
774  rejected.set(path, disk)
775  // A change moved to the archive is not recreated; it is reported for the person to resolve.
776  if (good && !archived) await $.fs.write(path, good)
777  const cancelled = changeId ? await cancelActive($, changeId, `Lifecycle state changed outside Stip (${why}); restored and cancelled.`) : []
778  const text = `${PLUGIN}: ${path.slice(root.length + 1)} changed outside this Claude Code session (${why}); ${archived ? 'the change was archived, not restored' : 'restored'}${cancelled.length ? `, cancelled ${cancelled.join(', ')}` : ''}. ${RESYNC_HINT}`
779  await notify($, `lifecycle:${path}`, TOASTS.lifecycleRestored(), text, 20_000)
780  statusLine($, 'Lifecycle restored')
781  log($, text)
782  await checkProvenance($).catch(() => undefined)
783  await refresh($, { force: true }).catch(() => undefined)
784}
785
786const phaseOf = (text: string): string | null => {
787  try {
788    return text ? (JSON.parse(text).phase ?? null) : null
789  } catch {
790    return null
791  }
792}
793
794/** One watch tick (every 3 s): compare against known-good, never re-baseline while watching. */
795async function integrityTick($: Dollar, force = false): Promise<string[]> {
796  if (engineInFlight > 0) {
797    tickDeferred = true
798    return []
799  }
800  // The watch runs for the whole session and never re-baselines from disk; idle, it is slower.
801  if (!force && !(await watching($))) {
802    const now = await $.clock.now()
803    if (now - lastIdleCheck < IDLE_MS) return []
804    lastIdleCheck = now
805  }
806  const { root } = await context($)
807  if (await $.fs.exists(`${root}/.workflow/.lock`)) return []
808  if (await otherBusy($)) return []
809  await baselineWatched($)
810  const changed: string[] = []
811  const { registries, lifecycles } = await watchedFiles($)
812  for (const path of registries) {
813    const disk = await readOr($, path)
814    const good = (await known($, path)) ?? ''
815    if (canon(parseRegistry(disk)) === canon(parseRegistry(good))) {
816      held.delete(path)
817      continue
818    }
819    const verdict = verifyRegistry(parseRegistry(good), parseRegistry(disk))
820    if (verdict.ok) continue
821    changed.push(path)
822    await onRegistryTamper($, path, verdict, parseRegistry(disk))
823  }
824  // A coordinator Bash call in flight attributes lifecycle changes when it ends.
825  if (mainBashInFlight === 0)
826    for (const path of lifecycles) {
827      if ((await readOr($, path)) === (await known($, path))) {
828        held.delete(path)
829        continue
830      }
831      changed.push(path)
832      await onLifecycleTamper($, path, 'not made by Stip or the coordinator in this session')
833    }
834  return changed
835}
836
837/** At the end of a coordinator Bash call: accept its lifecycle changes only when unambiguous and legal. */
838async function attributeCoordinatorBash($: Dollar, overlapped: boolean) {
839  const { lifecycles } = await watchedFiles($)
840  // A change the command re-created (a fresh lifecycle under a reused id) is bound first (AC-32).
841  await guardIdentities($, lifecycles.map(p => changeOfPath(p)!).filter(Boolean))
842  for (const path of lifecycles) {
843    const disk = await readOr($, path)
844    const good = (await known($, path))
845    if (good === undefined || disk === good) {
846      await setKnown($, path, disk)
847      continue
848    }
849    const { root } = await context($)
850    const id = /changes\/([a-z0-9-]+)\/state\.json$/.exec(path)?.[1]
851    // `archive` moves the change folder: its new phase is in .workflow/archive/<id>/state.json.
852    const moved = !disk && id ? phaseOf(await readOr($, `${root}/.workflow/archive/${id}/state.json`)) : null
853    const to = disk ? phaseOf(disk) : moved
854    if (overlapped) await onLifecycleTamper($, path, 'a worker ran during the coordinator command')
855    else if (!legalTransition(phaseOf(good), to)) await onLifecycleTamper($, path, `illegal phase move ${phaseOf(good)} -> ${to}`)
856    else await setKnown($, path, disk)
857  }
858}
859
860/** Flushes last tools recorded where the engine could not be called (re-entry path). */
861async function flushPendingTools($: Dollar) {
862  for (const [agentId, tool] of [...pendingTools]) {
863    pendingTools.delete(agentId)
864    const b = (await read($, workersAtom))[agentId]
865    if (!b || !ACTIVE_WORKER.includes(b.status) || !b.agentId) continue
866    await recordStep($, agentId, tool, {}, null)
867    await mutate($, [...settleArgs(b, 'running'), '--last-tool', tool.slice(0, 256)]).catch(err => log($, `flush ${b.taskId}: ${errorText(err)}`))
868  }
869}
870
871export function errorText(e: unknown): string {
872  return e instanceof Error ? e.message : String(e)
873}
874
875// --- Shared state (PluginState['stipulate'] in types/index.d.ts) ---
876
877export const snapshotAtom = atom({ plugin: 'stipulate', key: 'snapshot' } as const, null)
878export const settingsAtom = atom({ plugin: 'stipulate', key: 'settings' } as const, null)
879export const bindingAtom = atom({ plugin: 'stipulate', key: 'binding' } as const, null)
880export const workersAtom = atom({ plugin: 'stipulate', key: 'workers' } as const, {})
881export const bandHiddenAtom = atom({ plugin: 'stipulate', key: 'bandHidden' } as const, false)
882export const selectedTaskAtom = atom({ plugin: 'stipulate', key: 'selectedTask' } as const, null)
883export const settingsDraftAtom = atom({ plugin: 'stipulate', key: 'settingsDraft' } as const, null)
884export const ticketsAtom = atom({ plugin: 'stipulate', key: 'tickets' } as const, {})
885export const tamperedAtom = atom({ plugin: 'stipulate', key: 'tampered' } as const, {})
886export const justArchivedAtom = atom({ plugin: 'stipulate', key: 'justArchived' } as const, null)
887export const requestAtom = atom({ plugin: 'stipulate', key: 'request' } as const, null)
888export const replyAtom = atom({ plugin: 'stipulate', key: 'reply' } as const, null)
889
890// --- Toasts: short, coalesced; details go to a transcript notice the model does not read ---
891
892let toastState: CoalesceState = {}
893
894/** Shows `short` as a toast (≤60 chars, identical ones within 30 s coalesced as ×N) and appends `details`. */
895async function notify($: Dollar, key: string, short: string, details?: string, timeoutMs = 10_000) {
896  const r = coalesce(toastState, key, short, await $.clock.now())
897  toastState = r.state
898  try {
899    $.ui.toast(r.text, { timeoutMs })
900  } catch {}
901  // A repeat within the window is counted on the toast (×N); the transcript keeps the first notice only.
902  const text = details ? noticeText(details, (r.state[key]?.count ?? 1) > 1) : null
903  if (text)
904    await $.session
905      .append({ message: { type: 'system', content: [{ type: 'text', text }] } } as never)
906      .catch(() => undefined)
907}
908
909function statusLine($: Dollar, text: string) {
910  try {
911    $.ui.status(toastText(text))
912  } catch {}
913}
914
915// --- Module memory for the synchronous paths (re-entry .catch, integrity watch) ---
916
917/** agentId -> what a re-entry verdict needs; mirrors $.state workers. */
918const mirror = new Map<string, WorkerMirror>()
919/** Last tool per agent seen on a path that cannot call the engine; flushed by the timer. */
920const pendingTools = new Map<string, string>()
921let cachedRoot: string | null = null
922let cachedGate: { changeId: string | null; phase: string | null; approved: boolean; mode: 'off' | 'warn' | 'block' } = {
923  changeId: null,
924  phase: null,
925  approved: false,
926  mode: 'block',
927}
928
929function mirrorAll(map: Record<string, StipBinding>) {
930  mirror.clear()
931  for (const [id, b] of Object.entries(map))
932    mirror.set(id, { kind: b.kind, taskId: b.taskId, attempt: b.attempt, cwd: b.cwd, writePaths: b.writePaths, cancelled: b.status === 'cancelled' })
933}
934
935// --- Module-local caches (lost on hot reload; everything durable is in $.state or the registry) ---
936
937const notOurs = new Set<string>()
938const lastToolWrite = new Map<string, number>()
939const spawning = new Set<string>()
940let refreshing: Promise<StipSnapshot> | null = null
941let lastRefresh = 0
942let rebuilt = false
943
944const LAST_TOOL_INTERVAL = 10_000
945const HOST = 'claude-code'
946const STATE_PHASES = ['explore', 'validate']
947
948const jobKey = (b: { kind: string; changeId: string | null; taskId: string; attempt: number }) =>
949  `${b.kind}:${b.changeId ?? '-'}/${b.taskId}#${b.attempt}`
950
951function log($: Dollar, text: string) {
952  try {
953    $.ui.log(`${PLUGIN}: ${text}`, { to: 'debug' })
954  } catch {}
955}
956
957// --- Project reads (read-only; mutations go through the engine) ---
958
959async function readJson($: Dollar, path: string): Promise<any> {
960  if (!(await $.fs.exists(path))) return null
961  return JSON.parse(await ($.fs.read(path) as Promise<string>))
962}
963
964type ChangeBrief = { id: string; phase: string }
965
966async function listChanges($: Dollar): Promise<ChangeBrief[]> {
967  const { root } = await context($)
968  const dir = `${root}/.workflow/changes`
969  if (!(await $.fs.exists(dir))) return []
970  const out: ChangeBrief[] = []
971  for (const entry of await $.fs.list(dir)) {
972    if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.name) || entry.isLink) continue
973    try {
974      const s = await readJson($, `${dir}/${entry.name}/state.json`)
975      if (s && typeof s.phase === 'string' && s.phase !== 'archived') out.push({ id: entry.name, phase: s.phase })
976    } catch {}
977  }
978  return out.sort((a, b) => a.id.localeCompare(b.id))
979}
980
981async function registry($: Dollar, changeId: string): Promise<{ jobs: Job[] } | null> {
982  const { root } = await context($)
983  const data = await readJson($, `${root}/.workflow/.runtime/${changeId}/state.json`)
984  return data && Array.isArray(data.jobs) ? data : null
985}
986
987async function helperRegistry($: Dollar): Promise<Record<string, any>[]> {
988  const { root } = await context($)
989  const data = await readJson($, `${root}/.workflow/.runtime/engine-helpers.json`).catch(() => null)
990  return data && Array.isArray(data.jobs) ? data.jobs : []
991}
992
993const latestOf = (jobs: Job[], taskId: string) =>
994  jobs.filter(j => j.task_id === taskId).sort((a, b) => b.attempt - a.attempt)[0]
995
996// --- Binding: the change this session works on ---
997
998/**
999 * The bound change; auto-binds when exactly one non-archived change exists. When the bound change
1000 * was archived, it is remembered for the Archive state's summary, and auto-binding then waits for a
1001 * change opened after it (the changes open at that moment are not picked up on their own).
1002 */
1003async function boundChange($: Dollar): Promise<string | null> {
1004  const current = await read($, bindingAtom)
1005  const changes = await listChanges($)
1006  if (current && changes.some(c => c.id === current)) return current
1007  if (current) {
1008    const { root } = await context($)
1009    if (await $.fs.exists(`${root}/.workflow/archive/${current}`).catch(() => false))
1010      await update($, justArchivedAtom, () => ({ id: current, others: changes.map(c => c.id) }))
1011  }
1012  const archived = await read($, justArchivedAtom)
1013  const only = changes.length === 1 ? changes[0]!.id : null
1014  const next = only && !archived?.others.includes(only) ? only : null
1015  if (next !== current) await update($, bindingAtom, () => next)
1016  if (next && archived) await update($, justArchivedAtom, () => null)
1017  return next
1018}
1019
1020async function bindChange($: Dollar, id: string): Promise<StipSnapshot> {
1021  const changes = await listChanges($)
1022  if (!changes.some(c => c.id === id)) throw new Error(`Unknown or archived change: ${id}`)
1023  await update($, bindingAtom, () => id)
1024  await update($, justArchivedAtom, () => null)
1025  return refresh($, { force: true })
1026}
1027
1028// --- Settings ---
1029
1030async function scopePaths($: Dollar): Promise<Record<StipScope, string>> {
1031  const { root } = await context($)
1032  const personal = personalPath({
1033    CLAUDE_CONFIG_DIR: await $.env.get('CLAUDE_CONFIG_DIR'),
1034    HOME: await $.env.get('HOME'),
1035  })
1036  return { personal, project: `${root}/.workflow/config.json`, local: `${root}/.workflow/local.json` }
1037}
1038
1039async function rawScopes($: Dollar, paths: Record<StipScope, string>): Promise<Record<StipScope, string>> {
1040  const raw = {} as Record<StipScope, string>
1041  for (const scope of SCOPES) {
1042    const path = paths[scope]
1043    raw[scope] = (await $.fs.exists(path)) ? ((await $.fs.read(path)) as string) : ''
1044  }
1045  return raw
1046}
1047
1048// --- Project extensions (AC-46..AC-49): read here; changed only by the person's press ---
1049
1050/** The changes not yet archived that select each extension, from their state.json (file reads only). */
1051async function extensionUses($: Dollar): Promise<Record<string, string[]>> {
1052  const { root } = await context($)
1053  const dir = `${root}/.workflow/changes`
1054  const uses: Record<string, string[]> = {}
1055  if (!(await $.fs.exists(dir))) return uses
1056  for (const entry of await $.fs.list(dir)) {
1057    if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.name) || entry.isLink) continue
1058    const s = await readJson($, `${dir}/${entry.name}/state.json`).catch(() => null)
1059    if (!s || typeof s !== 'object' || s.phase === 'archived' || !Array.isArray(s.extensions)) continue
1060    for (const id of s.extensions) if (typeof id === 'string') (uses[id] ??= []).push(entry.name)
1061  }
1062  return uses
1063}
1064
1065/** `.workflow/config.json` as text ('' when missing) and parsed (null when unreadable). */
1066async function projectConfig($: Dollar): Promise<{ path: string; text: string; doc: unknown }> {
1067  const { root } = await context($)
1068  const path = `${root}/.workflow/config.json`
1069  const text = (await $.fs.exists(path)) ? ((await $.fs.read(path)) as string) : ''
1070  let doc: unknown = null
1071  try {
1072    doc = text ? JSON.parse(text) : null
1073  } catch {}
1074  return { path, text, doc }
1075}
1076
1077/** The project's extensions for the Settings tab, and the config revision a save is checked against. */
1078async function projectExtensions($: Dollar): Promise<Pick<StipSettingsView, 'extensions' | 'configRevision'>> {
1079  const { root } = await context($)
1080  const config = await projectConfig($)
1081  const dir = `${root}/.workflow/extensions`
1082  const manifests: Record<string, unknown> = {}
1083  if (await $.fs.exists(dir))
1084    for (const entry of await $.fs.list(dir)) {
1085      if (!EXTENSION_ID.test(entry.name) || entry.isLink) continue
1086      manifests[entry.name] = await readJson($, `${dir}/${entry.name}/extension.json`).catch(() => null)
1087    }
1088  return { extensions: projectExtensionsOf(config.doc, manifests, await extensionUses($)), configRevision: await sha256(config.text) }
1089}
1090
1091/**
1092 * AC-46: the bound change's selection, replaced as a whole through the engine's `select` (which
1093 * also withdraws the approval). Refused unless the change is exploring; the engine refuses an
1094 * extension that is not enabled. Reached only from a person's press on the pane (AC-49).
1095 */
1096async function selectExtensions($: Dollar, changeId: string, extensions: unknown) {
1097  if (!Array.isArray(extensions) || extensions.some(x => typeof x !== 'string' || !EXTENSION_ID.test(x)))
1098    throw new EngineError('extensions must be a list of extension ids')
1099  const ids = [...new Set(extensions as string[])]
1100  const status = await engine($, ['status', changeId, '--compact'])
1101  if (status.phase !== 'exploring')
1102    throw new EngineError(`${changeId} is ${status.phase ?? 'not open'}: its extensions change only while it is exploring.`)
1103  const result = await mutate($, ['select', changeId, ...ids.flatMap(id => ['--extension', id])])
1104  await refresh($, { force: true }).catch(() => undefined)
1105  return { change_id: changeId, phase: result.phase, extensions: Object.keys(result.extensions ?? {}) }
1106}
1107
1108/**
1109 * AC-47, AC-48: turns one project extension on or off in `.workflow/config.json` (only its `enabled`,
1110 * and `path` when newly enabled). A disable a change not yet archived selects is refused before
1111 * anything is written, naming it. Reached only from a person's press in Settings (AC-49).
1112 */
1113async function setExtension($: Dollar, id: string, enabled: unknown, revision: string): Promise<StipSettingsView> {
1114  if (!EXTENSION_ID.test(id)) throw new SettingsError(`Invalid extension id ${id}`)
1115  if (typeof enabled !== 'boolean') throw new SettingsError('enabled must be true or false')
1116  const { root } = await context($)
1117  const config = await projectConfig($)
1118  if ((await sha256(config.text)) !== revision) throw new SettingsError('The project config changed elsewhere; reload before saving.')
1119  const current = enabledOf(config.doc)[id] === true
1120  if (enabled === current) return loadSettings($)
1121  if (!enabled) {
1122    const refusal = disableRefusal(id, (await extensionUses($))[id] ?? [])
1123    if (refusal) throw new SettingsError(refusal)
1124  } else {
1125    const manifest = await readJson($, `${root}/${extensionPath(id)}/extension.json`).catch(() => null)
1126    if (!manifest || manifest.id !== id) throw new SettingsError(`${id} has no extension.json naming it in .workflow/extensions/${id}/`)
1127  }
1128  const request = { lock: `${root}/.workflow/.lock`, config: config.path, changes: `${root}/.workflow/changes`, revision, id, enabled, path: extensionPath(id) }
1129  const result = await $.process.run(['python3', '-c', EXTENSION_SCRIPT], { stdin: JSON.stringify(request), timeoutMs: 15_000 })
1130  if (result.exitCode !== 0) {
1131    let message = result.stderr.trim()
1132    try {
1133      const v = JSON.parse(message.split('\n').at(-1) ?? '') as { error?: string; users?: string[] }
1134      message = Array.isArray(v.users) ? (disableRefusal(id, v.users) ?? message) : (v.error ?? message)
1135    } catch {}
1136    throw new SettingsError(message || 'The extension setting could not be saved')
1137  }
1138  const view = await loadSettings($)
1139  await refresh($, { force: true }).catch(() => undefined)
1140  return view
1141}
1142
1143/** Reads the three scopes and publishes the settings view. */
1144async function loadSettings($: Dollar): Promise<StipSettingsView> {
1145  const paths = await scopePaths($)
1146  const view = { ...(await viewOf(await rawScopes($, paths), paths)), ...(await projectExtensions($).catch(() => ({}))) }
1147  await update($, settingsAtom, () => view)
1148  cachedGate = { ...cachedGate, mode: view.effective.applyGate }
1149  const motion = view.effective.motion
1150  await update($, snapshotAtom, s => (s && s.ui.motion !== motion ? { ...s, ui: { ...s.ui, motion } } : s))
1151  return view
1152}
1153
1154async function currentSettings($: Dollar): Promise<StipSettingsView> {
1155  return (await read($, settingsAtom)) ?? loadSettings($)
1156}
1157
1158/**
1159 * Saves one scope's clients["claude-code"] section after validating it and the merged result;
1160 * refuses when any scope file changed since `revision` was read.
1161 */
1162async function saveSettings($: Dollar, scope: StipScope, value: StipClientSettings, revision: string): Promise<StipSettingsView> {
1163  if (!SCOPES.includes(scope)) throw new SettingsError('Invalid settings scope')
1164  validateClient(value)
1165  const paths = await scopePaths($)
1166  const raw = await rawScopes($, paths)
1167  const docs = Object.fromEntries(SCOPES.map(s => [s, raw[s] ? JSON.parse(raw[s]) : {}])) as Record<StipScope, any>
1168  const byScope = Object.fromEntries(SCOPES.map(s => [s, docs[s]?.orchestration?.clients?.['claude-code'] ?? {}])) as Record<
1169    StipScope,
1170    StipClientSettings
1171  >
1172  byScope[scope] = value
1173  effective(byScope, { ...docs.project?.orchestration?.defaults, ...docs.local?.orchestration?.defaults })
1174  const { root } = await context($)
1175  const text = JSON.stringify(withClient(raw[scope] ? docs[scope] : null, scope, value), null, 2) + '\n'
1176  const request = { lock: `${root}/.workflow/.lock`, paths: SCOPES.map(s => paths[s]), revision, dest: paths[scope], scope, text }
1177  const result = await $.process.run(['python3', '-c', SAVE_SCRIPT], { stdin: JSON.stringify(request), timeoutMs: 15_000 })
1178  if (result.exitCode !== 0) {
1179    let message = result.stderr.trim()
1180    try {
1181      message = JSON.parse(message.split('\n').at(-1) ?? '').error ?? message
1182    } catch {}
1183    throw new SettingsError(message || 'Settings could not be saved')
1184  }
1185  const view = await loadSettings($)
1186  await registerAgentTypes($).catch(e => log($, `agent types: ${errorText(e)}`))
1187  return view
1188}
1189
1190// --- Agent types ---
1191
1192async function planOf($: Dollar, changeId: string): Promise<PlanTask[]> {
1193  const status = await engine($, ['status', changeId, '--compact'])
1194  return Array.isArray(status.plan?.tasks) ? status.plan.tasks : []
1195}
1196
1197/** Registers one agent type per role / phase-role in use, plus the research helper type. */
1198async function registerAgentTypes($: Dollar, plans?: PlanTask[]): Promise<string[]> {
1199  const view = await currentSettings($)
1200  const eff = view.effective
hooks/ui/index.tsx 94 lines
1// Stipulate interface: band (AC-16), /stip pane (AC-17), status line and toasts (AC-18),
2// context section (AC-19) and /stip-settings (AC-20). It reads PluginState['stipulate'] and acts
3// only through the core's `request` state (actions.ts); it never runs the engine itself while drawing (AC-21).
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { StipBinding, StipSnapshot } from '../../types'
8
9import { registerActions } from './actions.ts'
10import { registerBand } from './band.tsx'
11import * as motion from './motion.ts'
12import { SECTION_ID, fitStatus, uiToast, contextText, patchUi, statusText, statusUpdate, transitions, viewWidth, waitingTransitions } from './model.ts'
13import { registerPane } from './pane.tsx'
14import { registerSettings } from './settings.tsx'
15
16const snapshotAtom = atom({ plugin: 'stipulate', key: 'snapshot' } as const, null)
17const uiAtom = atom({ plugin: 'stipulate', key: 'selectedTask' } as const, null)
18
19/** True when the extension controls' state no longer applies: another change, or no longer exploring. */
20export const leftExploring = (prev: StipSnapshot | null, next: StipSnapshot | null) =>
21  prev !== null && prev.phase === 'exploring' && (next === null || next.changeId !== prev.changeId || next.phase !== 'exploring')
22
23/** The commands of a project that uses Stip (in a project without .workflow/ the core registers a read-only /stip). */
24async function registerCommands($: EngineInterface) {
25  await $.command.register({ name: 'stip', description: 'Open the Stipulate pane: stages, criteria, plan, workers and helpers' })
26  await $.command.register({ name: 'stip-settings', description: 'Edit Stipulate worker profiles for Claude Code (models, effort, isolation, limits, motion)' })
27}
28
29export const registerUi: Register = (on, options) => {
30  registerActions(on, options)
31  registerBand(on, options)
32  registerPane(on, options)
33  registerSettings(on, options)
34
35  // AC-18: every snapshot the core publishes goes through here; the previous value is the
36  // host's (`e.previous`), so transitions survive a hot reload of this module.
37  on('state.set', { plugin: 'stipulate', key: 'snapshot' }, async ($, e, next) => {
38    // AC-36: the one-shots this transition starts, read before the write lands so the redraw it
39    // causes already draws their first frame.
40    const before = (e.previous ?? null) as StipSnapshot | null
41    const after = (e.value ?? null) as StipSnapshot | null
42    motion.observe(before, after, await $.clock.now())
43    const done = await next(e)
44    // A refused or missed write changed nothing (the result may arrive wrapped as `{ value }`).
45    const result = (done as { value?: unknown }).value ?? done
46    const missed = 'deny' in done || (result as { isSet?: boolean }).isSet === false
47    if (!missed) {
48      const prev = (e.previous ?? null) as StipSnapshot | null
49      const snap = (e.value ?? null) as StipSnapshot | null
50      const line = statusUpdate(prev, snap)
51      if (line !== null) $.ui.status(fitStatus(line, viewWidth.columns))
52      // Set up during the session (.workflow/ created): the commands of a project that uses Stip.
53      if (prev?.setup && snap && !snap.setup) await registerCommands($).catch(() => undefined)
54      const now = await $.clock.now()
55      for (const text of transitions(prev, snap)) $.ui.toast(uiToast(text.split(' ')[0]!, text, now))
56      // The engine's reply and the open lists belong to that exploring change (AC-46): drop them.
57      if (leftExploring(prev, snap) || (prev && snap && prev.changeId !== snap.changeId))
58        await update($, uiAtom, raw => patchUi(raw, { extAdd: false, extAll: false, extReply: null, extReplyFor: null })).catch(() => undefined)
59    }
60    return done
61  }).catch(($, e, next) => next(e))
62
63  on('state.set', { plugin: 'stipulate', key: 'workers' }, async ($, e, next) => {
64    const done = await next(e)
65    if (!('deny' in done)) {
66      const now = await $.clock.now()
67      for (const text of waitingTransitions(e.previous as Record<string, StipBinding> | undefined, e.value as Record<string, StipBinding>)) $.ui.toast(uiToast(`wait:${text}`, text, now))
68    }
69    return done
70  }).catch(($, e, next) => next(e))
71
72  // The commands where the project uses Stip, and after a (re)load the status line from the
73  // snapshot held. (The matcher takes every session: one session.start without one is the core's.)
74  on('session.start', { isInteractive: [true, false] }, async ($, e, next) => {
75    motion.reset()
76    const started = await next(e)
77    try {
78      const root = await $.session.root()
79      if (await $.fs.exists(`${root}/.workflow`)) await registerCommands($)
80      $.ui.status(fitStatus(statusText(await read($, snapshotAtom)), viewWidth.columns))
81    } catch {}
82    return started
83  })
84
85  // AC-19: one short session section, last, only while a change is bound.
86  on('prompt.compose', async ($, e, next) => {
87    const composed = await next(e)
88    const text = contextText(await read($, snapshotAtom))
89    if (text === null) return composed
90    const sections = composed.sections.filter(s => s.id !== SECTION_ID)
91    return { ...composed, sections: [...sections, { id: SECTION_ID, text, scope: 'session' as const }] }
92  })
93}
94
hooks/core/brief.ts 153 lines
1// Worker briefs, agent-type definitions and spawn descriptions (pure).
2import { PLUGIN, TOOL_PREFIX, WORKER_FORBIDDEN_TOOLS } from './guards.ts'
3import { parseCriteria } from './criteria.ts'
4import type { ResolvedProfile } from './settings.ts'
5
6export const TOOL_NAMES = [
7  'stip_status',
8  'stip_research',
9  'stip_plan',
10  'stip_delegate',
11  'stip_interrupt',
12  'stip_contribution',
13  'stip_result',
14] as const
15
16const STIP_TOOLS = TOOL_NAMES.map(n => TOOL_PREFIX + n)
17
18export type PlanTask = {
19  id: string
20  role: string
21  phase: string
22  objective: string
23  criteria: string[]
24  depends_on: string[]
25  write_paths: string[]
26  read_paths: string[]
27  expected_output?: string
28}
29
30/** The spawn description that identifies an attempt in `$.agent.list()` (unique per attempt). */
31export const describeTask = (change: string, task: string, attempt: number) => `stip ${change}/${task}#${attempt}`
32export const describeHelper = (helperId: string) => `stip helper ${helperId}`
33
34export function parseDescription(text: string):
35  | { kind: 'task'; changeId: string; taskId: string; attempt: number }
36  | { kind: 'helper'; helperId: string }
37  | null {
38  const task = /^stip ([a-z0-9-]+)\/([A-Za-z0-9._-]+)#(\d+)$/.exec(text)
39  if (task) return { kind: 'task', changeId: task[1]!, taskId: task[2]!, attempt: Number(task[3]) }
40  const helper = /^stip helper (helper-[0-9a-f-]{36})$/.exec(text)
41  if (helper) return { kind: 'helper', helperId: helper[1]! }
42  return null
43}
44
45/** The full text of each requested criterion from spec.md (continuation lines and nested lists kept). */
46export function criteriaText(spec: string, ids: readonly string[]): string[] {
47  const all = new Map(parseCriteria(spec).map(c => [c.id, c.text]))
48  return ids.map(id => {
49    const text = all.get(id)
50    return text === undefined ? `${id}: (not found in spec.md; read the contract)` : `${id}: ${text}`
51  })
52}
53
54export const WORKER_RULES = [
55  'You are a Stipulate worker. Work only on the task in your brief.',
56  'Preserve changes made by other people and workers; never revert files you do not own.',
57  'Write only inside the write ownership your brief lists; everything else is read-only. Edits outside it are refused.',
58  'Do not delegate or spawn agents. Do not approve, check, document or archive the change.',
59  'Do not edit .workflow lifecycle or registry files and do not run the workflow engine.',
60  'Do not commit, push, stash, reset, switch branches or manage worktrees; the coordinator integrates your work.',
61  'Finish with a report: the paths you changed, the checks you ran with their results, and the gaps or risks that remain. Never claim your contribution is accepted.',
62].join('\n')
63
64export const HELPER_RULES = [
65  'You are a read-only Stipulate research helper. Answer the one bounded question you are given.',
66  'Do not edit files, run state-changing commands, delegate or touch the workflow. Bash runs inspection commands only.',
67  'Return concise findings with the evidence (paths, lines, sources) and the uncertainty that remains.',
68].join('\n')
69
70/** The `$.agent.register` spec of a worker type. */
71export function workerAgentSpec(profile: ResolvedProfile, role: string, phase: string) {
72  return {
73    name: profile.agentType,
74    description: `Stipulate ${role} worker${profile.agentType.includes('-') && profile.agentType !== role ? ` (${phase})` : ''}: runs one approved task; dispatched by stip_delegate only.`,
75    prompt: `${WORKER_RULES}\n\nYour role: ${role}. Your lifecycle phase: ${phase}.`,
76    disallowedTools: [...WORKER_FORBIDDEN_TOOLS, ...STIP_TOOLS],
77    ...(profile.model !== 'inherit' ? { model: profile.model } : { model: 'inherit' }),
78    ...(profile.effort !== 'inherit' ? { effort: profile.effort } : {}),
79  }
80}
81
82/** The `$.agent.register` spec of a read-only research helper type. */
83export function helperAgentSpec(profile: ResolvedProfile, role: string) {
84  return {
85    name: `helper-${role}`,
86    description: `Stipulate read-only ${role} helper: answers one bounded research question; dispatched by stip_research only.`,
87    prompt: `${HELPER_RULES}\n\nYour role: ${role}.`,
88    tools: ['Read', 'Glob', 'Grep', 'Bash', 'LSP', 'WebFetch', 'WebSearch', 'ToolSearch'],
89    disallowedTools: ['Edit', 'Write', 'NotebookEdit', ...WORKER_FORBIDDEN_TOOLS, ...STIP_TOOLS],
90    ...(profile.model !== 'inherit' ? { model: profile.model } : { model: 'inherit' }),
91    ...(profile.effort !== 'inherit' ? { effort: profile.effort } : {}),
92  }
93}
94
95export const agentTypeOf = (name: string) => `${PLUGIN}:${name}`
96
97export type BriefInput = {
98  changeId: string
99  task: PlanTask
100  attempt: number
101  root: string
102  cwd: string
103  isolated: boolean
104  criteria: string[]
105  extensions: string[]
106  instructions?: string
107  correction?: { reason: string | null } | null
108  /** Whether the worker runs as an existing agent type (rules are then only in the brief). */
109  foreignType: boolean
110}
111
112export function buildBrief(b: BriefInput): string {
113  const t = b.task
114  const docs = `${b.root}/.workflow/changes/${b.changeId}`
115  const sections = [
116    `# Stipulate task ${t.id} (attempt ${b.attempt}) of change ${b.changeId}`,
117    `Role: ${t.role}. Phase: ${t.phase}.`,
118    `## Objective\n${t.objective}`,
119    `## Acceptance criteria you serve\n${b.criteria.map(c => `- ${c}`).join('\n') || '- (none listed)'}`,
120    `## Contract\nRead ${docs}/spec.md and ${docs}/proposal.md before working. They are the approved contract; do not change them.`,
121    `## Write ownership\n${t.write_paths.length ? t.write_paths.map(p => `- ${p}`).join('\n') : 'READ ONLY: report findings; do not edit files.'}` +
122      (t.read_paths.length ? `\n\nRead context: ${t.read_paths.join(', ')}` : ''),
123    `## Working directory\n${b.cwd}\n` +
124      (b.isolated
125        ? `This is your own git worktree, a snapshot of the main checkout. Edit files only here (paths relative to it). The coordinator integrates accepted work into ${b.root}; edits elsewhere are refused.`
126        : `This is the shared main checkout. Other workers may be reading it; touch nothing outside your ownership.`),
127    `## Dependencies\n${t.depends_on.length ? `Accepted before you started: ${t.depends_on.join(', ')}.` : 'None.'}`,
128  ]
129  if (b.extensions.length)
130    sections.push(
131      `## Domain procedures\nFollow, where relevant to your task:\n${b.extensions.map(x => `- ${b.root}/.workflow/extensions/${x}/apply.md`).join('\n')}`,
132    )
133  if (b.correction)
134    sections.push(
135      `## Correction\nThis is a correction of an earlier attempt${b.correction.reason ? `, reviewed as: ${b.correction.reason}` : ''}. Fix what the review names within the same objective.`,
136    )
137  if (b.instructions?.trim()) sections.push(`## Coordinator notes\n${b.instructions.trim()}`)
138  sections.push(
139    `## Expected output\n${t.expected_output ?? 'Return: the paths you changed; the checks you ran and their results; remaining gaps and risks. Do not claim acceptance.'}`,
140  )
141  if (b.foreignType) sections.push(`## Rules\n${WORKER_RULES}`)
142  return sections.join('\n\n')
143}
144
145export function buildHelperBrief(question: string, role: string, phase: string, changeId: string | null, root: string): string {
146  return [
147    `# Stipulate research question (${role}, ${phase})`,
148    changeId ? `Context: change ${changeId} at ${root}/.workflow/changes/${changeId}/ (proposal.md, spec.md).` : `Project: ${root}.`,
149    `## Question\n${question.trim()}`,
150    `## Expected output\nFindings with evidence (paths, lines, sources), the uncertainty that remains, and nothing else. You are read-only.`,
151  ].join('\n\n')
152}
153
hooks/core/guards.ts 293 lines
1// Pure guard rules. Each returns null when the call may proceed, or the reason it is refused.
2// Paths reaching these functions are already resolved (symbolic links and `..` removed).
3import type { StipApplyGate, StipPhase } from '../../types'
4
5export const PLUGIN = 'stipulate'
6export const TOOL_PREFIX = `mcp__${PLUGIN}__`
7export const WRITE_TOOLS = ['Edit', 'Write', 'NotebookEdit'] as const
8/** Tools a worker never runs: delegation, worktree moves, workflows, Stip's own tools. */
9export const WORKER_FORBIDDEN_TOOLS = ['Agent', 'Task', 'Workflow', 'EnterWorktree', 'ExitWorktree', 'TeamCreate', 'TeamDelete']
10const APPROVED_PHASES: readonly string[] = ['approved', 'applying', 'checked', 'documented']
11
12export const deny = (rule: string, why: string) => `${PLUGIN}: ${rule} guard: ${why}`
13
14/** Collapses `.`, `..` and repeated separators of an absolute POSIX path. */
15export function normalize(path: string): string {
16  const out: string[] = []
17  for (const part of path.split('/')) {
18    if (part === '' || part === '.') continue
19    if (part === '..') out.pop()
20    else out.push(part)
21  }
22  return '/' + out.join('/')
23}
24
25/** Joins a possibly relative path onto a base directory and normalizes it. */
26export function absolute(base: string, path: string): string {
27  return normalize(path.startsWith('/') ? path : `${base}/${path}`)
28}
29
30/** The path relative to `base`, or null when it lies outside. */
31export function inside(base: string, path: string): string | null {
32  const b = normalize(base)
33  const p = normalize(path)
34  if (p === b) return ''
35  return p.startsWith(b === '/' ? '/' : b + '/') ? p.slice(b === '/' ? 1 : b.length + 1) : null
36}
37
38const head = (rel: string) => rel.split('/')[0]!.toLowerCase()
39
40export function owned(rel: string, writePaths: readonly string[]): boolean {
41  return writePaths.some(w => {
42    const prefix = w.replace(/\/+$/, '')
43    return rel === prefix || rel.startsWith(prefix + '/')
44  })
45}
46
47/** AC-11: a worker writes only inside its cwd, within write_paths, never under .workflow or .git. */
48export function workerWrite(real: string | null, cwd: string, writePaths: readonly string[]): string | null {
49  if (real === null) return deny('ownership', 'the target path cannot be resolved')
50  const rel = inside(cwd, real)
51  if (rel === null) return deny('ownership', `${real} is outside this worker's checkout ${cwd}`)
52  if (rel === '') return deny('ownership', 'the checkout root itself is not writable')
53  if (head(rel) === '.workflow' || head(rel) === '.git') return deny('ownership', `${rel} is managed workflow or git state`)
54  if (!writePaths.length) return deny('ownership', 'this task is read-only (no write_paths)')
55  if (!owned(rel, writePaths)) return deny('ownership', `${rel} is outside the task write_paths (${writePaths.join(', ')})`)
56  return null
57}
58
59/**
60 * A shell command with its quoting removed, so spellings like work''flow.py, "approve",
61 * \a\pprove or $'\x61pprove' are read as the shell would run them (best effort).
62 */
63export function normalizeCommand(command: string): string {
64  return command
65    .replace(/\$'((?:[^'\\]|\\.)*)'/g, (_, body: string) =>
66      body.replace(/\\x([0-9a-fA-F]{2})/g, (_m, h: string) => String.fromCharCode(parseInt(h, 16))).replace(/\\(.)/g, '$1'),
67    )
68    .replace(/\\\n/g, '')
69    .replace(/\\(.)/g, '$1')
70    .replace(/["']/g, '')
71}
72
73const ENGINE = /workflow\.py\b/
74/** The engine run as a module (`python3 -m scripts.workflow …`). */
75const ENGINE_MODULE = /(^|\s)-m\s+[\w.]*\bworkflow(\s|$)/
76/** An engine file name assembled from pieces inside inline code (`'work' + 'flow.py'`, `'workflow' + '.py'`). */
77const SPLIT_ENGINE = /\+\s*flow\.py|work\s*\+\s*flow|workflow\s*\+\s*\.py|\bflow\.py\b/
78/** A token naming the lifecycle engine: a path ending in workflow.py, or its module form. Not the `.workflow/` folder. */
79export const isEngine = (normalized: string) => ENGINE.test(normalized) || ENGINE_MODULE.test(normalized)
80
81/** True when a shell word with glob characters could expand to `target` (e.g. approv[e], work?low.py). */
82export function globCouldMatch(word: string, target: string): boolean {
83  if (!/[?*[]/.test(word)) return false
84  let re = ''
85  for (let i = 0; i < word.length; i++) {
86    const c = word[i]!
87    if (c === '?') re += '.'
88    else if (c === '*') re += '.*'
89    else if (c === '[') {
90      const end = word.indexOf(']', i + 1)
91      if (end < 0) re += '\\['
92      else {
93        re += '[' + word.slice(i + 1, end).replace(/^!/, '^').replace(/\\/g, '\\\\') + ']'
94        i = end
95      }
96    } else re += c.replace(/[.+^${}()|\\/]/g, m => '\\' + m)
97  }
98  try {
99    const base = target.split('/').pop()!
100    return new RegExp(`(^|/)${re}$`).test(target) || new RegExp(`^${re}$`).test(base)
101  } catch {
102    return true
103  }
104}
105
106const words = (c: string) => c.split(/[\s;&|()<>]+/).filter(Boolean)
107/** A glob that could name the engine or the approve subcommand. */
108const globbedEngine = (c: string) =>
109  words(c).some(w => {
110    const name = w.replace(/^.*\//, '')
111    // Only a glob that shares a literal fragment with the name is suspicious (not `*` or `*.py`).
112    const near = (stem: string) => name.split(/[?*]|\[[^\]]*\]/).some(part => part.length >= 3 && stem.includes(part.toLowerCase()))
113    return (globCouldMatch(name, 'workflow.py') && near('workflow')) || (globCouldMatch(name, 'approve') && near('approve'))
114  })
115/** Inline code or encoded payloads that can assemble a command no filter reads. */
116const ASSEMBLED = /(^|[\s;&|(])python[0-9.]*\s+(-[A-Za-z]*c\b|-\s*<<)|\bbase64\s+(-d|--decode)\b|\bxxd\s+-r\b/
117/** Paths of the plugin store (approval records). */
118export const STORE_PATH = /plugins\/store\b|stipulate_[A-Za-z0-9_-]*\.json/
119/** Shell features that can assemble an engine call this filter cannot read. */
120const INDIRECTION = /\$\{?[A-Za-z_@*0-9!#?-]|\$\(|`|(^|[\s;&|(])(eval|source|exec|xargs)(\s|$)|(^|[\s;&|(])\.\s+\S|\benv\s+(-[a-zA-Z]*[iu]|--unset|--ignore-environment)|\bunset\b|\bexport\s+-n\b/
121const APPROVE = /(^|[\s;&|(=])approve(\s|$|[;&|)])/
122const ACK = /(^|\s)--ack/
123const PYTHON = /(^|[\s;&|(/])(python[0-9.]*|py)(\s|$)|\.py\b/
124const GIT_FORBIDDEN = /\bgit\b(?:\s+(?:-[A-Za-z]\s+\S+|--?[A-Za-z][\w-]*(?:=\S+)?))*\s+(commit|push|worktree|reset|checkout|stash|switch|rebase|clean)\b/
125
126/**
127 * AC-11: worker Bash may not drive the lifecycle engine, rewrite git history/checkouts, or
128 * shed the STIP_WORKER marker the mod exports (which makes the engine refuse every mutation).
129 */
130export function workerBash(command: string): string | null {
131  const c = normalizeCommand(command)
132  if (isEngine(c) || SPLIT_ENGINE.test(c) || ACK.test(c))
133    return deny('lifecycle', 'workers cannot run the workflow engine; report to the coordinator instead')
134  if (/STIP_WORKER\s*=|export\s+-n|declare\s+[-+]x/.test(c) || /\benv\s+(-[a-zA-Z]*[iu]|--unset|--ignore-environment)/.test(c) || /\bunset\b|environ\b|putenv|unsetenv/.test(c))
135    return deny('lifecycle', 'workers cannot change the environment the mod sets (STIP_WORKER)')
136  if (globbedEngine(c)) return deny('lifecycle', 'globs that could name the workflow engine are refused')
137  if (STORE_PATH.test(c)) return deny('approval', 'the plugin store is not for workers')
138  if (/\bgit\b[^;&|]*\s-c\s+alias\./.test(c) || /\bgit\b(?:\s+-[A-Za-z]\s+\S+|\s+--?[A-Za-z][\w-]*(?:=\S+)?)*\s+\$/.test(c))
139    return deny('lifecycle', 'git aliases and variable subcommands are refused for workers')
140  const git = GIT_FORBIDDEN.exec(c)
141  if (git) return deny('lifecycle', `workers cannot run git ${git[1]}; the coordinator integrates and commits`)
142  return null
143}
144
145/** The prefix every worker Bash command runs under (engine refusal marker, no Python caches). */
146export function workerEnvPrefix(taskId: string, attempt: number): string {
147  const tag = `${taskId}#${attempt}`.replace(/[^A-Za-z0-9._#-]/g, '_')
148  return `export STIP_WORKER='${tag}' PYTHONDONTWRITEBYTECODE=1 PYTEST_ADDOPTS="-p no:cacheprovider"; `
149}
150const READ_ONLY_COMMANDS = new Set([
151  'ls', 'cat', 'head', 'tail', 'grep', 'egrep', 'fgrep', 'rg', 'find', 'wc', 'sort', 'uniq', 'cut', 'tr', 'file',
152  'stat', 'pwd', 'echo', 'printf', 'tree', 'du', 'diff', 'cmp', 'basename', 'dirname', 'realpath', 'which', 'jq',
153  'sed', 'awk', 'git', 'true', 'test', 'column', 'nl', 'less', 'more',
154])
155const READ_ONLY_GIT = new Set(['log', 'show', 'diff', 'status', 'blame', 'ls-files', 'grep', 'rev-parse', 'branch', 'tag', 'describe', 'shortlog', 'cat-file', 'ls-tree'])
156
157/** Research helpers are read-only: Bash runs only a conservative allow-list of inspection commands. */
158export function helperBash(command: string): string | null {
159  const refuse = (why: string) => deny('read-only helper', why)
160  if (/[`]|\$\(|>|<\(|\btee\b/.test(command)) return refuse('substitution and redirection are not allowed')
161  if (ENGINE.test(normalizeCommand(command))) return refuse('helpers cannot run the workflow engine')
162  for (const segment of command.split(/&&|\|\||[|;&\n]/)) {
163    const words = segment.trim().split(/\s+/).filter(Boolean)
164    if (!words.length) continue
165    let i = 0
166    while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!)) i++
167    const name = words[i]?.replace(/^.*\//, '')
168    if (!name) continue
169    if (!READ_ONLY_COMMANDS.has(name)) return refuse(`${name} is not an inspection command`)
170    const rest = words.slice(i + 1)
171    if (name === 'find' && rest.some(w => /^-(exec|execdir|ok|okdir|delete|fprint|fprintf|fls)$/.test(w)))
172      return refuse('find may not execute or delete')
173    if (name === 'sed' && rest.some(w => /^-(i|-in-place)/.test(w) || /^-[a-zA-Z]*i/.test(w))) return refuse('sed -i writes files')
174    if (name === 'awk' && /system\s*\(|print\s*>|getline/.test(segment)) return refuse('awk may not write or execute')
175    if (name === 'git') {
176      const sub = rest.find(w => !w.startsWith('-'))
177      if (!sub || !READ_ONLY_GIT.has(sub)) return refuse(`git ${sub ?? ''} is not read-only`)
178      if (sub === 'branch' || sub === 'tag') {
179        if (rest.some(w => /^-(d|D|m|M|c|C|f)$|^--(delete|move|copy|force)/.test(w)) || rest.filter(w => !w.startsWith('-')).length > 1)
180          return refuse(`git ${sub} may only list`)
181      }
182    }
183  }
184  return null
185}
186
187/**
188 * AC-14 (pre-hoc, best effort): the model never records approval itself. The post-hoc
189 * provenance check is the backstop for spellings this cannot read.
190 */
191export function mainBash(command: string): string | null {
192  const c = normalizeCommand(command)
193  const why = deny('approval', 'approval is a user action: the person runs /stip-approve or presses Approve')
194  if (ACK.test(c)) return why
195  const engine = isEngine(c)
196  if (APPROVE.test(c) && (engine || PYTHON.test(c))) return why
197  if (globbedEngine(c)) return deny('approval', 'globs that could name the engine or approve are refused; spell the command out')
198  if (STORE_PATH.test(c)) return deny('approval', 'the plugin store holds the person\'s approvals; it is not for the model')
199  if (/(^|[\s/])approv[^\s/]*\.(sh|py|bash|zsh)\b/.test(c)) return why
200  // Inline or decoded code is refused only next to the engine (or a name built to become it), an
201  // approval token, or a substitution/pipe that could assemble one. The `.workflow/` folder is not it.
202  const decoded = /\bbase64\s+(-d|--decode)\b|\bxxd\s+-r\b/.test(c) && /\|\s*((ba|z)?sh\b|python)/.test(c)
203  if (ASSEMBLED.test(c) && (engine || SPLIT_ENGINE.test(c) || /approv|(^|\s|['"])--ack/i.test(c) || /\$\(/.test(c) || decoded))
204    return deny('approval', 'inline or decoded code around the engine is refused; run the engine command literally')
205  if (engine && /(^|\s)runtime\s/.test(c) && !/(^|\s)runtime\s+show\b/.test(c))
206    return deny('registry', 'worker registry changes go through the stip_* tools (stip_delegate, stip_contribution, stip_interrupt), not the engine CLI')
207  if (engine && INDIRECTION.test(c))
208    return deny('approval', 'engine calls built from variables, eval, source, substitution or a stripped environment are refused; run the engine command literally')
209  return null
210}
211
212/** Edits of the plugin store (the person's approval records) are refused to every agent. */
213export function storeWrite(real: string | null): string | null {
214  return real !== null && STORE_PATH.test(real) ? deny('approval', 'the plugin store holds the person\'s approvals; it is not editable by agents') : null
215}
216
217/** AC-14: lifecycle and registry files change only through the engine. */
218export function protectedState(rel: string): string | null {
219  const parts = rel.split('/').map(p => p.toLowerCase())
220  if (parts[0] !== '.workflow') return null
221  if (parts[1] === '.runtime' || (parts[1] === '.lock' && parts.length === 2))
222    return deny('approval', `${rel} is engine state; use the stip_* tools`)
223  if ((parts[1] === 'changes' || parts[1] === 'archive') && parts.length === 4 && parts[3] === 'state.json')
224    return deny('approval', `${rel} is lifecycle state; it changes only through the engine`)
225  return null
226}
227
228export type GateInput = {
229  rel: string | null
230  changeId: string | null
231  phase: StipPhase | null
232  approvalCurrent: boolean
233  mode: StipApplyGate
234}
235
236/** True when the bound change has a current approval and has reached apply. */
237export function isApproved(phase: string | null, approvalCurrent: boolean): boolean {
238  return approvalCurrent && phase !== null && APPROVED_PHASES.includes(phase)
239}
240
241/**
242 * AC-13: with a bound, unapproved change, main-session edits inside the project are
243 * refused (block), allowed with a warning (warn) or untouched (off); the change's own
244 * documents under .workflow/changes/<id>/ stay editable. `rel` null = outside the project.
245 */
246export function applyGate(input: GateInput): { verdict: 'allow' | 'warn' | 'block'; reason?: string } {
247  if (input.mode === 'off' || !input.changeId || input.rel === null) return { verdict: 'allow' }
248  if (isApproved(input.phase, input.approvalCurrent)) return { verdict: 'allow' }
249  if (input.rel.startsWith(`.workflow/changes/${input.changeId}/`)) return { verdict: 'allow' }
250  const reason = deny(
251    'apply gate',
252    `change ${input.changeId} is ${input.phase ?? 'unknown'}${input.approvalCurrent ? '' : ' without a current approval'}; ` +
253      `edit its documents under .workflow/changes/${input.changeId}/ and have the user approve before editing ${input.rel || 'the project'}`,
254  )
255  return { verdict: input.mode === 'warn' ? 'warn' : 'block', reason }
256}
257
258/** The file a write tool targets, or null for other tools. */
259export function writeTarget(e: { tool: string } & Record<string, unknown>): string | null {
260  if (e.tool === 'Edit' || e.tool === 'Write') return typeof e.file_path === 'string' ? e.file_path : ''
261  if (e.tool === 'NotebookEdit') return typeof e.notebook_path === 'string' ? e.notebook_path : ''
262  return null
263}
264
265/** The minimum a re-entry verdict needs to know about a worker (kept in module memory). */
266export type WorkerMirror = {
267  kind: 'task' | 'helper'
268  taskId: string
269  attempt: number
270  cwd: string
271  writePaths: readonly string[]
272  cancelled: boolean
273}
274
275/**
276 * The worker rules decided synchronously from memory alone, for a call where the host raises the
277 * hook's `.catch` in its place (re-entry: its own `$` calls reject). Paths are checked lexically.
278 */
279export function reentryVerdict(w: WorkerMirror, e: { tool: string } & Record<string, unknown>): string | null {
280  if (w.cancelled) return deny('cancellation', `task ${w.taskId} was cancelled; stop now`)
281  if (WORKER_FORBIDDEN_TOOLS.includes(e.tool) || e.tool.startsWith(TOOL_PREFIX))
282    return deny('worker', `${e.tool} is not available to workers (no delegation, no lifecycle tools)`)
283  const target = writeTarget(e)
284  if (target !== null) {
285    if (w.kind === 'helper') return deny('read-only helper', 'helpers do not edit files')
286    const path = absolute(w.cwd, target)
287    return storeWrite(path) ?? workerWrite(target ? path : null, w.cwd, w.writePaths)
288  }
289  if (e.tool === 'Bash' && typeof e.command === 'string')
290    return workerBash(e.command) ?? (w.kind === 'helper' ? helperBash(e.command) : null)
291  return null
292}
293
hooks/core/settings.ts 590 lines
1// Worker settings for Claude Code: orchestration.clients["claude-code"] in three scopes
2// (personal <- project <- local), resolved defaults -> phase -> role -> phase-role.
3// The OpenCode plugin reads only clients.opencode and orchestration.defaults, so nothing
4// here is written outside clients["claude-code"].
5import type {
6  StipApplyGate,
7  StipMotion,
8  StipClientSettings,
9  StipEffectiveSettings,
10  StipProfile,
11  StipScope,
12  StipSettingsView,
13} from '../../types'
14
15export const PHASES = ['explore', 'validate', 'apply', 'check', 'docs'] as const
16export const MODEL_ALIASES = ['haiku', 'sonnet', 'opus', 'fable'] as const
17export const EFFORTS = ['inherit', 'low', 'medium', 'high', 'xhigh', 'max'] as const
18export const APPLY_GATES = ['off', 'warn', 'block'] as const
19export const MOTION_MODES: readonly StipMotion[] = ['auto', 'calm', 'off']
20export const SCOPES: readonly StipScope[] = ['personal', 'project', 'local']
21const PROFILE_KEYS = ['model', 'effort', 'agent', 'isolate']
22const DEFAULT_KEYS = [...PROFILE_KEYS, 'max_workers', 'max_shared_writers']
23const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
24const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:@/\-]{0,127}(\[1m\])?$/
25const AGENT_TYPE = /^[A-Za-z0-9][A-Za-z0-9_:.-]{0,127}$/
26
27export class SettingsError extends Error {}
28
29function fail(message: string): never {
30  throw new SettingsError(message)
31}
32
33const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x)
34
35function checkProfile(value: unknown, where: string, allowed: string[]): StipProfile {
36  if (!isObject(value)) fail(`${where}: a profile must be an object`)
37  for (const key of Object.keys(value))
38    if (!allowed.includes(key)) fail(`${where}: unknown field ${key}`)
39  const { model, effort, agent, isolate } = value
40  if (model !== undefined) {
41    if (typeof model !== 'string' || !(model === 'inherit' || (MODEL_ALIASES as readonly string[]).includes(model) || MODEL_ID.test(model)))
42      fail(`${where}: model must be inherit, ${MODEL_ALIASES.join(', ')} or a full model id`)
43  }
44  if (effort !== undefined && (typeof effort !== 'string' || !(EFFORTS as readonly string[]).includes(effort)))
45    fail(`${where}: effort must be one of ${EFFORTS.join(', ')}`)
46  if (agent !== undefined && (typeof agent !== 'string' || !AGENT_TYPE.test(agent) || agent.startsWith('stipulate:')))
47    fail(`${where}: agent must name an existing agent type (not a stipulate: type)`)
48  if (isolate !== undefined && typeof isolate !== 'boolean') fail(`${where}: isolate must be true or false`)
49  if (agent !== undefined && agent !== 'inherit' && effort !== undefined && effort !== 'inherit')
50    fail(`${where}: effort cannot be applied to an existing agent type; remove effort or agent`)
51  return value as StipProfile
52}
53
54function checkLimit(value: unknown, key: string) {
55  if (value !== undefined && !(Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 8))
56    fail(`defaults: ${key} must be an integer between 1 and 8`)
57}
58
59function checkRole(role: string, where: string) {
60  if (!SLUG.test(role)) fail(`${where}: invalid role ${role}`)
61  if (role === 'discovery' || role === 'coordinator') fail(`${where}: discovery stays with the coordinator`)
62}
63
64/** Validates one scope's clients["claude-code"] section; returns it unchanged or throws with the reason. */
65export function validateClient(value: unknown): StipClientSettings {
66  if (value === undefined || value === null) return {}
67  if (!isObject(value)) fail('clients["claude-code"] must be an object')
68  for (const key of Object.keys(value))
69    if (!['defaults', 'roles', 'phases', 'phase_roles', 'enforcement', 'ui'].includes(key)) fail(`unknown section ${key}`)
70  if (value.defaults !== undefined) {
71    const d = checkProfile(value.defaults, 'defaults', DEFAULT_KEYS) as Record<string, unknown>
72    checkLimit(d.max_workers, 'max_workers')
73    checkLimit(d.max_shared_writers, 'max_shared_writers')
74  }
75  if (value.roles !== undefined) {
76    if (!isObject(value.roles)) fail('roles must be an object')
77    for (const [role, p] of Object.entries(value.roles)) {
78      checkRole(role, 'roles')
79      checkProfile(p, `roles.${role}`, PROFILE_KEYS)
80    }
81  }
82  if (value.phases !== undefined) {
83    if (!isObject(value.phases)) fail('phases must be an object')
84    for (const [phase, p] of Object.entries(value.phases)) {
85      if (!(PHASES as readonly string[]).includes(phase)) fail(`phases: invalid phase ${phase}`)
86      checkProfile(p, `phases.${phase}`, PROFILE_KEYS)
87    }
88  }
89  if (value.phase_roles !== undefined) {
90    if (!isObject(value.phase_roles)) fail('phase_roles must be an object')
91    for (const [phase, roles] of Object.entries(value.phase_roles)) {
92      if (!(PHASES as readonly string[]).includes(phase)) fail(`phase_roles: invalid phase ${phase}`)
93      if (!isObject(roles)) fail(`phase_roles.${phase} must be an object`)
94      for (const [role, p] of Object.entries(roles)) {
95        checkRole(role, `phase_roles.${phase}`)
96        checkProfile(p, `phase_roles.${phase}.${role}`, PROFILE_KEYS)
97      }
98    }
99  }
100  if (value.enforcement !== undefined) {
101    if (!isObject(value.enforcement)) fail('enforcement must be an object')
102    for (const key of Object.keys(value.enforcement)) if (key !== 'apply_gate') fail(`enforcement: unknown field ${key}`)
103    const gate = value.enforcement.apply_gate
104    if (gate !== undefined && !(APPLY_GATES as readonly string[]).includes(gate as string))
105      fail('enforcement.apply_gate must be off, warn or block')
106  }
107  if (value.ui !== undefined) {
108    if (!isObject(value.ui)) fail('ui must be an object')
109    for (const key of Object.keys(value.ui)) if (key !== 'motion') fail(`ui: unknown field ${key}`)
110    const motion = value.ui.motion
111    if (motion !== undefined && !(MOTION_MODES as readonly unknown[]).includes(motion)) fail('ui.motion must be auto, calm or off')
112  }
113  return value as StipClientSettings
114}
115
116/** Deep merge of plain objects; later wins, arrays and scalars replace. */
117export function merge<T>(a: T, b: unknown): T {
118  if (!isObject(b)) return (b === undefined ? a : b) as T
119  const out: Record<string, unknown> = isObject(a) ? { ...a } : {}
120  for (const [k, v] of Object.entries(b)) {
121    if (k === '__proto__' || k === 'constructor' || k === 'prototype') fail('unsafe settings key')
122    out[k] = isObject(v) ? merge(out[k], v) : v
123  }
124  return out as T
125}
126
127/**
128 * Merges the scopes and checks combinations that only appear together.
129 * `shared` is orchestration.defaults from project/local (only its limits are read).
130 */
131export function effective(
132  byScope: Partial<Record<StipScope, StipClientSettings>>,
133  shared: { max_workers?: unknown; max_shared_writers?: unknown } = {},
134): StipEffectiveSettings {
135  let client: StipClientSettings = {}
136  for (const scope of SCOPES) client = merge(client, validateClient(byScope[scope]))
137  validateClient(client)
138  const pick = (key: 'max_workers' | 'max_shared_writers', fallback: number) => {
139    const own = client.defaults?.[key]
140    const value = own ?? (Number.isInteger(shared[key]) ? (shared[key] as number) : fallback)
141    if (!(Number.isInteger(value) && value >= 1 && value <= 8)) fail(`${key} must be between 1 and 8`)
142    return value
143  }
144  const limits = { max_workers: pick('max_workers', 2), max_shared_writers: pick('max_shared_writers', 1) }
145  if (limits.max_shared_writers > limits.max_workers)
146    fail(`max_shared_writers (${limits.max_shared_writers}) cannot exceed max_workers (${limits.max_workers})`)
147  if (limits.max_shared_writers > 1) {
148    const off: string[] = []
149    if (client.defaults?.isolate === false) off.push('defaults')
150    for (const [role, p] of Object.entries(client.roles ?? {})) if (p.isolate === false) off.push(`roles.${role}`)
151    for (const [phase, p] of Object.entries(client.phases ?? {})) if (p.isolate === false) off.push(`phases.${phase}`)
152    for (const [phase, roles] of Object.entries(client.phase_roles ?? {}))
153      for (const [role, p] of Object.entries(roles)) if (p.isolate === false) off.push(`phase_roles.${phase}.${role}`)
154    if (off.length)
155      fail(`Parallel writers (max_shared_writers > 1) need isolated worktrees; isolate is false in ${off.join(', ')}`)
156  }
157  const applyGate: StipApplyGate = client.enforcement?.apply_gate ?? 'block'
158  const motion: StipMotion = client.ui?.motion ?? 'auto'
159  return { client, limits, applyGate, motion }
160}
161
162export type ResolvedProfile = {
163  model: string
164  effort: string
165  agent: string | null
166  isolate: boolean
167  sources: Record<string, string>
168  /** The plugin agent type name (without the `stipulate:` prefix). */
169  agentType: string
170}
171
172/** The plugin agent type for a task: `<role>`, or `<phase>-<role>` when a phase layer applies. */
173export function agentTypeName(settings: StipEffectiveSettings, role: string, phase: string): string {
174  const c = settings.client
175  const phased = c.phases?.[phase] !== undefined || c.phase_roles?.[phase]?.[role] !== undefined
176  return phased ? `${phase}-${role}` : role
177}
178
179/**
180 * Resolves a worker profile: defaults -> phase -> role -> phase-role -> override.
181 * A layer that changes the model resets an effort no later layer sets (to inherit).
182 */
183export function resolveProfile(
184  settings: StipEffectiveSettings,
185  role: string,
186  phase: string,
187  options: { writing: boolean; override?: StipProfile } = { writing: false },
188): ResolvedProfile {
189  checkRole(role, 'role')
190  const c = settings.client
191  const layers: [string, StipProfile | undefined][] = [
192    ['defaults', c.defaults],
193    ['phase', c.phases?.[phase]],
194    ['role', c.roles?.[role]],
195    ['phase-role', c.phase_roles?.[phase]?.[role]],
196    ['task', options.override],
197  ]
198  const value = { model: 'inherit', effort: 'inherit', agent: null as string | null, isolate: undefined as boolean | undefined }
199  const sources: Record<string, string> = {}
200  for (const [source, p] of layers) {
201    if (!p) continue
202    if (p.model !== undefined && p.model !== value.model) {
203      value.effort = 'inherit'
204      sources.effort = source
205    }
206    if (p.model !== undefined) (value.model = p.model), (sources.model = source)
207    if (p.effort !== undefined) (value.effort = p.effort), (sources.effort = source)
208    if (p.agent !== undefined) (value.agent = p.agent === 'inherit' ? null : p.agent), (sources.agent = source)
209    if (p.isolate !== undefined) (value.isolate = p.isolate), (sources.isolate = source)
210  }
211  if (value.agent && value.effort !== 'inherit')
212    fail(`effort ${value.effort} cannot be applied to existing agent type ${value.agent}`)
213  const isolate = options.writing && (value.isolate ?? settings.limits.max_shared_writers > 1)
214  return { model: value.model, effort: value.effort, agent: value.agent, isolate, sources, agentType: agentTypeName(settings, role, phase) }
215}
216
217/** The path of the personal settings file under the user's Claude configuration directory. */
218export function personalPath(env: { CLAUDE_CONFIG_DIR?: string; HOME?: string }): string {
219  const base = env.CLAUDE_CONFIG_DIR || (env.HOME ? `${env.HOME}/.claude` : null)
220  if (!base) fail('Cannot locate the Claude configuration directory (HOME is unset)')
221  return `${base.replace(/\/+$/, '')}/stipulate.json`
222}
223
224/** Reads `orchestration.clients["claude-code"]` from a parsed scope document. */
225export function clientOf(doc: unknown): StipClientSettings {
226  if (!isObject(doc)) return {}
227  const o = doc.orchestration
228  if (!isObject(o) || !isObject(o.clients)) return {}
229  const c = o.clients['claude-code']
230  return (c ?? {}) as StipClientSettings
231}
232
233export function sharedDefaults(doc: unknown): { max_workers?: unknown; max_shared_writers?: unknown } {
234  if (!isObject(doc) || !isObject(doc.orchestration) || !isObject(doc.orchestration.defaults)) return {}
235  const d = doc.orchestration.defaults
236  return { max_workers: d.max_workers, max_shared_writers: d.max_shared_writers }
237}
238
239/** Returns the scope document with only clients["claude-code"] replaced. */
240export function withClient(doc: unknown, scope: StipScope, value: StipClientSettings): Record<string, unknown> {
241  const base: Record<string, unknown> = isObject(doc)
242    ? structuredClone(doc)
243    : scope === 'project'
244      ? { schema_version: 1, extensions: {}, settings: { require_user_approval: true } }
245      : {}
246  const orchestration = isObject(base.orchestration) ? base.orchestration : {}
247  const clients = isObject(orchestration.clients) ? orchestration.clients : {}
248  base.orchestration = { ...orchestration, clients: { ...clients, 'claude-code': value } }
249  return base
250}
251
252export async function sha256(text: string): Promise<string> {
253  const bytes = new TextEncoder().encode(text)
254  const hash = await crypto.subtle.digest('SHA-256', bytes)
255  return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, '0')).join('')
256}
257
258/** The optimistic revision over the three scope files' raw texts ('' when missing). */
259export async function revisionOf(raw: Record<StipScope, string>): Promise<string> {
260  return sha256(SCOPES.map(s => `${s}:${(raw[s] ? '1' : '0')}:${raw[s]}`).join('\u0000'))
261}
262
263/** Builds the settings view from the raw scope texts; never throws (the error is reported). */
264export async function viewOf(raw: Record<StipScope, string>, paths: Record<StipScope, string>): Promise<StipSettingsView> {
265  const revision = await revisionOf(raw)
266  const byScope = { personal: {}, project: {}, local: {} } as Record<StipScope, StipClientSettings>
267  const docs = {} as Record<StipScope, unknown>
268  let error: string | null = null
269  for (const scope of SCOPES) {
270    try {
271      docs[scope] = raw[scope] ? JSON.parse(raw[scope]) : {}
272      byScope[scope] = clientOf(docs[scope])
273    } catch (e) {
274      error = `${scope} settings (${paths[scope]}): ${(e as Error).message}`
275    }
276  }
277  let eff: StipEffectiveSettings
278  try {
279    if (error) throw new SettingsError(error)
280    eff = effective(byScope, merge(sharedDefaults(docs.project), sharedDefaults(docs.local)))
281  } catch (e) {
282    error = (e as Error).message
283    // Fail safe: an unreadable configuration keeps the strictest gate and the engine's limits.
284    eff = { client: {}, limits: { max_workers: 2, max_shared_writers: 1 }, applyGate: 'block', motion: 'auto' }
285  }
286  return { effective: eff, byScope, paths, revision, error }
287}
288
289/** Python run under the project lock: verify the revision, then replace the scope file atomically. */
290export const SAVE_SCRIPT = `
291import hashlib, json, os, sys, tempfile
292req = json.load(sys.stdin)
293lock = req['lock']
294os.makedirs(os.path.dirname(lock), exist_ok=True)
295try:
296    fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
297except FileExistsError:
298    print(json.dumps({'error': 'Workflow is locked by another operation; retry.'}), file=sys.stderr); sys.exit(1)
299try:
300    os.write(fd, b'stipulate-settings'); os.close(fd)
301    def raw(p):
302        try:
303            with open(p, 'rb') as f: return f.read().decode('utf-8')
304        except FileNotFoundError: return ''
305    texts = [raw(p) for p in req['paths']]
306    parts = [s + ':' + ('1' if t else '0') + ':' + t for s, t in zip(['personal', 'project', 'local'], texts)]
307    if hashlib.sha256('\\u0000'.join(parts).encode('utf-8')).hexdigest() != req['revision']:
308        print(json.dumps({'error': 'Settings changed elsewhere; reload before saving.'}), file=sys.stderr); sys.exit(1)
309    dest = req['dest']
310    if os.path.islink(dest):
311        print(json.dumps({'error': 'Settings destination is a symlink.'}), file=sys.stderr); sys.exit(1)
312    os.makedirs(os.path.dirname(dest), exist_ok=True)
313    fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest), prefix='.stip-settings-')
314    with os.fdopen(fd, 'w', encoding='utf-8') as f: f.write(req['text'])
315    os.chmod(tmp, 0o600 if req['scope'] == 'personal' else 0o644)
316    os.replace(tmp, dest)
317    print(json.dumps({'saved': True, 'path': dest}))
318finally:
319    try: os.unlink(lock)
320    except FileNotFoundError: pass
321`
322
323// --- Project extensions (AC-47, AC-48): `.workflow/config.json` `extensions.<id>`, nothing else ---
324
325/** An extension id as the engine accepts it (its `slug`). */
326export const EXTENSION_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
327
328/** Where a newly enabled extension's folder is recorded. */
329export const extensionPath = (id: string) => `.workflow/extensions/${id}`
330
331/** The enabled flags of a parsed config document, by id (an unreadable document enables nothing). */
332export function enabledOf(doc: unknown): Record<string, boolean> {
333  const out: Record<string, boolean> = {}
334  if (!isObject(doc) || !isObject(doc.extensions)) return out
335  for (const [id, entry] of Object.entries(doc.extensions)) out[id] = isObject(entry) && entry.enabled === true
336  return out
337}
338
339/**
340 * The Settings tab's list: every folder of `.workflow/extensions/` whose manifest names its own id,
341 * then configured ids without such a folder; each with its description, flag and the changes not
342 * yet archived that select it. Sorted by id.
343 */
344export function projectExtensionsOf(
345  doc: unknown,
346  manifests: Record<string, unknown>,
347  uses: Record<string, string[]>,
348): { id: string; description: string | null; enabled: boolean; present: boolean; selectedBy: string[] }[] {
349  const enabled = enabledOf(doc)
350  const present = Object.entries(manifests)
351    .filter(([id, m]) => EXTENSION_ID.test(id) && isObject(m) && m.id === id)
352    .map(([id]) => id)
353  const ids = [...new Set([...present, ...Object.keys(enabled).filter(id => EXTENSION_ID.test(id))])].sort()
354  return ids.map(id => {
355    const m = manifests[id]
356    const description = isObject(m) && typeof m.description === 'string' && m.description.trim() ? m.description.trim() : null
357    return { id, description, enabled: enabled[id] === true, present: present.includes(id), selectedBy: [...(uses[id] ?? [])].sort() }
358  })
359}
360
361/**
362 * The one refusal of AC-48: the changes not yet archived that select `id`, named; null when none.
363 * Checked before anything is written, and again under the lock by EXTENSION_SCRIPT.
364 */
365export function disableRefusal(id: string, users: string[]): string | null {
366  if (!users.length) return null
367  const list = [...users].sort()
368  return `${id} stays enabled: ${list.length === 1 ? 'change' : 'changes'} ${list.join(', ')} ${list.length === 1 ? 'selects' : 'select'} it. Remove it from ${list.length === 1 ? 'that change' : 'those changes'} while exploring, or archive ${list.length === 1 ? 'it' : 'them'}, first.`
369}
370
371/**
372 * Python run under the project lock (AC-47, AC-48): verify config.json is still the text the person
373 * saw, refuse a disable that a change not yet archived selects (re-read under the lock), then set
374 * only `extensions.<id>.enabled` (and `path` for a newly enabled one) and replace the file atomically.
375 * Every other key and value is kept, in order, and the file keeps its layout as far as `json` can
376 * write it: its indent (spaces or a tab, detected from the first indented line; one line stays one
377 * line), whether non-ASCII text is written raw or as \u escapes, and its final newline. Residual: a
378 * file mixing layouts is rewritten in the one detected, and spellings `json` normalizes (`\/`, `1E3`,
379 * escapes of ASCII characters, spaces before colons) come back in its standard form. Every failure
380 * is one line of JSON on stderr (`{"error": …}`), never a traceback.
381 */
382export const EXTENSION_SCRIPT = `
383import hashlib, json, os, sys, tempfile
384def out(error, **extra):
385    print(json.dumps({'error': error, **extra}), file=sys.stderr); sys.exit(1)
386try:
387    req = json.load(sys.stdin)
388    lock = req['lock']
389except Exception as e:
390    out('Invalid extension request: %s' % e)
391
392# A JSON reader that keeps where each value sits in the text, so the edit touches only those spans.
393WS = ' \\t\\r\\n'
394class Bad(Exception): pass
395def skip(t, i):
396    while i < len(t) and t[i] in WS: i += 1
397    return i
398def string_end(t, i):
399    i += 1
400    while i < len(t):
401        c = t[i]
402        if c == '\\\\': i += 2; continue
403        if c == '"': return i + 1
404        i += 1
405    raise Bad('unterminated string')
406def value(t, i):
407    i = skip(t, i)
408    if i >= len(t): raise Bad('unexpected end')
409    c = t[i]
410    if c == '{':
411        node = {'kind': 'object', 'start': i, 'members': []}
412        i = skip(t, i + 1)
413        if t[i:i + 1] == '}':
414            node['end'] = i + 1; return node
415        while True:
416            i = skip(t, i)
417            if t[i:i + 1] != '"': raise Bad('expected a key')
418            kend = string_end(t, i)
419            key = json.loads(t[i:kend])
420            kstart = i
421            i = skip(t, kend)
422            if t[i:i + 1] != ':': raise Bad('expected a colon')
423            v = value(t, i + 1)
424            node['members'].append({'key': key, 'kstart': kstart, 'kend': kend, 'value': v})
425            i = skip(t, v['end'])
426            if t[i:i + 1] == ',': i += 1; continue
427            if t[i:i + 1] == '}': node['end'] = i + 1; return node
428            raise Bad('expected , or }')
429    if c == '[':
430        i = skip(t, i + 1)
431        start = i - 1
432        if t[i:i + 1] == ']': return {'kind': 'array', 'start': start, 'end': i + 1}
433        while True:
434            v = value(t, i)
435            i = skip(t, v['end'])
436            if t[i:i + 1] == ',': i += 1; continue
437            if t[i:i + 1] == ']': return {'kind': 'array', 'start': start, 'end': i + 1}
438            raise Bad('expected , or ]')
439    if c == '"': return {'kind': 'string', 'start': i, 'end': string_end(t, i)}
440    j = i
441    while j < len(t) and t[j] not in WS + ',]}': j += 1
442    if j == i: raise Bad('unexpected character')
443    return {'kind': 'scalar', 'start': i, 'end': j}
444def member(node, key):
445    for m in node['members']:
446        if m['key'] == key: return m
447    return None
448
449try:
450    fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
451except FileExistsError:
452    out('Workflow is locked by another operation; retry.')
453except OSError as e:
454    out('The workflow lock could not be taken: %s' % e.strerror)
455try:
456    os.write(fd, b'stipulate-extensions'); os.close(fd)
457    dest = req['config']
458    if os.path.islink(dest):
459        out('The project config is a symlink.')
460    try:
461        with open(dest, 'rb') as f: text = f.read().decode('utf-8')
462        mode = os.stat(dest).st_mode & 0o7777
463    except FileNotFoundError:
464        out('The project config .workflow/config.json is missing; set Stip up again with /stip-bootstrap.')
465    except (OSError, UnicodeDecodeError) as e:
466        out('The project config could not be read: %s' % e)
467    if hashlib.sha256(text.encode('utf-8')).hexdigest() != req['revision']:
468        out('The project config changed elsewhere; reload before saving.')
469    bom = 1 if text.startswith('') else 0
470    try:
471        doc = json.loads(text[bom:])
472        root = value(text, bom)
473        if skip(text, root['end']) != len(text): raise Bad('extra text after the document')
474    except (ValueError, Bad) as e:
475        out('The project config is not valid JSON (%s); fix .workflow/config.json first.' % str(e).split('\\n')[0])
476    if not isinstance(doc, dict) or not isinstance(doc.get('extensions'), dict) or root['kind'] != 'object':
477        out('The project config has no extensions object.')
478    ident = req['id']
479    enabled = bool(req['enabled'])
480    if not enabled:
481        users = []
482        changes = req['changes']
483        for name in sorted(os.listdir(changes)) if os.path.isdir(changes) else []:
484            try:
485                with open(os.path.join(changes, name, 'state.json'), 'rb') as f: state = json.loads(f.read().decode('utf-8'))
486            except (OSError, ValueError):
487                continue
488            selected = state.get('extensions') if isinstance(state, dict) else None
489            if isinstance(selected, list) and state.get('phase') != 'archived' and ident in selected:
490                users.append(name)
491        if users:
492            out('in use', users=users)
493
494    # The file's own spelling of what is inserted: its line break, colon and indent unit.
495    nl = '\\r\\n' if '\\r\\n' in text else '\\n'
496    exts = member(root, 'extensions')
497    if exts is None or exts['value']['kind'] != 'object':
498        out('The project config has no extensions object.')
499    colon = text[exts['kend']:exts['value']['start']]
500    colon = colon.replace('\\r', '').replace('\\n', '')
501    if ':' not in colon: colon = ': '
502    def indent_of(pos):
503        line = text.rfind('\\n', 0, pos) + 1
504        lead = text[line:pos]
505        return lead if lead.strip() == '' else None
506    key_indent = indent_of(exts['kstart'])
507    obj = exts['value']
508    multiline = key_indent is not None and '\\n' in text[root['start']:root['end']]
509    def first_indent(node):
510        return indent_of(node['members'][0]['kstart']) if node['members'] else None
511    unit = None
512    if multiline:
513        unit = key_indent if key_indent else '  '
514        inner = first_indent(obj)
515        if inner is not None and inner.startswith(key_indent) and len(inner) > len(key_indent): unit = inner[len(key_indent):]
516    def spans_lines(node):
517        return '\\n' in text[node['start']:node['end']]
518    def insert(node, pairs, own_indent, multi):
519        """Where and what to add so object node ends with pairs, spelled like its members."""
520        texts = [json.dumps(k) + colon + v for k, v in pairs]
521        if node['members']:
522            last = node['members'][-1]
523            if len(node['members']) > 1:
524                gap = text[node['members'][-2]['value']['end']:last['kstart']]
525            else:
526                lead = text[node['start'] + 1:last['kstart']]
527                gap = ',' + lead if '\\n' in lead else (', ' if colon.endswith(' ') else ',')
528            return last['value']['end'], ''.join(gap + t for t in texts)
529        if multi and unit is not None:
530            inner = nl + own_indent + unit
531            return node['start'] + 1, inner + (',' + inner).join(texts) + nl + own_indent
532        return node['start'] + 1, (', ' if colon.endswith(' ') else ',').join(texts)
533    entry = member(obj, ident)
534    flag = 'true' if enabled else 'false'
535    path_text = json.dumps(req['path'])
536    edits = []
537    if entry is not None and entry['value']['kind'] == 'object':
538        node = entry['value']
539        en = member(node, 'enabled')
540        if en is not None:
541            edits.append((en['value']['start'], en['value']['end'], flag))
542        pairs = ([] if en is not None else [('enabled', flag)]) + ([('path', path_text)] if enabled and member(node, 'path') is None else [])
543        if pairs:
544            at, add = insert(node, pairs, indent_of(entry['kstart']) or '', spans_lines(obj))
545            edits.append((at, at, add))
546    elif enabled:
547        # A new entry: laid out like a sibling entry object when there is one, else like the file.
548        own = (indent_of(obj['members'][0]['kstart']) if obj['members'] else None)
549        if own is None: own = (key_indent or '') + (unit or '')
550        sibling = next((m['value'] for m in obj['members'] if m['value']['kind'] == 'object'), None)
551        multi = spans_lines(sibling) if sibling is not None else (spans_lines(obj) if obj['members'] else multiline)
552        empty = {'start': 0, 'members': []}
553        obj_text = '{' + insert(empty, [('enabled', 'true'), ('path', path_text)], own, multi and unit is not None)[1] + '}'
554        if entry is not None:
555            edits.append((entry['value']['start'], entry['value']['end'], obj_text))
556        else:
557            at, add = insert(obj, [(ident, obj_text)], key_indent or '', spans_lines(obj) or multiline)
558            edits.append((at, at, add))
559    else:
560        print(json.dumps({'saved': False, 'path': dest})); sys.exit(0)
561    new = text
562    for start, end, repl in sorted(edits, key=lambda e: e[0], reverse=True):
563        new = new[:start] + repl + new[end:]
564
565    # The edit must be exactly the intended change, or nothing is written.
566    expected = json.loads(text[bom:])
567    e = expected['extensions'].get(ident)
568    if isinstance(e, dict):
569        e['enabled'] = enabled
570        if enabled and 'path' not in e: e['path'] = req['path']
571    else:
572        expected['extensions'][ident] = {'enabled': True, 'path': req['path']}
573    try:
574        if json.loads(new[bom:]) != expected: raise Bad('mismatch')
575    except (ValueError, Bad):
576        out('The project config could not be edited in place; change extensions.%s by hand.' % ident)
577    fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest), prefix='.stip-config-')
578    with os.fdopen(fd, 'w', encoding='utf-8', newline='') as f: f.write(new)
579    os.chmod(tmp, mode)
580    os.replace(tmp, dest)
581    print(json.dumps({'saved': True, 'path': dest}))
582except SystemExit:
583    raise
584except Exception as e:
585    out('The extension setting could not be saved: %s: %s' % (type(e).__name__, str(e).split('\\n')[0]))
586finally:
587    try: os.unlink(lock)
588    except (NameError, FileNotFoundError): pass
589`
590
hooks/core/snapshot.ts 422 lines
1// Builds the shared snapshot from engine output (pure).
2import type {
3  StipBinding,
4  StipChangeRef,
5  StipGraph,
6  StipHelper,
7  StipNodeState,
8  StipJobStatus,
9  StipPhase,
10  StipSnapshot,
11  StipTask,
12  StipWorker,
13} from '../../types'
14import type { PlanTask } from './brief.ts'
15import { fallbackLabel } from './criteria.ts'
16
17export const ACTIVE: readonly string[] = ['starting', 'running', 'waiting', 'unknown', 'correction']
18export const TERMINAL: readonly string[] = ['returned', 'failed', 'cancelled']
19
20const ms = (iso: unknown): number | null => {
21  if (typeof iso !== 'string') return null
22  const t = Date.parse(iso)
23  return Number.isFinite(t) ? t : null
24}
25
26export type Job = Record<string, any> & {
27  task_id: string
28  attempt: number
29  status: StipJobStatus
30  acceptance: 'pending' | 'accepted' | 'rejected'
31}
32
33/** The model and effort a job runs with: the host-reported model, else its recorded launch profile's. */
34export function profileOf(job: Record<string, any>): { model: string | null; effort: string | null } {
35  const p = job.profile && typeof job.profile === 'object' ? job.profile : {}
36  const recorded = typeof p.model === 'string' && p.model && p.model !== 'inherit' ? p.model : null
37  return {
38    model: typeof job.actual_model === 'string' && job.actual_model ? job.actual_model : recorded,
39    effort: typeof p.effort === 'string' && p.effort ? p.effort : null,
40  }
41}
42
43const base = (path: unknown): string | null => {
44  if (typeof path !== 'string' || !path.trim()) return null
45  const name = path.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? ''
46  return name ? (name.length > 32 ? `${name.slice(0, 31)}…` : name) : null
47}
48
49/** A short phrase for a tool call ("writing README.md", "running pytest"), at most 40 characters. */
50export function activityOf(tool: string, input: Record<string, unknown> = {}): string {
51  const file = base(input.file_path ?? input.notebook_path ?? input.path)
52  let out: string
53  switch (tool) {
54    case 'Write':
55      out = file ? `writing ${file}` : 'writing'
56      break
57    case 'Edit':
58    case 'MultiEdit':
59    case 'NotebookEdit':
60      out = file ? `editing ${file}` : 'editing'
61      break
62    case 'Read':
63      out = file ? `reading ${file}` : 'reading'
64      break
65    case 'Grep':
66    case 'Glob':
67      out = 'searching'
68      break
69    case 'WebFetch':
70    case 'WebSearch':
71      out = 'browsing'
72      break
73    case 'TodoWrite':
74      out = 'planning'
75      break
76    case 'Bash': {
77      // The program, skipping variable assignments and wrappers (cd x && …, env, sudo-like prefixes).
78      const words = String(input.command ?? '')
79        .split(/&&|\|\||;|\|/)
80        .map(part => part.trim().split(/\s+/).filter(w => w && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)))
81        .find(ws => ws.length && !['cd', 'export', 'set'].includes(ws[0]!))
82      const skip = new Set(['env', 'command', 'exec', 'time', 'nice', 'npx', 'uv', 'poetry', 'pipenv'])
83      let i = 0
84      while (words && i < words.length - 1 && (skip.has(words[i]!) || (words[i] === 'run' && i > 0))) i++
85      let prog = words?.[i]?.split('/').pop() ?? ''
86      if (/^python[0-9.]*$/.test(prog) && words?.[i + 1] === '-m' && words[i + 2]) prog = words[i + 2]!
87      else if (/^python[0-9.]*$/.test(prog) && words?.[i + 1] && !words[i + 1]!.startsWith('-')) prog = base(words[i + 1]) ?? prog
88      else if (['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'go', 'make', 'git'].includes(prog) && words?.[i + 1] && !words[i + 1]!.startsWith('-'))
89        prog = `${prog} ${words[i + 1] === 'run' && words[i + 2] ? words[i + 2] : words[i + 1]}`
90      out = prog ? `running ${prog.replace(/['"`]/g, '')}` : 'running a command'
91      break
92    }
93    default:
94      out = tool.startsWith('mcp__') ? tool.split('__').pop()! : tool
95  }
96  return out.length > 40 ? `${out.slice(0, 39)}…` : out
97}
98
99const TEST_RUNNERS = /^(pytest|py\.test|unittest|tox|nox|vitest|jest|mocha|ava|playwright|go test|cargo test|npm test|pnpm test|yarn test|bun test|npm run test|mix test|swift test|rspec|phpunit|node --test|ctest|make test|make check)$/
100
101/**
102 * A short human phrase for one step of a worker's trail ("Reading api.py", "Running tests",
103 * "Searching the code"): capitalized, never a bare tool name (unknown tools read "Using <name>").
104 */
105export function stepPhrase(tool: string, input: Record<string, unknown> = {}): string {
106  let out: string
107  switch (tool) {
108    case 'Grep':
109    case 'Glob':
110      out = 'Searching the code'
111      break
112    case 'WebSearch':
113      out = 'Searching the web'
114      break
115    case 'WebFetch': {
116      const host = /^https?:\/\/([^/:?#]+)/.exec(String(input.url ?? ''))?.[1]
117      out = host ? `Reading ${host.replace(/^www\./, '')}` : 'Reading a web page'
118      break
119    }
120    case 'TodoWrite':
121    case 'TaskCreate':
122    case 'TaskUpdate':
123      out = 'Updating its plan'
124      break
125    case 'Agent':
126    case 'Task':
127      out = 'Delegating'
128      break
129    case 'Skill':
130      out = 'Using a skill'
131      break
132    case 'ToolSearch':
133      out = 'Loading tools'
134      break
135    case 'LS':
136      out = 'Listing files'
137      break
138    case 'Bash': {
139      const a = activityOf(tool, input)
140      const prog = a.startsWith('running ') ? a.slice(8) : ''
141      const cmd = String(input.command ?? '')
142      out = TEST_RUNNERS.test(prog) || /\b(pytest|unittest|vitest|jest|go test|cargo test|(npm|pnpm|yarn|bun)( run)? test)\b/.test(cmd) ? 'Running tests' : prog ? `Running ${prog}` : 'Running a command'
143      break
144    }
145    default: {
146      if (['Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'].includes(tool)) {
147        const a = activityOf(tool, input)
148        out = a.charAt(0).toUpperCase() + a.slice(1)
149        break
150      }
151      const name = tool.startsWith('mcp__') ? (tool.split('__')[1] ?? tool).replace(/[_-]+/g, ' ') : tool.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase()
152      out = `Using ${name}`
153    }
154  }
155  return out.length > 40 ? `${out.slice(0, 39)}…` : out
156}
157
158const STATUS_WORDS: Partial<Record<StipJobStatus, string>> = {
159  queued: 'queued',
160  starting: 'starting',
161  waiting: 'waiting on permission',
162  unknown: 'launch unconfirmed',
163  correction: 'correcting',
164}
165
166/** What an active job is doing: the waiting state first, then the live phrase, the last tool, the status. */
167function activityNow(status: StipJobStatus, live: StipBinding | undefined, lastTool: string | null): string | null {
168  if (!ACTIVE.includes(status) && status !== 'queued') return null
169  if (status === 'waiting' || status === 'unknown') return STATUS_WORDS[status]!
170  return live?.activity ?? lastTool ?? STATUS_WORDS[status] ?? status
171}
172
173export function workerOf(changeId: string, job: Job, now: number, live?: StipBinding): StipWorker {
174  const startedAt = ms(job.confirmed_at) ?? ms(job.created_at)
175  const finishedAt = TERMINAL.includes(job.status) ? (ms(job.finished_at) ?? ms(job.updated_at)) : null
176  const { model, effort } = profileOf(job)
177  const lastTool = live?.lastTool ?? (typeof job.last_tool === 'string' ? job.last_tool : null)
178  return {
179    changeId,
180    taskId: job.task_id,
181    attempt: job.attempt,
182    role: String(job.role ?? ''),
183    phase: String(job.phase ?? ''),
184    status: job.status,
185    acceptance: job.acceptance,
186    agentId: typeof job.session_id === 'string' ? job.session_id : null,
187    lastTool,
188    startedAt,
189    finishedAt,
190    elapsedMs: startedAt === null ? null : Math.max(0, (finishedAt ?? now) - startedAt),
191    hasResult: typeof job.result_path === 'string',
192    isolated: job.isolated === true,
193    integrated: job.integrated === true,
194    model,
195    effort,
196    activity: activityNow(job.status, live, lastTool),
197    steps: live?.steps ? [...live.steps] : [],
198    error: typeof job.error === 'string' ? job.error : null,
199    reviewReason: typeof job.review?.reason === 'string' ? job.review.reason : null,
200  }
201}
202
203export function helperOf(job: Record<string, any>, now: number, live?: StipBinding): StipHelper {
204  const startedAt = ms(job.confirmed_at) ?? ms(job.created_at)
205  const { model, effort } = profileOf(job)
206  const lastTool = live?.lastTool ?? (typeof job.last_tool === 'string' ? job.last_tool : null)
207  return {
208    helperId: String(job.helper_id),
209    changeId: typeof job.change_id === 'string' ? job.change_id : null,
210    role: String(job.role ?? ''),
211    phase: String(job.phase ?? ''),
212    question: String(job.objective ?? '').slice(0, 500),
213    status: job.status,
214    agentId: typeof job.session_id === 'string' ? job.session_id : null,
215    hasResult: typeof job.result_path === 'string',
216    startedAt,
217    finishedAt: TERMINAL.includes(job.status) ? (ms(job.finished_at) ?? now) : null,
218    error: typeof job.error === 'string' ? job.error : null,
219    model,
220    effort,
221    activity: activityNow(job.status, live, lastTool),
222    steps: live?.steps ? [...live.steps] : [],
223    title: fallbackLabel(String(job.objective ?? '')),
224    titleSource: 'fallback',
225  }
226}
227
228function lastRebaseOf(registry: Record<string, any> | null | undefined) {
229  const r = Array.isArray(registry?.rebases) ? registry!.rebases.at(-1) : null
230  if (!r || typeof r !== 'object') return null
231  const list = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [])
232  return {
233    from: typeof r.from === 'string' ? r.from : null,
234    to: String(r.to ?? ''),
235    at: String(r.at ?? ''),
236    reason: typeof r.reason === 'string' ? r.reason : null,
237    kept: list(r.kept),
238    reopened: list(r.reopened),
239    retired: list(r.retired),
240    retiredIntegratedPaths: pathsByTask(r.retired_integrated_paths),
241  }
242}
243
244/** `{task: [paths]}` as the engine records it, keeping only well-formed entries. */
245export function pathsByTask(value: unknown): Record<string, string[]> {
246  const out: Record<string, string[]> = {}
247  if (!value || typeof value !== 'object' || Array.isArray(value)) return out
248  for (const [task, paths] of Object.entries(value as Record<string, unknown>))
249    if (Array.isArray(paths)) out[task] = paths.filter((p): p is string => typeof p === 'string')
250  return out
251}
252
253/** The graph state of one task (see StipNodeState). */
254export function nodeState(t: StipTask): StipNodeState {
255  const w = t.latest
256  if (!w) return t.ready ? 'ready' : 'blocked'
257  if (ACTIVE.includes(w.status)) return w.attempt > 1 || w.status === 'correction' ? 'correction' : 'running'
258  if (w.acceptance === 'accepted') return t.awaitingIntegration || (w.isolated && !w.integrated) ? 'pending' : 'integrated'
259  if (w.acceptance === 'rejected') return 'correction'
260  if (w.status === 'returned') return 'pending'
261  if (w.status === 'failed' || w.status === 'cancelled') return 'failed'
262  return t.ready ? 'ready' : 'blocked'
263}
264
265/** The plan's dependency graph: one node per task (plan order), edges from each dependency to its dependant. */
266export function graphOf(tasks: StipTask[]): StipGraph {
267  const byId = new Map(tasks.map(t => [t.id, t]))
268  const depth = new Map<string, number>()
269  const depthOf = (id: string, seen: Set<string>): number => {
270    const known = depth.get(id)
271    if (known !== undefined) return known
272    if (seen.has(id)) return 0
273    seen.add(id)
274    const deps = (byId.get(id)?.dependsOn ?? []).filter(d => byId.has(d))
275    const d = deps.length ? 1 + Math.max(...deps.map(x => depthOf(x, seen))) : 0
276    depth.set(id, d)
277    return d
278  }
279  const nodes = tasks.map(t => ({
280    id: t.id,
281    role: t.role,
282    phase: t.phase,
283    state: nodeState(t),
284    attempt: t.latest?.attempt ?? null,
285    depth: depthOf(t.id, new Set()),
286    dependsOn: [...t.dependsOn],
287  }))
288  const state = new Map(nodes.map(n => [n.id, n.state]))
289  const edges = tasks.flatMap(t => t.dependsOn.filter(d => byId.has(d)).map(d => ({ from: d, to: t.id, done: state.get(d) === 'integrated' })))
290  return { nodes, edges }
291}
292
293/** The Ledger's state fields before computeSnapshot fills them (see StipSnapshot). */
294export function ledgerDefaults(): Pick<StipSnapshot, 'setup' | 'start' | 'explore' | 'contractDiff' | 'check' | 'archived' | 'ui'> {
295  return {
296    setup: null,
297    start: { followUps: [], specs: [] },
298    explore: null,
299    contractDiff: null,
300    check: { reviewer: null, criterion: null, reportAt: null },
301    archived: null,
302    ui: { motion: 'auto', typing: false },
303  }
304}
305
306export function emptySnapshot(changes: StipChangeRef[], now: number, changeId: string | null = null, error: string | null = null): StipSnapshot {
307  return {
308    ...ledgerDefaults(),
309    changeId,
310    phase: null,
311    schemaVersion: null,
312    approval: { current: false, digest: null, by: null, adopted: false },
313    approvalUnconfirmed: false,
314    registryCurrent: true,
315    orphanTasks: [],
316    lastRebase: null,
317    tampered: [],
318    restoreHeld: [],
319    extensions: [],
320    enabledExtensions: [],
321    tasks: [],
322    workers: [],
323    helpers: [],
324    graph: { nodes: [], edges: [] },
325    ready: [],
326    awaitingReview: [],
327    awaitingIntegration: [],
328    active: { change: 0, helpers: 0, total: 0, writers: 0 },
329    limits: { max_workers: 2, max_shared_writers: 1 },
330    changes,
331    archivedCount: 0,
332    criteria: [],
333    error,
334    refreshedAt: now,
335  }
336}
337
338/**
339 * `status` is `status <id> --compact`; `show` is `runtime show <id>` (null for a v1 change);
340 * `stateApproval` is the approval record of state.json when known.
341 */
342export function buildSnapshot(input: {
343  changeId: string
344  status: Record<string, any>
345  show: Record<string, any> | null
346  helpers: Record<string, any>[]
347  live: Record<string, StipBinding>
348  changes: StipChangeRef[]
349  now: number
350  approvedBy?: string | null
351  /** The digest the person approved through Stip ($.store), or null. */
352  userDigest?: string | null
353  /** The person's record was adopted from an approval that predates the mod. */
354  userAdopted?: boolean
355  error?: string | null
356}): StipSnapshot {
357  const { changeId, status, show, now } = input
358  const plan: PlanTask[] = Array.isArray(status.plan?.tasks) ? status.plan.tasks : []
359  const jobs: Job[] = Array.isArray(show?.registry?.jobs) ? show!.registry.jobs : []
360  const latest: Record<string, Job> = show?.latest ?? {}
361  const live = Object.values(input.live)
362  const liveOf = (j: Job) =>
363    live.find(b => b.kind === 'task' && b.changeId === changeId && b.taskId === j.task_id && b.attempt === j.attempt)
364  const liveHelper = (h: Record<string, any>) => live.find(b => b.kind === 'helper' && b.helperId === h.helper_id)
365  const ready: string[] = Array.isArray(show?.ready) ? show!.ready : []
366  const awaitingReview: string[] = Array.isArray(show?.awaiting_review) ? show!.awaiting_review : []
367  const awaitingIntegration: string[] = Array.isArray(show?.awaiting_integration) ? show!.awaiting_integration : []
368  const tasks: StipTask[] = plan.map(t => ({
369    id: t.id,
370    role: t.role,
371    phase: t.phase,
372    objective: t.objective,
373    criteria: t.criteria ?? [],
374    dependsOn: t.depends_on ?? [],
375    writePaths: t.write_paths ?? [],
376    ready: ready.includes(t.id),
377    awaitingReview: awaitingReview.includes(t.id),
378    awaitingIntegration: awaitingIntegration.includes(t.id),
379    latest: latest[t.id] ? workerOf(changeId, latest[t.id]!, now, liveOf(latest[t.id]!)) : null,
380  }))
381  const workers = jobs
382    .filter(j => latest[j.task_id]?.attempt === j.attempt)
383    .map(j => workerOf(changeId, j, now, liveOf(j)))
384    .sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0))
385  return {
386    changeId,
387    phase: (status.phase ?? null) as StipPhase | null,
388    schemaVersion: typeof status.schema_version === 'number' ? status.schema_version : null,
389    // An engine approval counts only when it is the one the person gave through Stip (AC-14).
390    approval: {
391      current: status.approval_current === true && input.userDigest === status.contract_digest,
392      digest: typeof status.contract_digest === 'string' ? status.contract_digest : null,
393      by: input.approvedBy ?? null,
394      adopted: status.approval_current === true && input.userDigest === status.contract_digest && input.userAdopted === true,
395    },
396    approvalUnconfirmed: status.approval_current === true && input.userDigest !== status.contract_digest,
397    registryCurrent: show?.registry_current !== false,
398    orphanTasks: Array.isArray(show?.orphan_tasks) ? show!.orphan_tasks : [],
399    lastRebase: lastRebaseOf(show?.registry),
400    tampered: [],
401    restoreHeld: [],
402    extensions: Array.isArray(status.extensions) ? status.extensions : [],
403    // Filled from the project config by computeSnapshot.
404    enabledExtensions: [],
405    tasks,
406    workers,
407    helpers: input.helpers.filter(h => h.change_id === changeId || h.change_id === null).map(h => helperOf(h, now, liveHelper(h))),
408    graph: graphOf(tasks),
409    ready,
410    awaitingReview,
411    awaitingIntegration,
412    active: show?.active ?? { change: 0, helpers: 0, total: 0, writers: 0 },
413    limits: show?.limits ?? { max_workers: 2, max_shared_writers: 1 },
414    changes: input.changes,
415    archivedCount: 0,
416    criteria: [],
417    ...ledgerDefaults(),
418    error: input.error ?? (typeof status.runtime?.error === 'string' ? status.runtime.error : null),
419    refreshedAt: now,
420  }
421}
422
hooks/core/ledger.ts 491 lines
1// The Ledger's pane states (AC-35): pure readers of the workflow files the snapshot shows in each
2// state (Setup, New change, Explore, Validate, Check, Archive). No `$` here: index.ts reads the files.
3import type {
4  StipAcceptedSpec,
5  StipArchiveSummary,
6  StipContractDiff,
7  StipDecision,
8  StipExplore,
9  StipFinding,
10  StipFollowUp,
11  StipMotion,
12  StipProof,
13  StipSetup,
14  StipStep,
15} from '../../types'
16import { fallbackLabel, parseCriteria, parseEvidence } from './criteria.ts'
17
18export const MOTIONS: readonly StipMotion[] = ['auto', 'calm', 'off']
19export const STEP_LIMIT = 6
20
21// --- Markdown sections ---
22
23type Section = { title: string; level: number; lines: string[] }
24
25/** The `##`+ sections of a markdown document, each with its lines up to the next heading of any level. */
26export function sections(md: string): Section[] {
27  const out: Section[] = []
28  let cur: Section | null = null
29  let fence = false
30  for (const line of md.replace(/\r\n?/g, '\n').split('\n')) {
31    if (/^\s*(```|~~~)/.test(line)) fence = !fence
32    const h = fence ? null : /^(#{2,6})\s+(.+?)\s*#*\s*$/.exec(line)
33    if (h) {
34      cur = { title: h[2]!.trim(), level: h[1]!.length, lines: [] }
35      out.push(cur)
36    } else if (cur) cur.lines.push(line)
37  }
38  return out
39}
40
41/** A section's lines with its subsections' (deeper headings) folded in, headings kept as `#` lines. */
42function withSubsections(all: Section[], i: number): string[] {
43  const head = all[i]!
44  const lines = [...head.lines]
45  for (let j = i + 1; j < all.length && all[j]!.level > head.level; j++) lines.push(`${'#'.repeat(all[j]!.level)} ${all[j]!.title}`, ...all[j]!.lines)
46  return lines
47}
48
49/** Plain text from inline markdown: code ticks, emphasis, link and image syntax removed, whitespace collapsed. */
50export function plain(text: string): string {
51  return text
52    .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
53    .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
54    .replace(/<(https?:[^>\s]+)>/g, '$1')
55    .replace(/(`+)\s?([^`]*?)\s?\1/g, '$2')
56    .replace(/(\*\*|__)(.+?)\1/g, '$2')
57    .replace(/(^|[\s(])[*_]([^*_\s][^*_]*?)[*_](?=[\s).,;:!?]|$)/g, '$1$2')
58    .replace(/\s+/g, ' ')
59    .trim()
60}
61
62/** Top-level list items (`-`, `*`, `+`, `1.`, `1)`) with their indented continuation lines. */
63export function listItems(lines: string[]): { marker: string; text: string; body: string[] }[] {
64  const out: { marker: string; text: string; body: string[] }[] = []
65  let cur: { marker: string; text: string; body: string[] } | null = null
66  for (const line of lines) {
67    const m = /^(?: {0,1})([-*+]|\d+[.)])\s+(.*)$/.exec(line)
68    if (m) {
69      cur = { marker: m[1]!, text: m[2]!.trim(), body: [] }
70      out.push(cur)
71    } else if (cur && /^\s+\S/.test(line)) cur.body.push(line.trim())
72    else if (!line.trim()) continue
73    else cur = null
74  }
75  return out
76}
77
78// --- Explore: proposal.md ---
79
80const CLASSES: Record<string, StipFinding['source']> = {
81  established: 'established',
82  inferred: 'inferred',
83  incomplete: 'incomplete',
84  missing: 'missing',
85}
86const KIND: Record<StipFinding['source'], StipFinding['kind']> = { established: 'Confirmed', inferred: 'Likely', incomplete: 'Open', missing: 'Open' }
87
88const cap = (t: string, n: number) => (t.length > n ? `${t.slice(0, n - 1).trimEnd()}…` : t)
89
90/**
91 * The "Problem" section as plain text: its first paragraph, or, when it opens with a list (or a
92 * lead line then a list), the list's items (at most 6). `text` is the paragraph, the lead line, or
93 * the items joined with "; ". Markdown (code ticks, emphasis, links, list markers) is stripped.
94 */
95export function problemSection(md: string): { text: string; items: string[] } | null {
96  const s = sections(md).find(x => /^problem\b/i.test(x.title))
97  if (!s) return null
98  const blocks: string[][] = []
99  let cur: string[] = []
100  for (const line of s.lines) {
101    if (!line.trim()) {
102      if (cur.length) blocks.push(cur), (cur = [])
103      continue
104    }
105    cur.push(line)
106  }
107  if (cur.length) blocks.push(cur)
108  const first = blocks[0]
109  if (!first) return null
110  const isItem = (l: string) => /^\s{0,3}(?:[-*+]|\d+[.)])\s+/.test(l)
111  const at = first.findIndex(isItem)
112  // A lead line ending with ":" may stand alone, its list in the next block.
113  const listLines = at >= 0 ? first.slice(at) : /:\s*$/.test(first.at(-1) ?? '') && blocks[1]?.some(isItem) ? blocks[1]! : []
114  const lead = plain((at >= 0 ? first.slice(0, at) : first).join(' '))
115  // Items may be indented as a block: strip the item lines' common indent from every line.
116  const indent = Math.min(3, ...listLines.filter(isItem).map(l => /^\s*/.exec(l)![0].length))
117  const items = listItems(listLines.map(l => l.slice(Math.min(indent, /^\s*/.exec(l)![0].length))))
118    .map(i => cap(plain([i.text, ...i.body].join(' ')), 200))
119    .filter(Boolean)
120    .slice(0, 6)
121  const text = cap(lead.replace(/:\s*$/, items.length ? ':' : '') || items.join('; '), 600)
122  return text || items.length ? { text: text || items.join('; '), items } : null
123}
124
125/** The first paragraph under "Problem" as plain text (see problemSection), or null. */
126export function problemOf(md: string): string | null {
127  return problemSection(md)?.text ?? null
128}
129
130/**
131 * Classified findings: list items under a "Findings" heading that lead with a class
132 * (`- **established**: …`, `- Inferred — …`), or items under a class subheading (`### Established`).
133 * `not-applicable` and unclassified items are left out.
134 */
135export function findingsOf(md: string): StipFinding[] {
136  const all = sections(md)
137  const out: StipFinding[] = []
138  all.forEach((s, i) => {
139    if (!/^findings\b/i.test(s.title)) return
140    let under: StipFinding['source'] | null = null
141    const lines = withSubsections(all, i)
142    let item: StipFinding | null = null
143    for (const line of lines) {
144      const h = /^#{2,6}\s+(.+)$/.exec(line)
145      if (h) {
146        under = CLASSES[plain(h[1]!).toLowerCase().replace(/[^a-z]/g, '')] ?? null
147        item = null
148        continue
149      }
150      const li = /^ {0,1}(?:[-*+]|\d+[.)])\s+(.*)$/.exec(line)
151      if (li) {
152        const lead = /^(?:\*\*|__|\*|_)?\s*(established|inferred|incomplete|missing|not[- ]applicable)\s*(?:\*\*|__|\*|_)?\s*(?::|—|–|-)\s*(.+)$/i.exec(li[1]!.trim())
153        const cls = lead ? CLASSES[lead[1]!.toLowerCase()] : under
154        const text = plain(lead ? lead[2]! : li[1]!)
155        item = cls && text ? { kind: KIND[cls], source: cls, text } : null
156        if (item) out.push(item)
157        continue
158      }
159      if (item && /^\s+\S/.test(line)) item.text = plain(`${item.text} ${line}`)
160      else if (line.trim()) item = null
161    }
162  })
163  return out
164}
165
166/**
167 * Decisions and open questions: items of a heading naming decisions or open questions, numbered
168 * (`D1`, `Q1`, `1.`) or listed, each with its "Recommended:" text when it gives one.
169 */
170export function decisionsOf(md: string): StipDecision[] {
171  const all = sections(md)
172  const out: StipDecision[] = []
173  all.forEach((s, i) => {
174    if (!/decision|open questions?/i.test(s.title) || /^findings\b/i.test(s.title)) return
175    // A subsection of a decisions section is read with its parent.
176    const parent = all.slice(0, i).reverse().find(p => p.level < s.level)
177    if (parent && /decision|open questions?/i.test(parent.title)) return
178    const openSection = /open questions?/i.test(s.title) && !/decision/i.test(s.title)
179    for (const item of listItems(withSubsections(all, i))) {
180      const whole = [item.text, ...item.body].join('\n')
181      const rec = /(?:^|\n|\s)(?:\*\*|_)?Recommended(?:\*\*|_)?\s*:\s*(?:\*\*|_)?\s*(.+)$/im.exec(whole)
182      const textPart = rec ? whole.slice(0, rec.index).trim() : whole
183      let text = plain(textPart.replace(/\n/g, ' '))
184      const tag = /^(?:\*\*)?([DQ]\d+)(?:\*\*)?\s*[:.)—–-]?\s*/.exec(text)
185      const id = tag ? tag[1]! : /^\d+[.)]$/.test(item.marker) ? item.marker.slice(0, -1) : null
186      if (tag) text = text.slice(tag[0].length).trim()
187      if (!text) continue
188      out.push({
189        id,
190        text,
191        recommended: rec ? plain(rec[1]!) || null : null,
192        open: id?.startsWith('Q') === true || openSection,
193      })
194    }
195  })
196  return out
197}
198
199export function exploreOf(proposal: string, extensions: string[]): StipExplore {
200  const problem = problemSection(proposal)
201  return { problem: problem?.text ?? null, problemItems: problem?.items ?? [], findings: findingsOf(proposal), decisions: decisionsOf(proposal), extensions: [...extensions] }
202}
203
204/** The proposal's first heading, else null. */
205export function titleOf(md: string): string | null {
206  return /^#\s+(.+?)\s*$/m.exec(md)?.[1]?.trim() ?? null
207}
208
209// --- New change: follow-ups and accepted specs ---
210
211const FOLLOW_UP = /^(?:open\s+|remaining\s+)?(?:follow[- ]?ups?|known limitations|limitations)\b/i
212
213/** Splits an item into a short title (its lead clause) and the rest. */
214function titled(text: string): { title: string; detail: string | null } {
215  const m = /^(.{3,90}?)(?::\s|\s[—–]\s|\s-\s|\.\s)(.+)$/.exec(text)
216  let title = (m ? m[1]! : text).replace(/[.:;]+$/, '').trim()
217  const detail = m ? m[2]!.trim() || null : null
218  if (title.length > 90) title = `${title.slice(0, 89)}…`
219  return { title, detail }
220}
221
222/**
223 * Follow-ups of an archived proposal: only the items of a "Follow-ups" / "Known limitations"
224 * section (conservative: no other heading counts), at most 10 per change.
225 */
226export function followUpsOf(md: string, source: string): StipFollowUp[] {
227  const out: StipFollowUp[] = []
228  const all = sections(md)
229  all.forEach((s, i) => {
230    if (!FOLLOW_UP.test(s.title)) return
231    for (const item of listItems(withSubsections(all, i))) {
232      const text = plain([item.text, ...item.body].join(' '))
233      if (!text || /^(none|n\/a|nothing)\.?$/i.test(text)) continue
234      out.push({ ...titled(text), source })
235    }
236  })
237  return out.slice(0, 10)
238}
239
240export function acceptedSpec(id: string, spec: string, archivedAt: string | null, commit: string | null): StipAcceptedSpec {
241  return { id, criteria: parseCriteria(spec).length, archivedAt, commit }
242}
243
244// --- Validate: the contract the person last approved ---
245
246/** What is recorded per change when the person approves (plugin store `contracts[root][id]`). */
247export type ApprovedContract = {
248  digest: string
249  at: string
250  /** Criterion id -> its full text. */
251  criteria: Record<string, string>
252  /** Task id -> a canonical text of its definition. */
253  tasks: Record<string, string>
254}
255
256export type TaskLike = { id: string; role: string; phase: string; objective: string; criteria: string[]; depends_on?: string[]; dependsOn?: string[]; write_paths?: string[]; writePaths?: string[] }
257
258/** A task's definition as compared between versions (fields a revision changes). */
259export function taskKey(t: TaskLike): string {
260  return JSON.stringify({
261    role: t.role,
262    phase: t.phase,
263    objective: t.objective,
264    criteria: [...(t.criteria ?? [])].sort(),
265    depends_on: [...(t.depends_on ?? t.dependsOn ?? [])].sort(),
266    write_paths: [...(t.write_paths ?? t.writePaths ?? [])].sort(),
267  })
268}
269
270export function approvedContract(digest: string, at: string, spec: string, tasks: TaskLike[]): ApprovedContract {
271  return {
272    digest,
273    at,
274    criteria: Object.fromEntries(parseCriteria(spec).map(c => [c.id, c.text])),
275    tasks: Object.fromEntries(tasks.map(t => [t.id, taskKey(t)])),
276  }
277}
278
279const byAc = (a: string, b: string) => (Number(a.replace(/\D/g, '')) || 0) - (Number(b.replace(/\D/g, '')) || 0) || a.localeCompare(b)
280
281/** The difference by id between the approved contract and the current criteria and plan. */
282export function contractDiff(approved: ApprovedContract | null | undefined, criteria: { id: string; text: string; title?: string }[], tasks: TaskLike[]): StipContractDiff | null {
283  if (!approved || typeof approved.digest !== 'string' || !approved.criteria || !approved.tasks) return null
284  const now = new Map(criteria.map(c => [c.id, c]))
285  const changes: StipContractDiff['criteria'] = []
286  for (const c of criteria) {
287    const before = approved.criteria[c.id]
288    if (before === undefined) changes.push({ id: c.id, change: 'added', title: c.title ?? fallbackLabel(c.text) })
289    else if (before.trim() !== c.text.trim()) changes.push({ id: c.id, change: 'changed', title: c.title ?? fallbackLabel(c.text) })
290  }
291  for (const [id, text] of Object.entries(approved.criteria)) if (!now.has(id)) changes.push({ id, change: 'removed', title: fallbackLabel(text) })
292  changes.sort((a, b) => byAc(a.id, b.id))
293  const current = new Map(tasks.map(t => [t.id, taskKey(t)]))
294  return {
295    since: approved.digest,
296    approvedAt: approved.at,
297    criteria: changes,
298    addedTasks: [...current.keys()].filter(id => !(id in approved.tasks)),
299    changedTasks: [...current].filter(([id, key]) => id in approved.tasks && approved.tasks[id] !== key).map(([id]) => id),
300    removedTasks: Object.keys(approved.tasks).filter(id => !current.has(id)),
301  }
302}
303
304// --- Check: recorded proof per criterion ---
305
306type ReportEntry = { id?: unknown; status?: unknown; evidence?: unknown }
307
308/** The check report's criteria (state.json `check.report.criteria`), by id. */
309export function reportOf(state: Record<string, any> | null | undefined): Record<string, StipProof> {
310  const out: Record<string, StipProof> = {}
311  const list: ReportEntry[] = Array.isArray(state?.check?.report?.criteria) ? state!.check.report.criteria : []
312  for (const e of list) {
313    if (typeof e?.id !== 'string') continue
314    const raw = String(e.status ?? '').toLowerCase()
315    const status: StipProof['status'] = raw === 'passed' ? 'passed' : raw === 'failed' ? 'failed' : raw === 'pending-docs' ? 'pending-docs' : 'unverified'
316    out[e.id] = { status, text: typeof e.evidence === 'string' ? e.evidence.trim() : '', source: 'check' }
317  }
318  return out
319}
320
321/**
322 * Adds the recorded proof to each criterion: the check report's entry, else evidence.md's line. A
323 * `pending-docs` entry gives way to a later `passed` line in evidence.md (docs recorded it).
324 */
325export function withProof<C extends { id: string }>(criteria: C[], report: Record<string, StipProof>, evidenceMd: string): (C & { pendingDocs: boolean; proof: StipProof | null })[] {
326  const evidence = parseEvidence(evidenceMd)
327  const raw = (id: string) => new RegExp(`^- ${id}:\\s*([a-z_-]+)`, 'mi').exec(evidenceMd)?.[1]?.toLowerCase() ?? null
328  return criteria.map(c => {
329    const ev = evidence[c.id]
330    const line: StipProof | null = ev
331      ? { status: /^pending[-_]docs$/.test(raw(c.id) ?? '') ? 'pending-docs' : ev.status === 'passed' ? 'passed' : ev.status === 'failed' ? 'failed' : 'unverified', text: ev.evidence, source: 'evidence' }
332      : null
333    let proof = report[c.id] ?? line
334    if (proof?.status === 'pending-docs' && line?.status === 'passed') proof = line
335    return { ...c, pendingDocs: proof?.status === 'pending-docs', proof }
336  })
337}
338
339/** The one acceptance criterion a tool call's input names (null when none or several). */
340export function criterionIn(input: Record<string, unknown>): string | null {
341  let text = ''
342  try {
343    text = JSON.stringify(input) ?? ''
344  } catch {
345    return null
346  }
347  const ids = new Set((text.match(/\bAC-[1-9][0-9]*\b/g) ?? []).map(s => s))
348  return ids.size === 1 ? [...ids][0]! : null
349}
350
351/** Appends one observed step, keeping the last STEP_LIMIT. */
352export function pushStep(steps: StipStep[] | undefined, step: StipStep): StipStep[] {
353  return [...(steps ?? []), step].slice(-STEP_LIMIT)
354}
355
356// --- Archive: the summary of a change just archived ---
357
358const ms = (iso: unknown): number | null => {
359  if (typeof iso !== 'string') return null
360  const t = Date.parse(iso)
361  return Number.isFinite(t) ? t : null
362}
363
364export function archiveSummary(input: {
365  id: string
366  state: Record<string, any> | null
367  /** execution-summary.json, or null. */
368  summary: Record<string, any> | null
369  /** The change's worker registry if it is still there (models when the summary records none). */
370  registry: Record<string, any> | null
371  spec: string
372  evidence: string
373  proposal: string
374  commit: string | null
375}): StipArchiveSummary {
376  const { id, state, summary, registry } = input
377  const criteria = parseCriteria(input.spec)
378  const report = reportOf(state)
379  const evidence = parseEvidence(input.evidence)
380  const passed = criteria.filter(c => evidence[c.id]?.status === 'passed' || (!evidence[c.id] && report[c.id]?.status === 'passed')).length
381  const tasks: Record<string, any>[] = Array.isArray(summary?.tasks) ? summary!.tasks : []
382  const jobs: Record<string, any>[] = Array.isArray(registry?.jobs) ? registry!.jobs : []
383  const latestJob = (taskId: string) => jobs.filter(j => j.task_id === taskId).sort((a, b) => (b.attempt ?? 0) - (a.attempt ?? 0))[0]
384  const models = tasks
385    .filter(t => typeof t.task_id === 'string')
386    .map(t => {
387      const job = latestJob(t.task_id)
388      const profile = job?.profile && typeof job.profile === 'object' ? job.profile : {}
389      const pick = (...v: unknown[]) => (v.find(x => typeof x === 'string' && x && x !== 'inherit') as string | undefined) ?? null
390      return {
391        taskId: String(t.task_id),
392        model: pick(t.actual_model, t.requested_model, job?.actual_model, profile.model),
393        effort: pick(t.effort, profile.effort),
394        attempts: Number.isInteger(t.attempt) && t.attempt > 0 ? (t.attempt as number) : 1,
395      }
396    })
397  const corrected = models.filter(m => m.attempts > 1).map(m => m.taskId)
398  const createdAt = typeof state?.created_at === 'string' ? state.created_at : null
399  const archivedAt = typeof summary?.archived_at === 'string' ? summary.archived_at : state?.phase === 'archived' && typeof state?.updated_at === 'string' ? state.updated_at : null
400  const a = ms(createdAt)
401  const b = ms(archivedAt)
402  return {
403    changeId: id,
404    title: titleOf(input.proposal) ?? id,
405    commit: input.commit,
406    createdAt,
407    archivedAt,
408    durationMs: a !== null && b !== null && b >= a ? b - a : null,
409    criteria: { passed, total: criteria.length },
410    tasks: { total: models.length, corrections: models.reduce((n, m) => n + m.attempts - 1, 0), corrected },
411    models,
412    followUps: followUpsOf(input.proposal, id),
413  }
414}
415
416// --- Setup: a project without .workflow/ ---
417
418export const SETUP_FILES = [
419  'pyproject.toml', 'setup.py', 'setup.cfg', 'requirements.txt', 'pytest.ini', 'tox.ini', 'conftest.py', '.python-version',
420  'package.json', 'tsconfig.json', 'deno.json', 'Cargo.toml', 'go.mod', 'Gemfile', 'pom.xml', 'build.gradle', 'build.gradle.kts',
421  'mix.exs', 'composer.json', 'Package.swift',
422  'AGENTS.md', 'CLAUDE.md', '.claude/CLAUDE.md',
423  '.github/workflows', '.gitlab-ci.yml', '.circleci', 'azure-pipelines.yml', 'Jenkinsfile', '.buildkite',
424] as const
425
426/**
427 * What Setup shows, from which of SETUP_FILES exist, the text of pyproject.toml / package.json
428 * when present, and `git status --porcelain=v1 -b` (null outside a repository).
429 */
430export function setupSummary(input: {
431  project: string
432  home: string | null
433  present: Set<string>
434  pyproject?: string
435  packageJson?: string
436  gitStatus: string | null
437}): StipSetup {
438  const has = (f: string) => input.present.has(f)
439  const langs: string[] = []
440  let tests: string | null = null
441  const py = has('pyproject.toml') || has('setup.py') || has('setup.cfg') || has('requirements.txt')
442  if (py) {
443    const version = /requires-python\s*=\s*["'][^0-9]*([0-9]+\.[0-9]+)/.exec(input.pyproject ?? '')?.[1]
444    langs.push(version ? `Python ${version}` : 'Python')
445    if (has('pytest.ini') || has('conftest.py') || /\bpytest\b/.test(input.pyproject ?? '')) tests = 'pytest'
446    else if (has('tox.ini')) tests = 'tox'
447  }
448  if (has('package.json')) {
449    let pkg: Record<string, any> = {}
450    try {
451      pkg = JSON.parse(input.packageJson ?? '{}')
452    } catch {}
453    langs.push(has('tsconfig.json') || pkg.devDependencies?.typescript || pkg.dependencies?.typescript ? 'TypeScript' : 'JavaScript')
454    const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
455    const script = typeof pkg.scripts?.test === 'string' ? pkg.scripts.test : ''
456    tests ??= ['vitest', 'jest', 'mocha', 'ava', 'playwright'].find(t => t in deps || new RegExp(`\\b${t}\\b`).test(script)) ?? (/bun test/.test(script) ? 'bun test' : /node --test/.test(script) ? 'node --test' : script && !/no test specified/.test(script) ? 'npm test' : null)
457  } else if (has('deno.json')) langs.push('TypeScript (Deno)')
458  if (has('Cargo.toml')) (langs.push('Rust'), (tests ??= 'cargo test'))
459  if (has('go.mod')) (langs.push('Go'), (tests ??= 'go test'))
460  if (has('Gemfile')) langs.push('Ruby')
461  if (has('pom.xml') || has('build.gradle') || has('build.gradle.kts')) langs.push(has('build.gradle.kts') ? 'Kotlin/Java' : 'Java')
462  if (has('mix.exs')) (langs.push('Elixir'), (tests ??= 'mix test'))
463  if (has('composer.json')) langs.push('PHP')
464  if (has('Package.swift')) (langs.push('Swift'), (tests ??= 'swift test'))
465  let git: StipSetup['git'] = { repo: false, branch: null, clean: null }
466  if (input.gitStatus !== null) {
467    const lines = input.gitStatus.split('\n').filter(Boolean)
468    const head = lines[0]?.startsWith('## ') ? lines[0].slice(3) : ''
469    const branch = /^No commits yet on (.+)$/.exec(head)?.[1] ?? (/^HEAD \(no branch\)/.test(head) ? null : head.split('...')[0]?.split(' ')[0] || null)
470    git = { repo: true, branch, clean: lines.filter(l => !l.startsWith('## ')).length === 0 }
471  }
472  const home = input.home?.replace(/\/+$/, '')
473  return {
474    project: home && (input.project === home || input.project.startsWith(`${home}/`)) ? `~${input.project.slice(home.length)}` : input.project,
475    code: langs.length ? langs.join(', ') : null,
476    tests,
477    git,
478    rules: ['AGENTS.md', 'CLAUDE.md', '.claude/CLAUDE.md'].filter(has),
479    ci: ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'azure-pipelines.yml', 'Jenkinsfile', '.buildkite'].find(has) ?? null,
480  }
481}
482
483// --- Prompt fills ---
484
485/** One line, at most 200 characters: a follow-up title as `/stip-explore` takes it. */
486export function exploreTitle(raw: unknown): string {
487  const t = typeof raw === 'string' ? raw.replace(/\s+/g, ' ').trim() : ''
488  if (!t) throw new Error('title is required')
489  return t.length > 200 ? t.slice(0, 200).trim() : t
490}
491
hooks/core/notify.ts 61 lines
1// Toast and status wording and coalescing (pure; the interface may import it too).
2// Rules: no "stipulate:" prefix (the toast already names the plugin), at most 60 characters, plain
3// words without paths or full digests. Details belong in a transcript notice or the snapshot.
4
5export const TOAST_MAX = 60
6export const COALESCE_MS = 30_000
7
8/** Short, plain toast text: prefix removed, paths and digests elided, cut to 60 characters. */
9export function toastText(text: string, max = TOAST_MAX): string {
10  let s = text
11    .replace(/^\s*stip(?:ulate)?\s*[:·-]\s*/i, '')
12    .replace(/(?:\.{0,2}\/)?(?:[\w.-]+\/)+[\w.-]+/g, m => (m.includes('/') ? m.split('/').pop()! : m))
13    .replace(/\b[0-9a-f]{16,}\b/gi, m => m.slice(0, 8))
14    .replace(/\s+/g, ' ')
15    .trim()
16  if (s.length > max) s = s.slice(0, max - 1).replace(/\s+\S*$/, '') + '…'
17  return s
18}
19
20export type CoalesceState = Record<string, { count: number; at: number }>
21
22/**
23 * Decides whether a toast under `key` is shown: the first within 30 s shows; a repeat within the
24 * window shows again as "text ×N" (a plugin's toast replaces its own, so one stays visible).
25 */
26export function coalesce(state: CoalesceState, key: string, text: string, now: number, windowMs = COALESCE_MS): { text: string; state: CoalesceState } {
27  const prev = state[key]
28  const count = prev && now - prev.at < windowMs ? prev.count + 1 : 1
29  const next = { ...state, [key]: { count, at: now } }
30  for (const [k, v] of Object.entries(next)) if (now - v.at >= windowMs * 4) delete next[k]
31  if (count === 1) return { text: toastText(text), state: next }
32  const suffix = ` ×${count}`
33  return { text: toastText(text, TOAST_MAX - suffix.length) + suffix, state: next }
34}
35
36/** The core's toast messages (short) by event. */
37export const TOASTS = {
38  registryRestored: (cancelled: number) => `Registry restored · outside change undone${cancelled ? ` · ${cancelled} cancelled` : ''}`,
39  lifecycleRestored: () => 'Lifecycle restored · outside change undone',
40  tampered: (jobs: string[]) => `Tampered ${jobs.join(', ')} · check git diff`,
41  approvalRevoked: () => 'Approval revoked · not given by you',
42  recordsDiscarded: () => 'Approval record discarded · changed outside',
43  workerCancelled: (job: string) => `${job} cancelled · tampering detected`,
44  applyGate: (change: string) => `Apply gate · ${change} not approved yet`,
45  staleSetAside: () => 'Stale Stip records set aside',
46  restoreStopped: () => 'Restore loop stopped · run /stip-resync',
47  rebased: (redo: number) => (redo ? `Registry rebased · ${redo} task${redo === 1 ? '' : 's'} to redo` : 'Registry rebased'),
48  rebaseNeeded: () => 'Workers done · rebase registry in /stip',
49} as const
50
51/**
52 * The transcript notice for `details`: one "Stipulate: " prefix (a leading plugin name is dropped),
53 * whitespace collapsed. Null when the same key's toast is a repeat within the coalescing window
54 * (its toast already says ×N; the first notice holds the details).
55 */
56export function noticeText(details: string, repeat: boolean): string | null {
57  if (repeat) return null
58  const body = details.replace(/^\s*stip(?:ulate)?\s*:\s*/i, '').replace(/\s+/g, ' ').trim()
59  return body ? `Stipulate: ${body}` : null
60}
61
hooks/core/criteria.ts 100 lines
1// Acceptance criteria and evidence of a change, and short display labels (pure).
2
3export type ParsedCriterion = { id: string; text: string }
4export type CriterionStatus = 'passed' | 'failed' | 'unverified' | 'pending'
5
6const START = /^- (AC-[1-9][0-9]*):\s*(.*)$/
7
8/**
9 * Every `- AC-n: ...` item of spec.md with its FULL text: wrapped continuation lines and nested
10 * lists (e.g. "The README documents:" followed by indented items) belong to the criterion until the
11 * next criterion, a heading, or a blank line followed by unindented prose.
12 */
13export function parseCriteria(spec: string): ParsedCriterion[] {
14  const lines = spec.replace(/\r\n?/g, '\n').split('\n')
15  const out: ParsedCriterion[] = []
16  for (let i = 0; i < lines.length; i++) {
17    const m = START.exec(lines[i]!)
18    if (!m) continue
19    const body = [m[2]!.trimEnd()]
20    let inList = false
21    let j = i + 1
22    for (; j < lines.length; j++) {
23      const line = lines[j]!
24      if (START.test(line) || /^#{1,6}\s/.test(line)) break
25      if (!line.trim()) {
26        // A blank line ends the item unless the next non-blank line is still part of it (indented).
27        let k = j + 1
28        while (k < lines.length && !lines[k]!.trim()) k++
29        if (k >= lines.length || !/^\s+\S/.test(lines[k]!)) break
30        body.push('')
31        continue
32      }
33      // An unindented list right after "...:" (or after such an item) belongs to the criterion.
34      const listItem = /^(?:[-*+]|\d+[.)])\s+\S/.test(line)
35      const prev = body[body.length - 1] ?? ''
36      if (!/^\s/.test(line)) {
37        if (listItem && (/:\s*$/.test(prev) || inList)) {
38          inList = true
39          body.push(line.trimEnd())
40          continue
41        }
42        break
43      }
44      body.push(line.replace(/^\s{2}/, '').trimEnd())
45    }
46    out.push({ id: m[1]!, text: body.join('\n').trim() })
47    i = j - 1
48  }
49  return out
50}
51
52/** evidence.md lines written by `check`: `- AC-n: <status> — <evidence>`. */
53export function parseEvidence(md: string): Record<string, { status: CriterionStatus; evidence: string }> {
54  const out: Record<string, { status: CriterionStatus; evidence: string }> = {}
55  for (const line of md.replace(/\r\n?/g, '\n').split('\n')) {
56    const m = /^- (AC-[1-9][0-9]*):\s*([a-z_-]+)\s*(?:—|--|-)\s*(.*)$/.exec(line)
57    if (!m) continue
58    const raw = m[2]!.toLowerCase()
59    const status: CriterionStatus = raw === 'passed' || raw === 'pass' ? 'passed' : raw === 'failed' || raw === 'fail' ? 'failed' : 'unverified'
60    out[m[1]!] = { status, evidence: m[3]!.trim() }
61  }
62  return out
63}
64
65const STOP = new Set(['a', 'an', 'the', 'and', 'or', 'of', 'to', 'in', 'on', 'for', 'with', 'by', 'that', 'this', 'is', 'are', 'be', 'it', 'its', 'when', 'each', 'every'])
66
67/** A label from the first meaningful words (≤32 characters), shown until the model's is ready. */
68export function fallbackLabel(text: string, max = 32): string {
69  const words = text
70    .replace(/`([^`]*)`/g, '$1')
71    .replace(/[*_#>[\]()]/g, ' ')
72    .split(/\s+/)
73    .map(w => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ''))
74    .filter(Boolean)
75  const meaningful = words.filter((w, i) => i === 0 || !STOP.has(w.toLowerCase()))
76  let out = ''
77  for (const w of meaningful) {
78    const next = out ? `${out} ${w}` : w
79    if (next.length > max) break
80    out = next
81    if (out.split(' ').length >= 5) break
82  }
83  if (!out) out = (words[0] ?? text).slice(0, max)
84  return out.charAt(0).toUpperCase() + out.slice(1)
85}
86
87/** Validates a model's label: 2-5 words, ≤40 characters, no trailing punctuation; null when unusable. */
88export function cleanLabel(raw: string): string | null {
89  let s = raw.trim().split('\n')[0]!.trim()
90  s = s.replace(/^(label|title)\s*:\s*/i, '').replace(/^["'`“”‘’*]+|["'`“”‘’*]+$/g, '').trim()
91  s = s.replace(/[.,;:!?…]+$/u, '').trim()
92  const words = s.split(/\s+/).filter(Boolean)
93  if (!s || s.length > 40 || words.length < 2 || words.length > 5) return null
94  return s
95}
96
97export const LABEL_PROMPT = (kind: 'requirement' | 'question', text: string) =>
98  `Write a short label for this ${kind}: 2 to 5 words that describe the ${kind}, no punctuation at the end, ` +
99  `no quotes, no explanation. Reply with the label only.\n\n${text.slice(0, 4000)}`
100
hooks/core/integrity.ts 428 lines
1// Integrity of the worker registry and lifecycle state (pure). The registry's known-good state
2// comes only from the mod's own engine calls: each call returns the job it wrote, and every
3// other job must be exactly what the mod knew before. Anything else is tampering.
4
5/** Stable JSON text (sorted keys) for comparing parsed documents. */
6export function canon(value: unknown): string {
7  if (Array.isArray(value)) return `[${value.map(canon).join(',')}]`
8  if (value && typeof value === 'object')
9    return `{${Object.keys(value as Record<string, unknown>)
10      .sort()
11      .map(k => `${JSON.stringify(k)}:${canon((value as Record<string, unknown>)[k])}`)
12      .join(',')}}`
13  return JSON.stringify(value)
14}
15
16export type Registry = { jobs: Record<string, any>[] } & Record<string, unknown>
17
18export const registryKey = (job: Record<string, any>) =>
19  typeof job.helper_id === 'string' ? job.helper_id : `${job.task_id}#${job.attempt}`
20
21export function parseRegistry(text: string): Registry | null {
22  if (!text.trim()) return null
23  try {
24    const value = JSON.parse(text)
25    return value && Array.isArray(value.jobs) ? value : null
26  } catch {
27    return null
28  }
29}
30
31/** The fields each engine action writes on its own job; every other field must be as known. */
32export const ACTION_FIELDS: Record<string, readonly string[]> = {
33  confirm: ['session_id', 'launch_confirmed', 'status', 'confirmed_at', 'updated_at', 'actual_model', 'error'],
34  settle: ['status', 'updated_at', 'last_activity_at', 'finished_at', 'stop_confirmed', 'last_tool', 'error', 'result_path', 'result_bytes', 'result_truncated'],
35  review: ['acceptance', 'review', 'updated_at', 'worktree_removed'],
36  cancel: ['status', 'acceptance', 'stop_confirmed', 'finished_at', 'cancellation', 'updated_at', 'worktree_removed'],
37  integrate: ['integrated', 'integrated_at', 'updated_at', 'integration', 'worktree_removed'],
38}
39
40/** Fields `reserve --correction` legitimately changes on other attempts. */
41const CORRECTION_FIELDS: Record<string, (v: unknown) => boolean> = {
42  acceptance: v => v === 'rejected',
43  worktree_removed: v => v === true,
44}
45
46export type Verdict = {
47  ok: boolean
48  /** The registry to keep: the disk content when ok, else known-good with the returned job applied. */
49  restored: Registry | null
50  /** Jobs whose content differs from what the mod knew (registry keys). */
51  tampered: string[]
52  /** Tampered jobs that the disk shows integrated while the known-good state does not. */
53  integrated: string[]
54}
55
56/**
57 * Compares the registry on disk with the known-good one. `returned` is the job the mod's own engine
58 * call reports having written (it may differ from known-good); `correction` allows what a correction
59 * reserve changes on earlier attempts.
60 */
61export function verifyRegistry(
62  known: Registry | null,
63  disk: Registry | null,
64  returned?: Record<string, any> | null,
65  correction = false,
66  action?: string,
67): Verdict {
68  const prev = new Map((known?.jobs ?? []).map(j => [registryKey(j), j]))
69  const now = new Map((disk?.jobs ?? []).map(j => [registryKey(j), j]))
70  const own = returned ? registryKey(returned) : null
71  const tampered: string[] = []
72  const integrated: string[] = []
73  const allowed = (before: Record<string, any>, after: Record<string, any>) => {
74    // A reserve drops the previous attempt's worktree; a correction also rejects earlier attempts.
75    if (!correction && action !== 'reserve') return false
76    const keys = new Set([...Object.keys(before), ...Object.keys(after)])
77    for (const k of keys) {
78      if (k === 'updated_at' || canon(before[k]) === canon(after[k])) continue
79      if (!correction && k !== 'worktree_removed') return false
80      if (!CORRECTION_FIELDS[k]?.(after[k])) return false
81    }
82    return true
83  }
84  for (const [key, job] of now) {
85    if (key === own) {
86      // The engine reports what it read plus its own change: only the action's fields may differ from known.
87      const before = prev.get(key)
88      const fields = action ? ACTION_FIELDS[action] : undefined
89      const foreign =
90        before && fields
91          ? [...new Set([...Object.keys(before), ...Object.keys(job)])].some(k => !fields.includes(k) && canon(before[k]) !== canon(job[k]))
92          : false
93      if (canon(job) !== canon(returned) || foreign) tampered.push(key)
94      continue
95    }
96    const before = prev.get(key)
97    if (!before || (canon(before) !== canon(job) && !allowed(before, job))) tampered.push(key)
98  }
99  for (const key of prev.keys()) if (!now.has(key) && key !== own) tampered.push(key)
100  if (own && !now.has(own)) tampered.push(own)
101  const header = (r: Registry | null) => (r ? canon({ ...r, jobs: undefined }) : null)
102  const headerOk = !known || !disk || header(known) === header(disk)
103  for (const key of tampered) if (now.get(key)?.integrated === true && prev.get(key)?.integrated !== true) integrated.push(key)
104  const ok = tampered.length === 0 && headerOk && !(known && !disk)
105  if (ok) return { ok, restored: disk, tampered, integrated }
106  // Known-good, with the mod's own returned job and the allowed correction changes applied.
107  const base: Registry = known ? { ...known, jobs: [...known.jobs] } : { ...(disk ?? {}), jobs: [] }
108  const jobs = base.jobs.map(j => {
109    const key = registryKey(j)
110    if (key === own) {
111      const fields = action ? ACTION_FIELDS[action] : undefined
112      if (!fields) return returned!
113      const merged: Record<string, any> = { ...j }
114      for (const f of fields) if (f in returned!) merged[f] = returned![f]
115      else delete merged[f]
116      return merged
117    }
118    const d = now.get(key)
119    return d && allowed(j, d) ? d : j
120  })
121  if (own && !prev.has(own)) jobs.push(returned!)
122  return { ok, restored: { ...base, jobs }, tampered: [...new Set(tampered)], integrated }
123}
124
125/** Lifecycle phase moves the engine itself makes (validate may send any later phase back to draft). */
126const NEXT: Record<string, string[]> = {
127  exploring: ['draft'],
128  draft: ['approved', 'exploring'],
129  approved: ['applying', 'draft'],
130  applying: ['checked', 'draft'],
131  checked: ['documented', 'applying', 'draft'],
132  documented: ['archived', 'applying', 'checked', 'draft'],
133}
134
135export function legalTransition(from: string | null, to: string | null): boolean {
136  if (from === to) return true
137  if (!from || !to) return false
138  // `approve` is valid from any phase; whether the person gave it is the provenance check's call.
139  if (to === 'approved' && from !== 'archived') return true
140  return NEXT[from]?.includes(to) ?? false
141}
142
143// --- SHA-256 and HMAC-SHA256, synchronous (no host round trip, deterministic timing) ---
144
145const K256 = new Uint32Array([
146  0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01,
147  0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
148  0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147,
149  0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
150  0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08,
151  0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
152  0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
153])
154
155/** SHA-256 of bytes. */
156export function sha256Bytes(data: Uint8Array): Uint8Array {
157  const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
158  const bitLen = data.length * 8
159  const padded = new Uint8Array(((data.length + 9 + 63) >> 6) << 6)
160  padded.set(data)
161  padded[data.length] = 0x80
162  const view = new DataView(padded.buffer)
163  view.setUint32(padded.length - 4, bitLen >>> 0)
164  view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000))
165  const w = new Uint32Array(64)
166  for (let off = 0; off < padded.length; off += 64) {
167    for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4)
168    for (let i = 16; i < 64; i++) {
169      const a = w[i - 15]!, b = w[i - 2]!
170      const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3)
171      const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10)
172      w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0
173    }
174    let [a, b, c, d, e, f, g, hh] = [h[0]!, h[1]!, h[2]!, h[3]!, h[4]!, h[5]!, h[6]!, h[7]!]
175    for (let i = 0; i < 64; i++) {
176      const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7))
177      const t1 = (hh + S1 + ((e & f) ^ (~e & g)) + K256[i]! + w[i]!) >>> 0
178      const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10))
179      const t2 = (S0 + ((a & b) ^ (a & c) ^ (b & c))) >>> 0
180      hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0
181    }
182    h[0] = (h[0]! + a) >>> 0; h[1] = (h[1]! + b) >>> 0; h[2] = (h[2]! + c) >>> 0; h[3] = (h[3]! + d) >>> 0
183    h[4] = (h[4]! + e) >>> 0; h[5] = (h[5]! + f) >>> 0; h[6] = (h[6]! + g) >>> 0; h[7] = (h[7]! + hh) >>> 0
184  }
185  const out = new Uint8Array(32)
186  const ov = new DataView(out.buffer)
187  for (let i = 0; i < 8; i++) ov.setUint32(i * 4, h[i]!)
188  return out
189}
190
191const hex = (b: Uint8Array) => [...b].map(x => x.toString(16).padStart(2, '0')).join('')
192
193/** SHA-256 of a string (UTF-8), hex. */
194export function sha256Sync(text: string): string {
195  return hex(sha256Bytes(new TextEncoder().encode(text)))
196}
197
198/** HMAC-SHA256 (RFC 2104), hex. */
199export function hmac(key: Uint8Array, message: string): string {
200  const block = 64
201  const k = key.length > block ? sha256Bytes(key) : key
202  const msg = new TextEncoder().encode(message)
203  const inner = new Uint8Array(block + msg.length)
204  const outer = new Uint8Array(block + 32)
205  for (let i = 0; i < block; i++) {
206    const kb = k[i] ?? 0
207    inner[i] = kb ^ 0x36
208    outer[i] = kb ^ 0x5c
209  }
210  inner.set(msg, block)
211  outer.set(sha256Bytes(inner), block)
212  return hex(sha256Bytes(outer))
213}
214
215/** What `runtime rebase` reports having done. */
216export type RebaseReport = {
217  contract_digest?: string
218  changed?: boolean
219  kept?: string[]
220  reopened?: string[]
221  retired?: string[]
222  /** Paths each retired task had already integrated into the main checkout (they stay there). */
223  retired_integrated_paths?: Record<string, string[]>
224  rebase?: Record<string, unknown>
225}
226
227const REOPEN_FIELDS = ['acceptance', 'review', 'superseded_review', 'updated_at']
228
229/**
230 * Verifies that a registry changed exactly as the engine's rebase reports: retired tasks' jobs moved
231 * to `retired_jobs`, the latest attempt of each reopened task rejected (review recorded), every other
232 * job untouched, and the header moved to the new contract with the reported rebase entry appended.
233 */
234export function verifyRebase(known: Registry | null, disk: Registry | null, report: RebaseReport): boolean {
235  if (!known || !disk) return false
236  if (report.changed === false) return canon(known) === canon(disk)
237  const retired = new Set(report.retired ?? [])
238  const reopened = new Set(report.reopened ?? [])
239  const latestAttempt = new Map<string, number>()
240  for (const j of known.jobs) if (!retired.has(j.task_id)) latestAttempt.set(j.task_id, Math.max(latestAttempt.get(j.task_id) ?? 0, j.attempt))
241  const expectJobs = known.jobs.filter(j => !retired.has(j.task_id))
242  const now = new Map(disk.jobs.map(j => [registryKey(j), j]))
243  if (now.size !== expectJobs.length) return false
244  for (const before of expectJobs) {
245    const after = now.get(registryKey(before))
246    if (!after) return false
247    const mayChange = reopened.has(before.task_id) && latestAttempt.get(before.task_id) === before.attempt
248    for (const k of new Set([...Object.keys(before), ...Object.keys(after)])) {
249      if (canon(before[k]) === canon(after[k])) continue
250      if (!mayChange || !REOPEN_FIELDS.includes(k)) return false
251    }
252    if (mayChange && after.acceptance !== 'rejected') return false
253  }
254  // Retired jobs: the known ones, each with its retirement stamp, its worktree removed and the paths it integrated.
255  const retiredBefore = ((known as any).retired_jobs ?? []) as Record<string, any>[]
256  const retiredNow = ((disk as any).retired_jobs ?? []) as Record<string, any>[]
257  const moved = known.jobs.filter(j => retired.has(j.task_id))
258  if (retiredNow.length !== retiredBefore.length + moved.length) return false
259  for (let i = 0; i < retiredBefore.length; i++) if (canon(retiredBefore[i]) !== canon(retiredNow[i])) return false
260  for (const [i, job] of moved.entries()) {
261    const after = retiredNow[retiredBefore.length + i]!
262    for (const k of new Set([...Object.keys(job), ...Object.keys(after)]))
263      if (canon(job[k]) !== canon(after[k]) && !['retired_reason', 'retired_at', 'worktree_removed', 'retired_integrated_paths'].includes(k)) return false
264  }
265  // Header: new digest, the reported rebase entry appended, nothing else changed.
266  const rebasesBefore = ((known as any).rebases ?? []) as unknown[]
267  const rebasesNow = ((disk as any).rebases ?? []) as unknown[]
268  if (disk.contract_digest !== report.contract_digest) return false
269  if (rebasesNow.length !== rebasesBefore.length + 1 || canon(rebasesNow.at(-1)) !== canon(report.rebase)) return false
270  const header = (r: Registry) => canon({ ...r, jobs: undefined, retired_jobs: undefined, rebases: undefined, contract_digest: undefined })
271  return header(known) === header(disk)
272}
273
274// --- Change identity (AC-32): records of an earlier lifecycle of the same change id are never trusted ---
275
276/** A change lifecycle's identity: its id and the engine's `created_at`. */
277export type Identity = { change: string; created: string | null }
278
279export const identityKey = (i: Identity): string => `${i.change}@${i.created ?? ''}`
280
281export function parseIdentityKey(key: string): Identity | null {
282  const at = key.indexOf('@')
283  if (at <= 0) return null
284  const created = key.slice(at + 1)
285  return { change: key.slice(0, at), created: created || null }
286}
287
288/** The identity a lifecycle state.json text carries, or null when it is absent or unreadable. */
289export function lifecycleIdentity(text: string): Identity | null {
290  if (!text.trim()) return null
291  try {
292    const s = JSON.parse(text)
293    if (!s || typeof s.id !== 'string') return null
294    return { change: s.id, created: typeof s.created_at === 'string' ? s.created_at : null }
295  } catch {
296    return null
297  }
298}
299
300const instant = (iso: string | null | undefined): number | null => {
301  if (typeof iso !== 'string') return null
302  const t = Date.parse(iso)
303  return Number.isFinite(t) ? t : null
304}
305
306/**
307 * True when a record belongs to an older lifecycle than `current`: its identity names another change
308 * or an earlier creation, or (identity unknown) it was written before `current` was created. A current
309 * lifecycle without a creation time (older engine) never makes anything stale.
310 */
311export function predates(entry: Identity | null, writtenAt: number | null, current: Identity | null): boolean {
312  const now = instant(current?.created)
313  if (!current || now === null) return false
314  if (entry) {
315    if (entry.change !== current.change) return true
316    if (entry.created === current.created) return false
317    const then = instant(entry.created)
318    return then === null || then < now
319  }
320  return writtenAt !== null && writtenAt < now
321}
322
323/** True when a worker registry holds a job, retired job or rebase older than the `current` lifecycle. */
324export function registryPredates(text: string, current: Identity | null): boolean {
325  const now = instant(current?.created)
326  const reg = parseRegistry(text) as (Registry & { retired_jobs?: unknown; rebases?: unknown; change_id?: unknown }) | null
327  if (!reg || now === null || !current) return false
328  if (typeof reg.change_id === 'string' && reg.change_id !== current.change) return true
329  const stamps: unknown[] = [
330    ...reg.jobs.map(j => j?.created_at),
331    ...(Array.isArray(reg.retired_jobs) ? reg.retired_jobs.map((j: any) => j?.created_at) : []),
332    ...(Array.isArray(reg.rebases) ? reg.rebases.map((r: any) => r?.at) : []),
333  ]
334  return stamps.some(s => {
335    const t = instant(typeof s === 'string' ? s : null)
336    return t !== null && t < now
337  })
338}
339
340/** A lifecycle as `new` creates it: exploring, never approved. Re-creating one grants nothing. */
341export function freshLifecycle(text: string): boolean {
342  try {
343    const s = JSON.parse(text)
344    return !!s && s.phase === 'exploring' && !s.approval
345  } catch {
346    return false
347  }
348}
349
350// --- Restore loop guard (AC-32): the watch never fights another writer forever ---
351
352export const SAME_RESTORES = 2
353export const BURST_RESTORES = 5
354export const BURST_MS = 120_000
355
356export type RestoreRecord = { counts: Record<string, number>; total: number; since: number; held: string | null }
357
358/**
359 * Decides whether the watch may restore `path` once more, the disk holding content `sha`: at most
360 * SAME_RESTORES times for the same content (another writer keeps putting it back), and at most
361 * BURST_RESTORES restores of the file within BURST_MS. `notify` is true on the first refusal of a hold.
362 */
363export function restoreGate(
364  log: Record<string, RestoreRecord>,
365  path: string,
366  sha: string,
367  now: number,
368): { allowed: boolean; notify: boolean; log: Record<string, RestoreRecord> } {
369  const prev = log[path] ?? { counts: {}, total: 0, since: now, held: null }
370  const rec: RestoreRecord = now - prev.since > BURST_MS ? { ...prev, counts: { ...prev.counts }, total: 0, since: now } : { ...prev, counts: { ...prev.counts } }
371  const same = rec.counts[sha] ?? 0
372  if (same >= SAME_RESTORES || rec.total >= BURST_RESTORES) {
373    const notify = rec.held !== sha
374    rec.held = sha
375    return { allowed: false, notify, log: { ...log, [path]: rec } }
376  }
377  rec.counts[sha] = same + 1
378  rec.total += 1
379  rec.held = null
380  return { allowed: true, notify: false, log: { ...log, [path]: rec } }
381}
382
383const ACTIVE_JOB = ['starting', 'running', 'waiting', 'unknown', 'correction']
384
385/**
386 * Jobs of a registry that make a silent re-creation of its change unsafe: any active job (whoever runs
387 * it), and `session`'s own work still owed a decision (returned unreviewed, or accepted but not integrated).
388 */
389export function owedWork(text: string, session: string): string[] {
390  const reg = parseRegistry(text)
391  if (!reg) return []
392  return reg.jobs
393    .filter(
394      j =>
395        ACTIVE_JOB.includes(j.status) ||
396        (j.parent_id === session &&
397          ((j.status === 'returned' && j.acceptance === 'pending') || (j.acceptance === 'accepted' && j.isolated === true && j.integrated !== true))),
398    )
399    .map(registryKey)
400}
401
402/** The sessions a registry's jobs were reserved by (`parent_id`). */
403export function registryWriters(text: string): string[] {
404  const reg = parseRegistry(text) as (Registry & { retired_jobs?: unknown }) | null
405  if (!reg) return []
406  const jobs = [...reg.jobs, ...(Array.isArray(reg.retired_jobs) ? (reg.retired_jobs as Record<string, any>[]) : [])]
407  return [...new Set(jobs.map(j => j?.parent_id).filter((p): p is string => typeof p === 'string' && p.length > 0))]
408}
409
410/**
411 * Worktrees (project-relative) a stale registry recorded for jobs older than `current` and not yet
412 * removed: only paths under `.workflow/.runtime/<change>/worktrees/`, never one a newer job records.
413 */
414export function staleWorktrees(text: string, change: string, current: Identity | null): string[] {
415  const reg = parseRegistry(text) as (Registry & { retired_jobs?: unknown }) | null
416  if (!reg) return []
417  const jobs = [...reg.jobs, ...(Array.isArray(reg.retired_jobs) ? (reg.retired_jobs as Record<string, any>[]) : [])]
418  const prefix = `.workflow/.runtime/${change}/worktrees/`
419  const valid = (rel: unknown): rel is string =>
420    typeof rel === 'string' && rel.startsWith(prefix) && rel.length > prefix.length && !rel.split('/').includes('..') && !rel.slice(prefix.length).includes('/')
421  const now = instant(current?.created)
422  const keep = new Set(jobs.filter(j => now === null || (instant(j?.created_at) ?? now) >= now).map(j => j?.worktree))
423  const out = new Set<string>()
424  for (const j of jobs)
425    if (valid(j?.worktree) && j.worktree_removed !== true && !keep.has(j.worktree) && now !== null && (instant(j.created_at) ?? now) < now) out.add(j.worktree)
426  return [...out].sort()
427}
428
hooks/ui/actions.ts 516 lines
1// The interface's actions. They run here, in `ui.press` / `ui.input` hooks keyed by the element
2// pressed (a hook frame, never a Button's onPress closure or a timer). Each goes to the core as a
3// `request` state write served by the tools' engine paths: no slash command, no transcript row.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { StipAction, StipActionReply, StipScope, StipSettingsView } from '../../types'
8import { SCOPES } from '../core/settings.ts'
9import * as motion from './motion.ts'
10import { PANE_ID, PLAN_TITLE, SETTINGS_TITLE, asDraft, decodeUi, draftFrom, integrationFeedback, launchFeedback, patchUi, tamperOf, uiToast, previewOf, type Draft, type PaneUi, type Target } from './model.ts'
11
12const snapshotAtom = atom({ plugin: 'stipulate', key: 'snapshot' } as const, null)
13const settingsAtom = atom({ plugin: 'stipulate', key: 'settings' } as const, null)
14const draftAtom = atom({ plugin: 'stipulate', key: 'settingsDraft' } as const, null)
15const selectedTaskAtom = atom({ plugin: 'stipulate', key: 'selectedTask' } as const, null)
16const bandHiddenAtom = atom({ plugin: 'stipulate', key: 'bandHidden' } as const, false)
17// The core's action path without a slash command: a request in, its reply out (same reply shape).
18const requestAtom = atom({ plugin: 'stipulate', key: 'request' } as const, null)
19const replyAtom = atom({ plugin: 'stipulate', key: 'reply' } as const, null)
20
21/** The engine's words when a press, input or pick reaches a handler released by a redraw (AC-33). */
22export const STALE_HANDLE = /no handler is held under handle/
23
24/**
25 * True when `err` is the engine's refusal of a released handle, in whatever shape it crossed the
26 * worker boundary: an Error, a HooksError, a plain object (`message`, `name`, `kind`, `error`,
27 * `cause`, nested), or a string.
28 */
29export function isStaleHandle(err: unknown): boolean {
30  const texts: string[] = []
31  const seen = new Set<unknown>()
32  const walk = (v: unknown, depth: number) => {
33    if (depth > 4 || v === null || v === undefined || seen.has(v)) return
34    if (typeof v === 'string') return void texts.push(v)
35    if (typeof v !== 'object' && typeof v !== 'function') return void texts.push(String(v))
36    seen.add(v)
37    for (const k of ['message', 'name', 'kind', 'error', 'cause', 'reason', 'text', 'why', 'detail']) {
38      try {
39        walk((v as Record<string, unknown>)[k], depth + 1)
40      } catch {}
41    }
42    try {
43      texts.push(String(v))
44    } catch {}
45    try {
46      texts.push(JSON.stringify(v))
47    } catch {}
48  }
49  walk(err, 0)
50  return texts.some(t => STALE_HANDLE.test(t))
51}
52
53/** Patches the pane's UI state (carried in `selectedTask`). */
54const setUi = ($: EngineInterface, fn: (ui: PaneUi) => Partial<PaneUi>) => update($, selectedTaskAtom, raw => patchUi(raw, fn(decodeUi(raw))))
55
56/**
57 * Plan and Settings are tabs of the one Stip pane: pressing the shown tab closes the pane, the
58 * other opens it (or switches it) to that tab. The band and the pane redraw to say which is shown.
59 */
60async function showTab($: EngineInterface, title: string) {
61  const pane = (await $.ui.panes().catch(() => [])).find(p => p.id === PANE_ID)
62  if (pane && pane.title === title) await $.ui.close({ id: PANE_ID })
63  else await $.ui.open({ id: PANE_ID, title })
64  $.ui.invalidate('ui.render')
65}
66
67export const ACCEPT_REASON = 'Accepted by the user from the Stip pane after review.'
68export const CANCEL_REASON = 'Cancelled by the user from the Stip pane.'
69export const FIXED_CORRECTION = 'Correction requested by the user from the Stip pane; re-check the review notes and the acceptance criteria.'
70
71/**
72 * A short toast (≤60 chars, coalesced ×N, the core's rules); when the text had to be cut, the
73 * whole of it goes to a transcript notice the model does not read.
74 */
75async function toast($: EngineInterface, key: string, text: string, details?: string) {
76  const shown = uiToast(key, text, await $.clock.now())
77  $.ui.toast(shown, { timeoutMs: 10_000 })
78  const full = details ?? (shown.replace(/ ×\d+$/, '').endsWith('…') ? text : null)
79  if (full) await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: `Stip: ${full}` }] } }).catch(() => undefined)
80}
81
82/** Request ids of this module instance (with the clock, unique across reloads). */
83let requestSeq = 0
84
85/**
86 * Runs one interface action through the core without a transcript row: the `request` state written
87 * here (from a ui.press / ui.input / ui.select hook) is served by the core before the write resolves,
88 * and its outcome is read back from `reply` under the same id. Refusals are toasted.
89 */
90async function act($: EngineInterface, action: StipAction, quiet = false): Promise<StipActionReply> {
91  let reply: StipActionReply
92  try {
93    const id = `ui-${await $.clock.now()}-${++requestSeq}`
94    await update($, requestAtom, () => ({ id, action }))
95    const got = await read($, replyAtom)
96    reply = got && got.id === id ? got.reply : { ok: false, error: 'The Stipulate core did not answer this action (is Stip active in this project?).' }
97  } catch (e) {
98    reply = { ok: false, error: e instanceof Error ? e.message : String(e) }
99  }
100  if (!reply.ok && !quiet) {
101    const conflicts = reply.conflicts?.length ? ` (${reply.conflicts.map(c => c.path).join(', ')})` : ''
102    await toast($, `refused:${action.action}`, `Refused · ${reply.error}${conflicts}`)
103  }
104  return reply
105}
106
107/** Rejects a returned attempt (when still pending) with the reason, then starts its correction. */
108async function requestCorrection($: EngineInterface, changeId: string, taskId: string, reason: string) {
109  const text = reason.trim()
110  if (text.length < 8) {
111    await toast($, 'reason', 'Correction needs a reason (8+ characters)')
112    return
113  }
114  const latest = (await read($, snapshotAtom))?.tasks.find(t => t.id === taskId)?.latest ?? null
115  if (latest && latest.status === 'returned' && latest.acceptance === 'pending') {
116    const rejected = await act($, { action: 'reject', change_id: changeId, task_id: taskId, reason: text })
117    if (!rejected.ok) return
118  }
119  const started = await act($, { action: 'delegate', change_id: changeId, task_id: taskId, instructions: `Correction requested: ${text}`, correction: true })
120  if (started.ok) {
121    await tellLaunch($, launchFeedback(started.value, taskId, 'start the correction of'), started.value, taskId)
122    await update($, selectedTaskAtom, raw => patchUi(raw, { task: null }))
123  }
124}
125
126/** After a reservation: the toast, and the instruction as a notice when the prompt could not be filled. */
127async function tellLaunch($: EngineInterface, feedback: { toast: string; notice: string | null }, value?: unknown, taskId?: string) {
128  // ✎ where the launch went (AC-36): the prompt, or a notice when the prompt held a draft.
129  const v = (value ?? {}) as { prompt_filled?: boolean; send_this?: unknown }
130  if (taskId && (v.prompt_filled === true || typeof v.send_this === 'string')) {
131    motion.handoff(`launch ${taskId}`, v.prompt_filled === true, await $.clock.now())
132    $.ui.invalidate('ui.render')
133  }
134  await toast($, 'launch', feedback.toast)
135  if (feedback.notice)
136    await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: feedback.notice }] } }).catch(() => undefined)
137}
138
139/** Shows a captured answer as a transcript notice (the model does not read notices). */
140async function showResult($: EngineInterface, action: StipAction & { action: 'result' }, label: string) {
141  const reply = await act($, action)
142  if (!reply.ok) return
143  const value = reply.value as { result?: string | null }
144  const text = typeof value?.result === 'string' && value.result.trim() ? value.result : null
145  if (text === null) {
146    await toast($, 'no-answer', `${label}: no answer yet`)
147    return
148  }
149  await $.session
150    .append({ message: { type: 'system', content: [{ type: 'text', text: `Stip result: ${label}\n\n${text}` }] } })
151    .catch(() => toast($, 'no-show', `${label}: answer could not be shown`))
152}
153
154async function currentDraft($: EngineInterface): Promise<Draft> {
155  return asDraft(await read($, draftAtom)) ?? draftFrom(await read($, settingsAtom), 'project')
156}
157
158/** Reloads the scopes from disk and resets the draft to the stored section of `scope`. */
159async function reloadSettings($: EngineInterface, scope: StipScope, target: Target, notice: string | null) {
160  const reply = await act($, { action: 'settings-load' }, true)
161  const view = reply.ok ? (reply.value as StipSettingsView) : await read($, settingsAtom)
162  await update($, draftAtom, () => ({ ...draftFrom(view, scope, target), notice, ...(reply.ok ? {} : { error: reply.error }) }))
163}
164
165/** Validates the draft as the core will, then saves its scope with the revision it was read at. */
166async function saveSettings($: EngineInterface) {
167  const draft = await currentDraft($)
168  const checked = previewOf(await read($, settingsAtom), draft)
169  if (checked.error) {
170    await update($, draftAtom, raw => ({ ...(asDraft(raw) ?? draft), error: `Not saved: ${checked.error}`, notice: null }))
171    return
172  }
173  const reply = await act($, { action: 'settings-save', scope: draft.scope, value: draft.value, revision: draft.revision }, true)
174  if (!reply.ok) {
175    await update($, draftAtom, raw => ({ ...(asDraft(raw) ?? draft), error: `Not saved: ${reply.error}`, notice: null }))
176    return
177  }
178  await update($, draftAtom, () => ({ ...draftFrom(reply.value as StipSettingsView, draft.scope, draft.target), notice: `Saved the ${draft.scope} scope.` }))
179}
180
181/**
182 * Puts a command in the person's prompt through the core (Set up, + New, a follow-up, Start next
183 * change) and marks where it went (✎, AC-36). A draft in the prompt is never overwritten: the
184 * command then goes to a notice for the person to send.
185 */
186async function handOff($: EngineInterface, action: StipAction & { action: 'setup' | 'new' | 'new-from' }, text: string) {
187  const reply = await act($, action)
188  if (!reply.ok) return
189  const v = (reply.value ?? {}) as { prompt_filled?: boolean; send_this?: string }
190  const filled = v.prompt_filled === true
191  const command = (typeof v.send_this === 'string' ? v.send_this : text).trim()
192  motion.handoff(command, filled, await $.clock.now())
193  if (!filled) {
194    await toast($, 'handoff', 'Your prompt holds a draft · command in the notice')
195    await $.session
196      .append({ message: { type: 'system', content: [{ type: 'text', text: `Stip: your prompt holds a draft, left as it is. Send this when ready:\n\n${command}` }] } })
197      .catch(() => undefined)
198  }
199  $.ui.invalidate('ui.render')
200}
201
202/**
203 * AC-49: a press counts as the person's only when the engine raised it (a click, a hotkey, a tap).
204 * A press another plugin or a surface module raised (`next.origin` names it) does not.
205 */
206export const isPersonPress = (origin: { plugin: string; tier: string } | undefined | null): boolean => origin?.plugin === 'engine' && origin.tier === 'core'
207
208/** The keys that change a change's extensions or the project's (AC-46, AC-47): a person's press only. */
209export const EXTENSION_KEYS = /^(ext-add|ext-more|ext-remove|ext-pick|ext-toggle)(:|$)/
210
211export const NOT_PERSON = 'Extensions change only from your own press'
212
213/**
214 * AC-46: replaces the bound change's selection through the engine's `select` while it explores, and
215 * keeps the engine's reply (or refusal) to show under the chips. Outside exploring, nothing happens.
216 */
217async function selectExtensions($: EngineInterface, next: (current: string[]) => string[]) {
218  const snapshot = await read($, snapshotAtom)
219  if (!snapshot?.changeId || snapshot.phase !== 'exploring') return
220  const extensions = [...new Set(next(snapshot.extensions))]
221  const reply = await act($, { action: 'extensions-select', change_id: snapshot.changeId, extensions }, true)
222  // The reply is about one selection: the new one, or (refused) the one that stayed. Another
223  // selection later (the coordinator's `select`) hides it.
224  const now = reply.ok ? ((reply.value ?? {}) as { extensions?: string[] }).extensions ?? extensions : snapshot.extensions
225  const text = reply.ok ? `Engine: ${snapshot.changeId} selects ${now.length ? now.join(', ') : 'no extension'}; any approval is withdrawn.` : `Engine refused: ${reply.error}`
226  if (!reply.ok) await toast($, 'refused:extensions-select', `Refused · ${reply.error}`)
227  await setUi($, () => ({ extAdd: false, extReply: text, extReplyFor: JSON.stringify(now) }))
228}
229
230/** AC-47, AC-48: turns one project extension on or off at the config revision the Settings tab drew. */
231async function setExtension($: EngineInterface, id: string) {
232  const view = await read($, settingsAtom)
233  const ext = view?.extensions?.find(x => x.id === id)
234  if (!view || !ext) return
235  const d = await currentDraft($)
236  const reply = await act($, { action: 'extension-set', id, enabled: !ext.enabled, revision: view.configRevision ?? '' }, true)
237  // The toggle rewrote config.json (the project scope's file): the draft keeps its unsaved edits but
238  // takes the new revision, so its own Save is not refused as "changed elsewhere".
239  const saved = reply.ok ? (reply.value as StipSettingsView | null) : null
240  await update($, draftAtom, raw => ({
241    ...(asDraft(raw) ?? d),
242    ...(saved && typeof saved.revision === 'string' ? { revision: saved.revision } : {}),
243    extNotice: reply.ok ? `${id} ${ext.enabled ? 'disabled' : 'enabled'} in .workflow/config.json.` : null,
244    extError: reply.ok ? null : `Not saved: ${reply.error}`,
245  }))
246}
247
248/** Runs what an element key names; true when the key was one of the interface's actions. */
249async function onPress($: EngineInterface, key: string, surface: string, person = true): Promise<boolean> {
250  const snapshot = await read($, snapshotAtom)
251  const changeId = snapshot?.changeId ?? null
252  const [verb, ...rest] = key.split(':')
253  const arg = rest.join(':')
254  const latestOf = (taskId: string) => snapshot?.tasks.find(t => t.id === taskId)?.latest ?? null
255  // AC-49: a selection or enable press that is not the person's own changes nothing.
256  if (EXTENSION_KEYS.test(key) && !person) {
257    await toast($, 'not-person', NOT_PERSON)
258    return true
259  }
260  switch (verb) {
261    // --- Extensions (AC-46, AC-47): the bound change's selection; the project's enabled set ---
262    case 'ext-add':
263      if (snapshot?.changeId && snapshot.phase === 'exploring') await setUi($, u => ({ extAdd: !u.extAdd }))
264      return true
265    case 'ext-more':
266      // `+n`: the chips that did not fit, listed (removable while exploring, else to read), folded again by ▴.
267      if (snapshot?.changeId) await setUi($, u => ({ extAll: !u.extAll }))
268      return true
269    case 'ext-remove':
270      if (arg) await selectExtensions($, current => current.filter(id => id !== arg))
271      return true
272    case 'ext-pick':
273      if (arg) await selectExtensions($, current => [...current, arg])
274      return true
275    case 'ext-toggle':
276      if (arg) await setExtension($, arg)
277      return true
278    // --- The interface's own state: pane tabs, band, toggles (keys are stable across redraws) ---
279    case 'tab-plan':
280    case 'plan':
281      await showTab($, PLAN_TITLE)
282      return true
283    case 'tab-settings':
284    case 'settings':
285      await showTab($, SETTINGS_TITLE)
286      return true
287    case 'collapse':
288      await update($, bandHiddenAtom, () => true)
289      return true
290    case 'expand':
291    case 'show-band':
292      await update($, bandHiddenAtom, () => false)
293      return true
294    case 'picker':
295      await setUi($, u => ({ picker: !u.picker }))
296      return true
297    case 'settings-picker':
298      // The change picker lives on the Plan tab: open it there.
299      await setUi($, () => ({ picker: true }))
300      await showTab($, PLAN_TITLE)
301      return true
302    case 'sopen': {
303      const d = await currentDraft($)
304      await update($, draftAtom, raw => ({ ...(asDraft(raw) ?? d), open: (asDraft(raw) ?? d).open === arg ? null : arg }))
305      return true
306    }
307    case 'other-roles-toggle': {
308      const d = await currentDraft($)
309      await update($, draftAtom, raw => ({ ...(asDraft(raw) ?? d), showOther: !(asDraft(raw) ?? d).showOther }))
310      return true
311    }
312    case 'details':
313      if (arg) await setUi($, u => ({ task: u.task === arg ? null : arg }))
314      return true
315    case 'gtoggle':
316      if (arg) await setUi($, u => ({ group: u.group === arg ? null : arg }))
317      return true
318    case 'hdetails':
319      if (arg) await setUi($, u => ({ helper: u.helper === arg ? null : arg }))
320      return true
321    case 'open':
322      if (arg) await setUi($, u => ({ criterion: u.criterion === arg ? null : arg }))
323      return true
324    case 'criteria-toggle':
325      await setUi($, u => ({ criteria: !u.criteria, criterion: null }))
326      return true
327    case 'retired-toggle':
328      await setUi($, u => ({ retired: !u.retired }))
329      return true
330    case 'approve':
331      // The digest shown is the one approved; the core refuses it when the contract moved since.
332      if (changeId && arg) await act($, { action: 'approve', change_id: changeId, digest: arg })
333      return true
334    case 'refresh':
335      await act($, { action: 'refresh' })
336      return true
337    // AC-35, AC-37: the command goes to the person's prompt; the bound change stays open.
338    case 'setup':
339      await handOff($, { action: 'setup' }, '/stip-bootstrap')
340      return true
341    case 'new':
342    case 'start-next':
343      await handOff($, { action: 'new' }, '/stip-explore')
344      return true
345    case 'new-from':
346      if (arg) await handOff($, { action: 'new-from', title: arg }, `/stip-explore ${arg}`)
347      return true
348    case 'rebase':
349      // Re-ties the worker registry to the approved contract (refused while workers are active).
350      if (changeId) {
351        const reply = await act($, { action: 'rebase', change_id: changeId }, true)
352        if (!reply.ok) await toast($, 'rebase', `Rebase refused · ${reply.error}`, reply.error)
353        else {
354          // A done rebase is announced by the core ("Registry rebased · N tasks to redo").
355          const v = (reply.value ?? {}) as { needed?: boolean; done?: boolean }
356          if (!v.done) await toast($, 'rebase', v.needed === false ? 'Registry already current' : 'Registry not rebased')
357        }
358      }
359      return true
360    case 'bind':
361      if (arg) {
362        await act($, { action: 'bind', change_id: arg })
363        await update($, selectedTaskAtom, raw => patchUi(raw, { picker: false, task: null, criterion: null }))
364      }
365      return true
366    case 'redo':
367      // A task a rebase reopened: a new attempt against the revised contract.
368      if (changeId && arg) {
369        const reply = await act($, { action: 'delegate', change_id: changeId, task_id: arg, correction: true, instructions: 'The contract changed and this task was reopened: redo it against the revised contract.' })
370        if (reply.ok) await tellLaunch($, launchFeedback(reply.value, arg), reply.value, arg)
371      }
372      return true
373    case 'delegate':
374    case 'relaunch':
375      // A reserved, unlaunched attempt hands back the same ticket, so this also re-fills the prompt.
376      if (changeId && arg) {
377        const reply = await act($, { action: 'delegate', change_id: changeId, task_id: arg })
378        if (reply.ok) await tellLaunch($, launchFeedback(reply.value, arg), reply.value, arg)
379      }
380      return true
381    case 'accept':
382      if (changeId && arg) {
383        const reply = await act($, { action: 'accept', change_id: changeId, task_id: arg, reason: ACCEPT_REASON })
384        // An accepted isolated attempt the engine could not integrate: say so and what to do.
385        const feedback = reply.ok ? integrationFeedback(reply.value) : null
386        if (feedback) {
387          await toast($, 'integration', feedback.toast)
388          await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: feedback.notice }] } }).catch(() => undefined)
389        }
390        // Accepting an attempt the integrity watch flagged: the core's warning, kept in the transcript.
391        const warning = reply.ok ? (reply.value as { warning?: unknown } | null)?.warning : undefined
392        if (typeof warning === 'string' && warning) {
393          await toast($, 'tamper-warning', `${arg} accepted · tamper warning (see notice)`)
394          await $.session
395            .append({ message: { type: 'system', content: [{ type: 'text', text: `Stip: ${arg} accepted although flagged tampered\n\n${warning}` }] } })
396            .catch(() => undefined)
397        }
398      }
399      return true
400    case 'accept-check': {
401      // First press on a tampered attempt: open its details, where the second press accepts.
402      const w = latestOf(arg)
403      const tamper = snapshot && w ? tamperOf(snapshot, arg, w.attempt) : null
404      if (tamper) {
405        await update($, selectedTaskAtom, raw => patchUi(raw, { task: arg }))
406        await toast($, 'tamper-check', `${arg} flagged tampered · check git diff first`)
407      }
408      return true
409    }
410    case 'cancel':
411      if (changeId && arg) await act($, { action: 'cancel', change_id: changeId, task_id: arg, reason: CANCEL_REASON })
412      return true
413    case 'correct':
414      // Where the surface has an Input the reason is typed in the details; elsewhere a fixed one is sent.
415      if (changeId && arg) {
416        if (surface === 'mobile') await requestCorrection($, changeId, arg, FIXED_CORRECTION)
417        else await update($, selectedTaskAtom, raw => patchUi(raw, { task: arg }))
418      }
419      return true
420    case 'result': {
421      const w = latestOf(arg)
422      if (changeId && w) await showResult($, { action: 'result', change_id: changeId, task_id: arg, attempt: w.attempt }, `${arg}#${w.attempt}`)
423      return true
424    }
425    case 'hcancel':
426      if (arg) await act($, { action: 'cancel', helper_id: arg, reason: CANCEL_REASON })
427      return true
428    case 'hresult': {
429      const helper = snapshot?.helpers.find(x => x.helperId === arg)
430      if (helper) await showResult($, { action: 'result', helper_id: arg }, `helper ${helper.role}`)
431      return true
432    }
433    case 'settings-save':
434      await saveSettings($)
435      return true
436    case 'settings-discard': {
437      const d = await currentDraft($)
438      await reloadSettings($, d.scope, d.target, 'Discarded unsaved changes.')
439      return true
440    }
441    case 'scope':
442      if ((SCOPES as readonly string[]).includes(arg)) await reloadSettings($, arg as StipScope, (await currentDraft($)).target, null)
443      return true
444  }
445  return false
446}
447
448export const registerActions: Register = on => {
449  // Every press on the interface's band and pane runs here, by key. A key the interface answers
450  // (its own actions and toggles, whose element closures do nothing) is answered here without
451  // reaching the element's closure, so a press that arrives after a redraw released that closure
452  // never asks the engine for the released handle (AC-33). Other presses (settings steppers and
453  // cycling cells) go on to their closure; a released handle there is ignored the same way.
454  on('ui.press', { plugin: 'stipulate' }, async ($, e, next) => {
455    if (e.component === 'Pane' || e.component === 'AbovePrompt') {
456      let answered = false
457      try {
458        answered = await onPress($, e.element, e.surface, isPersonPress(next.origin))
459      } catch (err) {
460        answered = true
461        await toast($, 'error', err instanceof Error ? err.message : String(err)).catch(() => undefined)
462      }
463      if (answered) return { element: e.element }
464    }
465    try {
466      return await next(e)
467    } catch (err) {
468      if (isStaleHandle(err)) return { element: e.element }
469      throw err
470    }
471  }).catch(async ($, e, next) => {
472    try {
473      return await next(e)
474    } catch {
475      return { element: e.element }
476    }
477  })
478
479  // A correction reason typed in the pane's task details (`reason:<task>`), on Enter: answered here.
480  on('ui.input', { plugin: 'stipulate' }, async ($, e, next) => {
481    if (e.kind === 'submit' && e.element.startsWith('reason:')) {
482      const changeId = (await read($, snapshotAtom))?.changeId
483      if (changeId) await requestCorrection($, changeId, e.element.slice('reason:'.length), e.value).catch(() => undefined)
484      return { element: e.element, value: e.value }
485    }
486    try {
487      return await next(e)
488    } catch (err) {
489      if (isStaleHandle(err)) return { element: e.element, value: e.value }
490      throw err
491    }
492  }).catch(async ($, e, next) => {
493    try {
494      return await next(e)
495    } catch {
496      return { element: e.element, value: e.value }
497    }
498  })
499
500  // The settings tab's pickers: a pick on a released handle is dropped quietly (pick again).
501  on('ui.select', { plugin: 'stipulate' }, async ($, e, next) => {
502    try {
503      return await next(e)
504    } catch (err) {
505      if (isStaleHandle(err)) return { element: e.element, value: e.value }
506      throw err
507    }
508  }).catch(async ($, e, next) => {
509    try {
510      return await next(e)
511    } catch {
512      return { element: e.element, value: e.value }
513    }
514  })
515}
516