Spec-driven development and context engineering for AI coding agents, backed by project context in Git.

The agent stops guessing and starts following the system.
A coding agent can write the code. It does not know your project: what the feature must do, where the code belongs, which decisions and rules already apply. So it guesses, and you explain the same things again in the next session.
Archcore keeps specs, architecture, decisions, rules, and plans in Git, and makes the right project context available to AI coding agents as they work. It helps your coding agent make changes that fit your repo's architecture, rules, and past decisions.
/archcore:plan before you build. The agent implements from a spec, examples, and tasks, sized to the change./archcore:document as you go. One sentence from you becomes a finished, linked document./archcore:review before merge. Archcore compares the branch with the documents and names the side that is wrong.
On macOS, Linux, or WSL:
curl -fsSL https://archcore.ai/install.sh | bash
On Windows (PowerShell 5.1+):
irm https://archcore.ai/install.ps1 | iex
Then, in your project:
archcore init
The installer adds the CLI and plugins for Claude Code, Codex CLI, and GitHub Copilot CLI when it finds them. archcore init connects the agents you choose to this project. It also supports Gemini CLI, OpenCode, Roo Code, and Cline. Cursor needs one extra setup step.
Already have a CLAUDE.md, AGENTS.md, or rule files? Say /archcore:init import in your agent to turn them into project documents.
The documents stay in your repo, in .archcore/. Details: privacy.
/archcore:plan [your feature]
/archcore:plan reads the repo and the existing documents first. Then it asks you only what it cannot find there. Your answers become documents in .archcore/: a spec with requirements, and a plan with tasks mapped to files. A user-facing change also gets examples in Given/When/Then form. Larger work can add a PRD, research, or a formal requirements chain.
Archcore weighs the change, computes the route, and reports its size from S to XL. You never choose a template or a size.
| The change | What plan prepares |
|---|---|
| A small fix | No documents |
| A settled choice | A decision record |
| A change to existing behavior | A check of the covering spec: update the spec, or fix the code |
| One new capability | A spec and a plan |
| Several capabilities | A PRD, one spec per capability, and a plan |
Risk raises the size. A security requirement adds a formal requirements chain. A data migration adds a migration runbook.
The agent then implements from the spec, the examples, and the tasks. The open questions are settled before the code, not after it.
/archcore:document decision why we chose [X]
/archcore:document code [module]
/archcore:document records what is true now: a decision, a team standard, how a module works, or a how-to. You do not choose a format. Archcore selects the document type, reads the code and the existing documents, and checks that the document does not exist yet. It asks you only what it cannot find. It links the new document to the related ones. A decision can also produce the rule and the guide that follow from it.
With no subject, /archcore:document reads the changes on your branch and asks one question about what to record.
The document outlives the session. It is in Git with the code, it has a status (draft, then accepted), and review checks it against the code.
/archcore:review
When documents cover the change. /archcore:review compares the branch with them in both directions. Each finding names the code and the document that disagree, and carries one verdict: code-wrong when the code breaks a document that still stands, spec-wrong when the document is out of date. You fix the right side. When a plan covers the branch, review checks its tasks and closes the plan when the work is done.
When no document covers the change. Review still checks the branch against the decisions and rules the project has. If the branch repeats a pattern that no document records, review offers to record it. To record work that shipped without a plan, run /archcore:document.
Use the three commands together on one change, or use one alone. Archcore does not need a plan for every change.
The slash commands run in Claude Code, Cursor, Codex CLI, and GitHub Copilot. In every connected agent, a plain sentence works too: “Plan [your feature]”, “Record why we chose [X]”, “Review my branch”.
Each command leaves plain Markdown in .archcore/, versioned with the code it describes. The document type is in the filename.
.archcore/
├── architecture/
│ └── architecture-overview.doc.md ← /archcore:init
├── conventions/
│ └── project-stack.rule.md ← /archcore:init
└── api/
├── rate-limiting.spec.md ← /archcore:plan
├── rate-limiting.plan.md ← /archcore:plan
├── token-bucket-in-redis.adr.md ← /archcore:document
└── error-shapes.rule.md ← /archcore:document
A spec is one part of context, not the whole context: decisions, rules, plans, and guides live beside it. A change to a document is a diff in a pull request, like a change to code. This repository's own .archcore/ is a working example.
How Archcore works · Quick start · Commands · Contributing (source is on dev) · Apache 2.0 license
hooks/next-step.tsx 616 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PromptOrigin, Register } from 'claude-code'
3
4import type { Hint, Session, Turn } from '../types'
5
6type Step = Pick<Hint, 'reason' | 'command'>
7type Row = Record<string, unknown>
8
9const EMPTY: Turn = {
10 requestId: '', turnId: '', isDecision: false, edits: [], editCount: 0, pushed: false, drafts: 0,
11 emptySearch: null, archcoreCommand: false, plan: null, editsAfterPlan: 0, planFile: null, found: [], read: [],
12}
13const FRESH: Session = { reads: 0, superpowers: false }
14// Bump SHAPE when Turn, Session or Hint change: a reload then drops what the old code wrote.
15const SHAPE = { shape: 'v4' }
16const turn = atom({ plugin: 'archcore', key: 'turn' } as const, EMPTY, SHAPE)
17const session = atom({ plugin: 'archcore', key: 'session' } as const, FRESH, SHAPE)
18const hint = atom({ plugin: 'archcore', key: 'hint' } as const, null as Hint | null, SHAPE)
19
20// The Archcore MCP server as a plugin install and as a project .mcp.json name it.
21const SERVERS = ['plugin_archcore_archcore', 'archcore']
22const ARCHCORE_TOOL = /^mcp__(.*archcore.*)__(get_document|search_documents|create_document)$/
23// An /archcore:* command owns its own follow-up: the mod stays quiet in its request.
24// A Superpowers run keeps to its own steps (AGENTS.md Integrations rule 4): the
25// session's `superpowers` flag keeps the mod quiet through the whole flow.
26const OWN_SKILL = /^archcore:/
27// A typed slash command: the prompt's first word, or the engine's <command-name> envelope.
28const COMMAND = /^\s*\/([\w-]+(?::[\w-]+)?)(?=\s|$)|<command-name>\/?([\w-]+(?::[\w-]+)?)<\/command-name>/
29// Plan files written outside .archcore/. Superpowers keeps its design and plan files
30// under docs/superpowers/ (AGENTS.md Integrations, the rules on docs/superpowers/specs/
31// and docs/superpowers/plans/ files), so nothing there counts.
32const PLAN_FILE = /(^|\/)plan\.md$|(^|\/)plans\/[^/]+\.md$/i
33const SUPERPOWERS = /(^|\/)docs\/superpowers\//
34// search_documents filters that make an empty answer say nothing about the topic.
35const FILTERS = ['types', 'status', 'source', 'mtime_after', 'path_ref']
36// Prompt origins that are no new request from the person: they keep the request and its hint.
37const NOT_USER = new Set([
38 'task-notification', 'scheduled-trigger', 'peer', 'peer-send-message', 'projects-relay',
39 'channel', 'coordinator', 'observer', 'observer-activity', 'auto-continuation',
40])
41// git options that take the next word as their value.
42const GIT_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env'])
43const MAX_TURN_EDITS = 20
44const MAX_PENDING = 20
45const MAX_DOCS = 200
46const LOOKUP_MS = 1500
47// The Archcore server connects after the session starts: an unknown track lookup
48// is tried again after each of these waits, until the first request starts.
49const START_RETRY_MS = [2000, 5000, 10000, 20000]
50
51// ponytail: keyword heuristics on the prompt and the command line; swap for a
52// model classifier (decision) or a real shell parser (push) if they misfire.
53// A decision is an explicit first-person statement: "we decided …", "решили: …",
54// "from now on we …". A sentence that tells the assistant how to work ("from now
55// on, answer in English", "договорились, продолжай") is an instruction, not a decision.
56const WE_DECIDED = /(?:^|[.!?\n])\s*(?:we decided|we['’]ve decided|we['’]re going with)\b/i
57// "решили" or "договорились" opens a sentence (after at most two words, none of them "не")
58// and a separator follows; after a comma only "что" or a we-verb ("используем", "будем") goes on.
59const RU_DECIDED = /(?:^|[.!?\n])\s*(?:(?!не\s)[\p{L}-]+,?\s+){0,2}?(?:мы\s+)?(?:решили|договорились)\s*(?:([:—–]|-(?=\s))|,)\s*(\p{L}+)/iu
60const FROM_NOW_ON = /(?:^|[.!?\n])\s*from now on\b|(?:отныне|с этого момента|теперь всегда)(?!\p{L})/iu
61const NEXT_WORD = /^[\s,:—–-]*(?:(?:always|never|всегда|никогда)\s+)?([\p{L}'’-]+)/iu
62const ADDRESSED = /^(?:you|answer|reply|respond|write|speak|talk|use|call|please|don['’]t|do|be|keep|ask|tell|show|explain|translate|format|ты|вы|пиши(?:те)?|говори(?:те)?|ответь(?:те)?|переведи(?:те)?|[а-яё]+(?:ай|яй|ей|уй|ой)(?:те)?)$/iu
63const WE_VERB = /^(?:что|[а-яё]+(?:ем|ём|им)(?:ся)?)$/iu
64
65let server = SERVERS[0]!
66// Prompts submitted and not started yet, oldest first: a turn.start takes the one with its text.
67let pending: { text: string; isUser: boolean }[] = []
68// A skill.prompt raised before its turn.start (a typed /name) marks the coming turn.
69let isSkillPending = false
70// Between a turn.start and the main loop's turn.complete; a reload forgets it.
71let isRunning = false
72// Moved by every turn.start and /clear. State reads within one dispatch see one
73// moment, so a turn.complete compares this instead to see that it went stale.
74let epoch = 0
75const logged = new Set<string>()
76
77const str = (value: unknown) => (typeof value === 'string' ? value : '')
78const isRow = (value: unknown): value is Row => typeof value === 'object' && value !== null && !Array.isArray(value)
79const rows = (value: unknown): Row[] => (Array.isArray(value) ? value.filter(isRow) : [])
80const hasValue = (value: unknown) => (Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== '')
81const toEnd = (list: readonly string[], item: string) => [...list.filter(one => one !== item), item]
82// ponytail: the first 200 distinct paths only; the count stops there.
83const addAll = (list: readonly string[], items: readonly string[]) =>
84 [...new Set([...list, ...items.filter(Boolean)])].slice(0, MAX_DOCS)
85// C0 and C1 control characters out, whitespace runs to one space: what the band and the box show.
86const clean = (text: string) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim()
87const why = (err: unknown) => clean(err instanceof Error ? err.message : String(err)).slice(0, 120)
88
89function parse(text: unknown): Row | undefined {
90 try {
91 const value: unknown = JSON.parse(str(text))
92 return isRow(value) ? value : undefined
93 } catch {
94 return undefined
95 }
96}
97
98// First Markdown heading, else the first non-empty line: one clean line, 80 characters at most.
99function titleOf(text: string, fallback: string): string {
100 const lines = text.split('\n').map(clean).filter(Boolean)
101 const line = lines.find(one => one.startsWith('#')) ?? lines[0] ?? clean(fallback)
102 return line.replace(/^#+\s*/, '').slice(0, 80)
103}
104
105// One debug-log line per distinct reason a lookup or call was given up.
106function note($: EngineInterface, reason: string) {
107 if (logged.has(reason)) return
108 logged.add(reason)
109 $.ui.log(reason, { to: 'debug' })
110}
111
112function isDecision(prompt: string): boolean {
113 if (prompt.trimEnd().endsWith('?')) return false
114 if (WE_DECIDED.test(prompt)) return true
115 const ru = RU_DECIDED.exec(prompt)
116 if (ru && (ru[1] ? !ADDRESSED.test(ru[2] ?? '') : WE_VERB.test(ru[2] ?? ''))) return true
117 const later = FROM_NOW_ON.exec(prompt)
118 const word = later && NEXT_WORD.exec(prompt.slice(later.index + later[0].length))?.[1]
119 return typeof word === 'string' && !ADDRESSED.test(word)
120}
121
122// Heredoc bodies and quoted text are data, not commands; the rest splits into simple
123// commands, each read past VAR=value prefixes and git's global options.
124function isPush(command: string): boolean {
125 const code = command
126 .replace(/<<-?[ \t]*(['"]?)(\w+)\1([^\n]*)[\s\S]*?\n[ \t]*\2[ \t]*(?=\n|$)/g, '<<$3')
127 .replace(/'[^']*'|"(?:[^"\\]|\\[\s\S])*"/g, "''")
128 return code.split(/[;&|\n()`]/).some(part => {
129 const words = part.trim().split(/\s+/)
130 let i = 0
131 while (/^[A-Za-z_]\w*=/.test(words[i] ?? '')) i++
132 const [tool, verb, action] = words.slice(i)
133 if ((tool === 'gh' && verb === 'pr') || (tool === 'glab' && verb === 'mr')) {
134 return action === 'create' && !words.includes('--dry-run')
135 }
136 if (tool !== 'git') return false
137 i++
138 while (words[i]?.startsWith('-')) i += GIT_VALUE_OPTIONS.has(words[i]!) ? 2 : 1
139 return words[i] === 'push' && !words.slice(i + 1).some(word => word === '-n' || word === '--dry-run')
140 })
141}
142
143// Absolute, '/'-separated, '.' and '..' folded, the drive letter upper-cased; a relative
144// path is taken from `base`.
145function absolute(path: string, base: string): string {
146 const slashed = path.replace(/\\/g, '/')
147 const [head = '', ...parts] = (/^(?:\/|[A-Za-z]:\/)/.test(slashed) ? slashed : `${base}/${slashed}`).split('/')
148 const kept: string[] = []
149 for (const part of parts) {
150 if (part === '..') kept.pop()
151 else if (part !== '' && part !== '.') kept.push(part)
152 }
153 return `${head.toUpperCase()}/${kept.join('/')}`
154}
155
156function under(file: string, base: string): string | undefined {
157 const prefix = base.endsWith('/') ? base : `${base}/`
158 return file.startsWith(prefix) ? file.slice(prefix.length) : undefined
159}
160
161const rootOf = async ($: EngineInterface) => absolute(await $.session.root(), '/')
162const inRoot = (root: string, rel: string) => `${root}/${rel}`
163
164async function realPath($: EngineInterface, path: string): Promise<string | undefined> {
165 try {
166 const real = (await $.fs.stat(path, { resolve: true })).realPath
167 return real === undefined ? undefined : absolute(real, '/')
168 } catch (err) {
169 note($, `fs.stat failed: ${why(err)}`)
170 return undefined
171 }
172}
173
174// Project-relative path, or undefined outside the project root. A spelling outside the
175// root is checked again by where it lands, as a root under /var lands under /private/var.
176async function projectPath($: EngineInterface, path: string): Promise<string | undefined> {
177 const root = await rootOf($)
178 const file = absolute(path, root)
179 const rel = under(file, root)
180 if (rel !== undefined) return rel
181 const [realFile, realRoot] = await Promise.all([realPath($, file), realPath($, root)])
182 return realFile && realRoot ? under(realFile, realRoot) : undefined
183}
184
185// undefined when unknown: the engine refused the check.
186async function hasArchcore($: EngineInterface): Promise<boolean | undefined> {
187 try {
188 return await $.fs.exists(inRoot(await rootOf($), '.archcore'))
189 } catch (err) {
190 note($, `fs.exists failed: ${why(err)}`)
191 return undefined
192 }
193}
194
195const LATE: unique symbol = Symbol('late')
196
197// The work's answer, or undefined after LOOKUP_MS. A sleep the clock refuses sets no deadline.
198async function timed<T>($: EngineInterface, what: string, work: Promise<T | undefined>): Promise<T | undefined> {
199 const stop = new AbortController()
200 const deadline = $.clock.sleep(LOOKUP_MS, { signal: stop.signal }).then(
201 (): typeof LATE => LATE,
202 () => new Promise<never>(() => {}),
203 )
204 try {
205 const value = await Promise.race([work, deadline])
206 if (value !== LATE) return value
207 note($, `${what} took over ${LOOKUP_MS} ms`)
208 return undefined
209 } finally {
210 stop.abort()
211 }
212}
213
214async function callServer($: EngineInterface, name: string, tool: string, args: Row): Promise<Row | undefined> {
215 try {
216 const res = await $.mcp.call(name, tool, args)
217 const body = res.isError ? undefined : parse(res.content.find(block => block.type === 'text')?.text)
218 if (!body) note($, `${tool} on ${name} answered ${res.isError ? 'an error' : 'no JSON object'}`)
219 return body
220 } catch (err) {
221 note($, `${tool} on ${name} failed: ${why(err)}`)
222 return undefined
223 }
224}
225
226// The tool's JSON answer, or undefined when it is unknown: every server threw,
227// reported an error or answered no JSON object, or the deadline passed.
228function callArchcore($: EngineInterface, tool: string, args: Row): Promise<Row | undefined> {
229 return timed($, tool, (async () => {
230 for (const name of [server, ...SERVERS.filter(one => one !== server)]) {
231 const body = await callServer($, name, tool, args)
232 if (body) {
233 server = name
234 return body
235 }
236 }
237 return undefined
238 })())
239}
240
241async function nextStep($: EngineInterface, t: Turn, s: Session): Promise<Step | null> {
242 if (t.archcoreCommand || s.superpowers) return null
243 // No .archcore/ (or no answer): only the session-start /archcore:init hint speaks.
244 if ((await hasArchcore($)) !== true) return null
245
246 // A plan approved or proposed and not yet implemented; once edits follow,
247 // /archcore:plan no longer fits ("not for documenting existing code").
248 if (t.plan && t.editsAfterPlan === 0) {
249 const state = t.plan.isProposed ? 'proposed' : 'approved'
250 return { reason: `The ${state} plan is outside .archcore/. Record it?`, command: `/archcore:plan ${t.plan.title}` }
251 }
252
253 if (t.isDecision) {
254 return { reason: 'This sounds like a decision. Record it?', command: '/archcore:document decision ' }
255 }
256
257 // Drift, coverage and closeout need the engine's code alignment and staleness
258 // advisories (engine-runtime-boundary.adr): the runtime does not compute them.
259 if (t.pushed) {
260 return { reason: 'The branch went out for review.', command: '/archcore:review' }
261 }
262 if (t.drafts > 0) {
263 return { reason: `${t.drafts} draft document(s) created; they are not accepted yet.`, command: '/archcore:review' }
264 }
265 if (t.planFile && t.edits.length === 0) {
266 let topic = titleOf('', t.planFile)
267 try {
268 topic = titleOf(await $.fs.read(inRoot(await rootOf($), t.planFile)), t.planFile)
269 } catch (err) {
270 note($, `fs.read of a plan file failed: ${why(err)}`)
271 }
272 return { reason: `A plan was written to ${clean(t.planFile)}, outside Archcore.`, command: `/archcore:plan ${topic}` }
273 }
274 if (t.edits.length > 0 && s.reads === 0) {
275 return { reason: `${t.editCount} file(s) edited without reading any Archcore document this session.`, command: '/archcore:review' }
276 }
277 if (t.emptySearch) {
278 return { reason: `Nothing in Archcore matches "${t.emptySearch}".`, command: `/archcore:document code ${t.emptySearch}` }
279 }
280 return null
281}
282
283// The command that resumes a track, by its id (skills/_shared/tracks/<id>.md and the
284// argument hints of skills/*/SKILL.md). Standalone evidence is filed through document research.
285function resumeOf(track: string, title: string, type: string): string | undefined {
286 switch (track) {
287 case 'research':
288 return type === 'evidence' ? `/archcore:document research ${title}` : `/archcore:plan ${title}`
289 case 'sdd':
290 case 'requirements-cascade':
291 return `/archcore:plan ${title}`
292 case 'decision':
293 return `/archcore:document decision ${title}`
294 case 'describe':
295 return `/archcore:document code ${title}`
296 case 'actualize':
297 case 'closeout':
298 case 'experience':
299 return '/archcore:review'
300 case 'import':
301 return '/archcore:init import'
302 default:
303 return undefined
304 }
305}
306
307// The first `gate:` inside an archcore:track block outside fenced code, unless it is
308// the gate contract's `<track>.<stage>` placeholder.
309function gateOf(content: string): string | undefined {
310 const text = content.replace(/^ {0,3}(`{3,}|~{3,})[^\n]*\n[\s\S]*?^ {0,3}\1[^\n]*$/gm, '')
311 for (const [, block = ''] of text.matchAll(/<!-- archcore:track\b([\s\S]*?)-->/g)) {
312 const gate = /^gate:\s*([a-z][a-z-]*\.[a-z-]+)\s*$/m.exec(block)?.[1]
313 if (gate) return gate
314 }
315 return undefined
316}
317
318// A local draft whose track block names a real gate is a track its command resumes; plans first.
319// Undefined when the search answer is unknown, null when no draft is stopped.
320async function stoppedTrack($: EngineInterface): Promise<Step | null | undefined> {
321 const body = await callArchcore($, 'search_documents', {
322 content: '<!-- archcore:track', match: 'exact', status: 'draft', source: 'local', limit: 10,
323 })
324 if (!body || !Array.isArray(body.results)) return undefined
325 const drafts = rows(body?.results).filter(row => row.source_kind === 'local')
326 for (const row of [...drafts.filter(one => one.type === 'plan'), ...drafts.filter(one => one.type !== 'plan')]) {
327 const gate = gateOf(str((await callArchcore($, 'get_document', { path: row.path }))?.content))
328 const [track = '', stage = ''] = gate?.split('.') ?? []
329 const title = titleOf(str(row.title), str(row.path))
330 const command = resumeOf(track, title, str(row.type))
331 if (command) return { reason: `Draft "${title}" stopped at the ${stage.replace(/-/g, ' ')} step.`, command }
332 }
333 return null
334}
335
336// The search topic when a filter-free search_documents found nothing and no near miss.
337function emptySearchTopic(args: Row, body: Row | undefined): string {
338 if (!body || !Array.isArray(body.results) || body.results.length > 0) return ''
339 if (hasValue(body.near_misses) || FILTERS.some(key => hasValue(args[key]))) return ''
340 return titleOf(str(args.content), '')
341}
342
343async function recordEdit($: EngineInterface, path: string) {
344 const rel = await projectPath($, path)
345 // Edits outside the project, to documents, and to Superpowers files say nothing about code.
346 if (rel === undefined || rel.startsWith('.archcore/') || SUPERPOWERS.test(rel)) return
347 if (PLAN_FILE.test(rel)) {
348 await update($, turn, t => (t.planFile ? t : { ...t, planFile: rel }))
349 return
350 }
351 await update($, turn, t => ({
352 ...t,
353 edits: toEnd(t.edits, rel).slice(-MAX_TURN_EDITS),
354 // ponytail: a file re-edited after it left the last 20 counts twice.
355 editCount: t.editCount + (t.edits.includes(rel) ? 0 : 1),
356 editsAfterPlan: t.plan ? t.editsAfterPlan + 1 : 0,
357 }))
358}
359
360// A hint names a command the person can run now, or none: the Archcore plugin may be off.
361async function isAvailable($: EngineInterface, command: string): Promise<boolean> {
362 const name = command.slice(1).split(' ')[0]
363 try {
364 if ((await $.command.list()).some(one => one.name === name)) return true
365 note($, `/${name} is not available; no hint`)
366 } catch (err) {
367 note($, `command.list failed: ${why(err)}`)
368 }
369 return false
370}
371
372const suggest = ($: EngineInterface, command: string) =>
373 $.prompt.suggest({ text: command }).then(
374 done => done.isShown,
375 (err: unknown) => {
376 note($, `prompt.suggest failed: ${why(err)}`)
377 return false
378 },
379 )
380
381const markShown = ($: EngineInterface) => update($, hint, h => (h ? { ...h, shown: true } : h))
382
383// Draws the step in the band at once; "Tab →" replaces "run:" once the box takes it.
384// `started` is the epoch the step was computed in: a later request owns the band.
385async function show($: EngineInterface, step: Step, requestId: string, started: number) {
386 if (started !== epoch || !(await isAvailable($, step.command))) return
387 await update($, hint, () => ({ ...step, requestId, shown: false }))
388 // The write may land after the next request began: the band skips such a hint; the box must not get it.
389 if (started !== epoch) return
390 if (await suggest($, step.command)) {
391 await markShown($)
392 return
393 }
394 // The box declines while the turn winds down: one more try once the session is idle.
395 $.clock.after(0, () => {
396 if (started !== epoch) return
397 suggest($, step.command)
398 .then(isShown => (isShown ? markShown($) : undefined))
399 .catch((err: unknown) => note($, `retry of prompt.suggest failed: ${why(err)}`))
400 })
401}
402
403// The session-start hint: /archcore:init without .archcore/, else a stopped track to resume.
404async function startHint($: EngineInterface, started: number, attempt = 0): Promise<void> {
405 if (started !== epoch) return
406 const has = await hasArchcore($)
407 if (has === false) return show($, { reason: 'This repository has no .archcore/.', command: '/archcore:init' }, '', started)
408 if (!has) return
409 const step = await stoppedTrack($)
410 if (step) return show($, step, '', started)
411 if (step === null) return
412 const wait = START_RETRY_MS[attempt]
413 if (wait === undefined) return note($, 'session start: the Archcore server did not answer; no resume hint')
414 $.clock.after(wait, () => {
415 startHint($, started, attempt + 1).catch((err: unknown) => note($, `session start lookup failed: ${why(err)}`))
416 })
417}
418
419const isUserOrigin = (origin: PromptOrigin) => (origin.kind === 'plugin' ? origin.asUser === true : !NOT_USER.has(origin.kind))
420const commandOf = (prompt: string) => {
421 const m = COMMAND.exec(prompt)
422 return m?.[1] ?? m?.[2]
423}
424
425export const register: Register = (on, options) => {
426 // The /config row "Next-step hints" (userConfig next_step_hints) turns the mod off;
427 // a change reloads the module with the new value.
428 if (options.next_step_hints === false) return
429 on('session.start', async ($, e, next) => {
430 const done = await next(e)
431 // A -p run or an SDK host draws nowhere yet: no lookups and no hint.
432 if (!e.isInteractive || e.surface === null) return done
433 // The session's first start only: a reload, an enable or a respawn keeps the live hint.
434 if ((await read($, turn)).turnId !== '' || (await read($, hint)) !== null) return done
435 const started = epoch
436 // Detached, so the lookups do not hold the first prompt.
437 $.clock.after(0, () => {
438 startHint($, started).catch((err: unknown) => note($, `session start lookup failed: ${why(err)}`))
439 })
440 return done
441 })
442
443 // A /clear ends the conversation; no session.start follows it.
444 on('session.end', async ($, e, next) => {
445 if (e.reason === 'clear') {
446 epoch++
447 isSkillPending = false
448 pending = []
449 await update($, turn, () => EMPTY)
450 await update($, hint, () => null)
451 await update($, session, () => FRESH)
452 }
453 return next(e)
454 })
455
456 on('prompt.submit', ($, e, next) => {
457 pending = [...pending, { text: e.text.trim(), isUser: isUserOrigin(e.origin) }].slice(-MAX_PENDING)
458 return next(e)
459 })
460
461 on('skill.prompt', async ($, e, next) => {
462 if (OWN_SKILL.test(e.skill)) {
463 isSkillPending = true
464 await update($, turn, t => ({ ...t, archcoreCommand: true }))
465 }
466 // A superpowers:* skill starts the quiet flow; an /archcore:* skill, or another
467 // skill the person typed (expanded before its turn starts), ends it.
468 const isSuperpowers = e.skill.startsWith('superpowers:')
469 if (isSuperpowers || OWN_SKILL.test(e.skill) || !isRunning) {
470 await update($, session, s => (s.superpowers === isSuperpowers ? s : { ...s, superpowers: isSuperpowers }))
471 }
472 return next(e)
473 })
474
475 on('turn.start', async ($, e, next) => {
476 const prompt = e.text.trim()
477 const i = pending.findIndex(one => one.text === prompt)
478 const isUser = i < 0 || pending[i]!.isUser
479 if (i >= 0) pending.splice(i, 1)
480 const command = commandOf(prompt)
481 const isOwnCommand = isSkillPending || (command !== undefined && OWN_SKILL.test(command))
482 // An empty text continues the same request; a notification, a peer or a
483 // schedule is no new user request: both keep the request and the hint.
484 const isNewRequest = prompt !== '' && isUser
485 isRunning = true
486 epoch++
487 if (isNewRequest) {
488 await update($, turn, () => ({
489 ...EMPTY, requestId: e.turnId, turnId: e.turnId, isDecision: isDecision(prompt), archcoreCommand: isOwnCommand,
490 }))
491 if (command !== undefined) {
492 const isSuperpowers = command.startsWith('superpowers:')
493 await update($, session, s => (s.superpowers === isSuperpowers ? s : { ...s, superpowers: isSuperpowers }))
494 }
495 } else {
496 await update($, turn, t => ({ ...t, turnId: e.turnId, archcoreCommand: t.archcoreCommand || isOwnCommand }))
497 }
498 return next(e)
499 })
500
501 on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
502 const ran = await next(e)
503 if (ran.deny === undefined && !ran.isError) await recordEdit($, str(e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path))
504 return ran
505 })
506
507 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
508 const ran = await next(e)
509 const plan = ran.deny === undefined && !ran.isError && isRow(ran.result) ? str(ran.result.plan) : ''
510 if (plan) {
511 await update($, turn, t => ({ ...t, plan: { title: titleOf(plan, 'the approved plan'), isProposed: false }, editsAfterPlan: 0 }))
512 }
513 return ran
514 })
515
516 on('tool.call', { tool: 'Agent', subagent_type: 'Plan' }, async ($, e, next) => {
517 const ran = await next(e)
518 if (ran.deny === undefined && !ran.isError) {
519 await update($, turn, t => ({ ...t, plan: { title: titleOf(e.description, 'the plan'), isProposed: true }, editsAfterPlan: 0 }))
520 }
521 return ran
522 })
523
524 on('tool.call', { tool: 'Bash', command: /\b(?:git|gh|glab)\b/ }, async ($, e, next) => {
525 const ran = await next(e)
526 if (ran.deny === undefined && !ran.isError && isPush(e.command)) await update($, turn, t => ({ ...t, pushed: true }))
527 return ran
528 })
529
530 on('tool.call', { tool: ARCHCORE_TOOL }, async ($, e, next) => {
531 const ran = await next(e)
532 const m = ARCHCORE_TOOL.exec(e.tool)
533 if (ran.deny !== undefined || ran.isError || !m) return ran
534 server = m[1] ?? server
535 const args = e as unknown as Row
536 if (m[2] === 'create_document') {
537 await update($, turn, t => ({ ...t, drafts: t.drafts + 1 }))
538 } else if (m[2] === 'get_document') {
539 await update($, session, s => ({ ...s, reads: s.reads + 1 }))
540 await update($, turn, t => ({ ...t, read: addAll(t.read, [str(args.path)]) }))
541 } else {
542 const body = parse(ran.text)
543 const paths = rows(body?.results).map(row => str(row.path))
544 if (args.mode === 'full' && paths.length > 0) await update($, session, s => ({ ...s, reads: s.reads + 1 }))
545 await update($, turn, t => ({
546 ...t, found: addAll(t.found, paths), read: args.mode === 'full' ? addAll(t.read, paths) : t.read,
547 }))
548 const topic = emptySearchTopic(args, body)
549 if (topic) await update($, turn, t => (t.emptySearch ? t : { ...t, emptySearch: topic }))
550 }
551 return ran
552 })
553
554 on('turn.complete', async ($, e, next) => {
555 const done = await next(e)
556 // A subagent's run raises no turn.start; its completion is not the request's.
557 if (e.agentId !== undefined) return done
558 isSkillPending = false
559 isRunning = false
560 const started = epoch
561 const t = await read($, turn)
562 if (t.turnId !== e.turnId || e.reason !== 'answer') return done
563 // Nothing draws the band or the box (a -p run, an SDK host before it attaches).
564 if ((await $.session.surfaces()).length === 0) return done
565 const step = await nextStep($, t, await read($, session))
566 if (step) await show($, step, t.requestId, started)
567 return done
568 })
569
570 // The engine guesses the next prompt after the turn too and would replace ours.
571 on('prompt.suggest', { origin: { kind: 'suggestion' } }, async ($, e, next) => {
572 const step = await read($, hint)
573 if (!step || step.requestId !== (await read($, turn)).requestId) return next(e)
574 const done = await next({ ...e, text: step.command })
575 if (done.isShown) await markShown($)
576 return done
577 })
578
579 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
580 // A survey, a running turn, or a subagent's transcript.
581 if (e.props.hasSurvey || e.props.isWorking || e.props.view.agentId !== undefined) return next(e)
582 const t = await read($, turn)
583 const stored = await read($, hint)
584 // A hint of an earlier request is a late write.
585 const step = stored && stored.requestId === t.requestId ? stored : null
586 const docs = t.found.length + t.read.length > 0 ? `documents: ${t.found.length} found · ${t.read.length} read` : ''
587 if (!step && !docs) return next(e)
588 const { Box, Button, Text } = $.ui.resolve(e)
589 // A band tree replaces later mods' band content; theirs stays below ours.
590 const below = await next(e)
591 if (!step) {
592 return (
593 <Box flexDirection="column">
594 <Box>
595 <Text color="cyan">archcore </Text>
596 <Text dimColor wrap="truncate-end">{docs}</Text>
597 </Box>
598 {below}
599 </Box>
600 )
601 }
602 return (
603 <Box flexDirection="column">
604 <Box>
605 <Button key="hide" label="Hide" onPress={() => update($, hint, () => null)} />
606 <Text color="cyan"> archcore </Text>
607 <Text wrap="truncate-end">{step.reason}</Text>
608 </Box>
609 <Text dimColor wrap="truncate-end">{`${step.shown ? 'Tab →' : 'run:'} ${step.command.trim()}`}</Text>
610 {docs ? <Text dimColor wrap="truncate-end">{docs}</Text> : null}
611 {below}
612 </Box>
613 )
614 })
615}
616types/index.d.ts 49 lines1// The values the next-step hints mod (hooks/next-step.tsx) keeps in $.state.
2// What the current request has done so far; reset on each new user request.
3export type Turn = {
4 // The turnId of the turn.start that began this request; '' before any. A hint names it.
5 requestId: string
6 // The id of the last turn.start; a turn.complete with another id is stale.
7 turnId: string
8 // The request's prompt reads as a decision, judged at turn.start.
9 isDecision: boolean
10 // Project-relative paths of edited code files, most recent last, the last 20 only:
11 // not .archcore/, plan files, or docs/superpowers/.
12 edits: string[]
13 // Distinct files edited, for the "N file(s)" reason; it counts past the 20 kept.
14 editCount: number
15 pushed: boolean
16 drafts: number
17 // The first search_documents topic that matched nothing.
18 emptySearch: string | null
19 // The request ran an /archcore:* or superpowers:* command or skill.
20 archcoreCommand: boolean
21 // A plan approved in plan mode, or proposed by the Plan subagent.
22 plan: { title: string; isProposed: boolean } | null
23 editsAfterPlan: number
24 // The first plan file written outside .archcore/ and docs/superpowers/.
25 planFile: string | null
26 // Distinct document paths the request's searches returned, the first 200 only.
27 found: string[]
28 // Distinct document paths the request read: get_document and full-mode search results.
29 read: string[]
30}
31
32// What the conversation has done since it began or since /clear.
33export type Session = {
34 // Archcore documents read: get_document calls and full-mode searches that returned documents.
35 reads: number
36 // A superpowers:* skill ran and no other slash command or /archcore:* skill followed.
37 superpowers: boolean
38}
39
40// A next step proposed after a turn: why, the command, whether the prompt box
41// took the command as its Tab suggestion, and the request it belongs to.
42export type Hint = { reason: string; command: string; shown: boolean; requestId: string }
43
44declare module 'claude-code' {
45 interface PluginState {
46 archcore: { turn: Shaped<Turn>; session: Shaped<Session>; hint: Shaped<Hint | null> }
47 }
48}
49