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

<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%">
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
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:
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.
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.
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 →
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.
$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 →
<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.
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.
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 sidebar | Change 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.
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 settings | Model 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 →
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, p, s)./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./stip-settings, along with limits, the apply gate and motion (auto, calm, off); the coordinator keeps the session's /model and /effort.One small change in a real Claude Code terminal session, from the first conversation to the archive commit.
| 1. Explore | 2. 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. Apply | 4. 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.
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 exploring | Extensions 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 →
| Skill | What it does | What you get |
|---|---|---|
| stip-bootstrap | Adopts a new or existing project. | Project map, configuration, and workflow guidance. |
| stip-explore | Explores intent with relevant available extensions. | A bounded idea and recorded decisions. |
| stip-validate | Turns the discussion into a contract for user review. | Specification and acceptance criteria. |
| stip-apply | Builds and corrects within the approved scope. | Implementation and relevant tests. |
| stip-check | Reconciles every criterion with current evidence. | Passed, failed, or unverified results. |
| stip-docs | Updates documentation affected by the change. | Documentation grounded in the implementation. |
| stip-archive | Promotes the accepted spec and closes the change. | Archived records and a scoped local commit. |
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.
| Domain | Available extensions |
|---|---|
| Product and experience | product-strategy, user-research, storytelling, ux-design, visual-design, design-system, content-design, accessibility |
| Application engineering | frontend-engineering, backend-engineering, api-integrations, database-engineering, mobile-engineering, desktop-engineering, cli-tooling |
| Infrastructure and operations | cloud-engineering, devops-delivery, sre-operations, release-management |
| Data, AI, and assurance | data-engineering, ai-engineering, analytics-experimentation, quality-engineering, security-engineering, privacy-engineering |
| Reach and support | build-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 →
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.
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 →
| Start here | For |
|---|---|
| Installer reference | Plugin installation, scope, updates, and removal. |
| Claude Code mod | Mod installation, use, settings, enforcement limits, and re-qualification. |
| Native orchestration | Plans, tools, worker records, runtime commands, and rebase. |
| Installation and first-feature guide | Setup, prompt examples, updates, removal, and troubleshooting. |
| Workflow reference | Lifecycle commands, contracts, state, and evidence. |
| Domain extensions | Package installation, selection, and contribution rules. |
| Contributing | Engine changes and validation commands. |
| Security | Boundaries and security reporting. |
hooks/register.tsx 10 lines1import 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}
10hooks/core/index.ts 3386 lines1// 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.effectivehooks/ui/index.tsx 94 lines1// 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}
94hooks/core/brief.ts 153 lines1// 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}
153hooks/core/guards.ts 293 lines1// 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}
293hooks/core/settings.ts 590 lines1// 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`
590hooks/core/snapshot.ts 422 lines1// 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}
422hooks/core/ledger.ts 491 lines1// 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}
491hooks/core/notify.ts 61 lines1// 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}
61hooks/core/criteria.ts 100 lines1// 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)}`
100hooks/core/integrity.ts 428 lines1// 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}
428hooks/ui/actions.ts 516 lines1// 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