SLOPSHOPPER

archcore

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

newbandguardprompttimer
★ 68v0.12.0Apache-2.0updated 2026-10-09archcore-ai/archcore/plugins/archcore
A shopper browsing a rack in a slop shop
README

Archcore - Spec-driven development and git-native context engineering for AI coding agents

License Release Platform Go Docs

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.

Archcore: from idea to reviewed code

Install

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.

Plan: the agent builds from documents, not from a chat message

/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 changeWhat plan prepares
A small fixNo documents
A settled choiceA decision record
A change to existing behaviorA check of the covering spec: update the spec, or fix the code
One new capabilityA spec and a plan
Several capabilitiesA 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.

Document: you say it once, Archcore writes the document

/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.

Review: the code and the documents agree before merge

/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”.

Project knowledge becomes files

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.

Go deeper

How Archcore works · Quick start · Commands · Contributing (source is on dev) · Apache 2.0 license

Source 2 files
hooks/next-step.tsx 616 lines
1import { 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}
616
types/index.d.ts 49 lines
1// 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