8000 feat(hosts): natural-language auto-trigger — a plain build intent runs as /aladdin by adeelahmad · Pull Request #34 · adeelahmad/aloop · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ tag in lockstep. Tag releases as `vMAJOR.MINOR.PATCH`.
## [Unreleased]

### Added
- Natural-language auto-trigger (ROADMAP §4.11): `bin/intent-trigger` on UserPromptSubmit adds context
to run a plain build/fix request as `/aladdin "<intent>"` when it lands in an aloop project with no
sprint running. Quiet for questions, slash commands, short replies, a live sprint lock and non-aloop
repos; switch off with `ALADDIN_AUTO_TRIGGER=off` or `intent: {auto_trigger: false}` in
`.aladdin/config.yaml`. Wired for Claude Code and Codex (hooks.json) and OpenCode (`chat.message`).
- The planner's stop gate (`gate-stage2-complete` on SubagentStop) now logs its verdicts to `gates.jsonl` and the event stream like every other gate, and the stuck release covers the planner: the same block three times in a row releases its stop unverified with a `state/stuck/plan-sprintN.<agent>` marker the supervisor acts on (a re-dispatch is a new agent id and starts over). A direct call (the supervisor's own check, `selfcheck`) is logged as a self-check and never releases. Reason: live e2e run 19's re-planning planner was blocked 9 times on one unchanged reason, none counted. Test: `scripts/test/planner-stuck.test.py`.

### Fixed
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ test-hosts: ## Budget/worktree regression + Codex hooks emulation + OpenCode plu
python3 scripts/test/log-execution.test.py
python3 scripts/test/ensure-tools.test.py
python3 scripts/test/fail-closed.test.py
python3 scripts/test/intent-trigger.test.py
@if command -v node >/dev/null; then node scripts/test/opencode-plugin.test.mjs; else echo "node not found — skipped OpenCode test (CI runs it)"; fi

.PHONY: conformance
Expand Down
2 changes: 1 addition & 1 deletion docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ The model can ignore a skill; it cannot ignore an exit code.
| Supervisor playbook | [`plugin/skills/aloop/SKILL.md`](../plugin/skills/aloop/SKILL.md) | The skill the main agent runs; `aladdin` and `init` are its entry points |
| 9 roles | [`plugin/agents/`](../plugin/agents/) | `archivist`, `intake`, `standards`, `planner` (Opus) · `red-worker`, `scaffolder`, `green-worker`, `structural-reviewer`, `final-gate` (Sonnet) |
| Pipeline source | [`plugin/pipeline/`](../plugin/pipeline/) | Per activity: `persona.md`, `init.template.md`, `artifacts.md`, `gate.md` |
| Hook wiring | [`plugin/hooks/hooks.json`](../plugin/hooks/hooks.json) | SessionStart, PreToolUse, PostToolUse, SubagentStart/Stop, Stop, UserPromptSubmit |
| Hook wiring | [`plugin/hooks/hooks.json`](../plugin/hooks/hooks.json) | SessionStart, PreToolUse, PostToolUse, SubagentStart/Stop, Stop, UserPromptSubmit (transcript capture and the natural-language auto-trigger, `intent-trigger`) |
| Gates | [`plugin/bin/gate-*`](../plugin/bin/README.md) | 15 bash gates; exit `0` pass, `2` block with reason |
| Harness tools | [`plugin/bin/`](../plugin/bin/README.md) | `aladdin` / `aloop` (entry points), `intent`, `budget` (time box, envelope, breach classes), `sweep` (the orchestrator loop), `targets` (semantic plan, shadow workspace, render), `ids`, `events` (audit stream, lineage, trace), `stats` (ledger, metrics, handoff), `models` / `host` / `config` (routes, HostAdapter, portable config), `transcripts`, `install`, `aloop-init`, worktree helpers |
| Schemas | [`plugin/schemas/*.kdl`](../plugin/schemas/) | What `md-db validate` enforces on every planning and comms artifact |
Expand Down
4 changes: 2 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ original plan.
| **M5 — Narrow context** | v0.16 | Session 6: semantic execution map, shadow workspace, `target` / `context` CLI (§4.6) | done: targets, shadow workspace, `target`/`context`, LSP seam, GREEN scope from targets, and tasks.md/validate.md/plan.md rendered from the semantic plan (`targets render`; tests: `semantic-targets`, `lsp-adapter`, `targets-scope`, `plan-render`) |
| **M6 — Audit** | v0.17 | Sessions 7–8: event stream, rationale, authority, lineage query (§4.7); provider seam (§4.8) | done: `aladdin.event/1` stream, decisions, authority, lineage, `events trace SHA`, commit messages and graph export (§4.7); impact provider seam (§4.8) |
| **M7 — Any host** | v0.18 | Session 9: `HostAdapter`, capability matrix, `.aladdin/config.yaml`, host swap, metrics (§4.9–4.10) | done: `HostAdapter` + `host check` in CI, capability matrix, usage accuracy, `.aladdin/config.yaml` with migration, `host swap`, the full metrics table incl. context-window share (tests: `host-adapter`, `portable-config`, `host-swap`, `metrics`) |
| **M8 — Aladdin 1.0** | v1.0 | Session 10: `/aladdin` + `aladdin` CLI with aliases, site, measured public runs (§4.11). The rename to aloop shipped early in v0.11.0 | in progress: `bin/aladdin` + `/aladdin` with `aloop`/`agentic` aliases (test: `aladdin-cli.test.py`); README reordered product-first; site at `docs/index.html` (Pages switch is the owner's) |
| **M8 — Aladdin 1.0** | v1.0 | Session 10: `/aladdin` + `aladdin` CLI with aliases, site, measured public runs (§4.11). The rename to aloop shipped early in v0.11.0 | in progress: `bin/aladdin` + `/aladdin` with `aloop`/`agentic` aliases (test: `aladdin-cli.test.py`); natural-language auto-trigger on every host (`intent-trigger`, test: `intent-trigger.test.py`); README reordered product-first; site at `docs/index.html` (Pages switch is the owner's). Open: evidence links from every public claim to a measured run |

**Order rationale:** identity and conformance fixtures first, so every later change is
measured against today's behaviour; budgets and handoff next, because they shape every run;
Expand Down Expand Up @@ -272,7 +272,7 @@ No extra model calls are made just for telemetry.
### 4.11 Rebrand and product surface (last)

- ✅ *Done (Unreleased): `bin/aladdin` (`"<intent>"`, `run`, `status`, `resume`, `report`, the semantic queries, every aloop tool) and the `/aladdin` skill, linked and copied by the installers beside `aloop` / `agentic`. Test: `scripts/test/aladdin-cli.test.py`. The README is reordered product-first (test: `scripts/test/readme.test.py` checks the §4.11 order, that every tool it names exists, the install names, every link and that each demo figure is one §0 records) and the site is `docs/index.html` (one self-contained page: original lamp mark, the RED explainer, the audit tree; test: `scripts/test/site.test.py` checks every time/cost number against §0). Turning GitHub Pages on (Settings → Pages → deploy from branch, `/docs`) is the owner's switch.*
- **New entry points:** `/aladdin` and an `aladdin` CLI (`"<intent>"`, `run`, `status`, `resume`, `report`, plus the semantic queries). Natural-language auto-trigger inside hosts.
- **New entry points:** `/aladdin` and an `aladdin` CLI (`"<intent>"`, `run`, `status`, `resume`, `report`, plus the semantic queries). ✅ *Natural-language auto-trigger inside hosts (Unreleased): `bin/intent-trigger` on UserPromptSubmit (Claude Code, Codex via hooks.json; OpenCode via `chat.message`) adds the `/aladdin "<intent>"` context when a plain build/fix request lands in an aloop project with no sprint running; quiet for questions, slash commands, short replies, a live sprint lock and non-aloop repos; off with `ALADDIN_AUTO_TRIGGER=off` or `intent: {auto_trigger: false}`. Test: `scripts/test/intent-trigger.test.py`. Reason: the intent is the product's entry point, so typing it should be enough.*
- **Compatibility aliases for one migration window:**
- `/agentic-agile:init`
- the `agentic` command
Expand Down
1 change: 1 addition & 0 deletions plugin/bin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ ctx-symbols) → WARN + grep fallback; never a silent pass.
| script | purpose |
|--------|---------|
| `transcripts` | full capture: stage-in / record / prompt / snapshot / stop / view / prune (`transcripts --help`) |
| `intent-trigger` | UserPromptSubmit · natural-language auto-trigger: a plain build/fix request in an aloop project with no sprint running gets context to run it as `/aladdin "<intent>"`; quiet for questions, slash commands, short replies, a live sprint lock, non-aloop repos; off with `ALADDIN_AUTO_TRIGGER=off` / `intent: {auto_trigger: false}`. Always exit 0, writes nothing |
| `worktree-hygiene` | SubagentStart · exec roles — sparse-checks `docs/agents/` out of the new worktree (workers reach it via the absolute `STORY_DIR` back into the main tree, never their own copy) + bootstraps the main tree's `.gitignore` with `.agentic/`/`.transcripts/` if missing. Runs regardless of whether the harness's built-in isolation or `worktree-create` made the worktree. |
| `worktree-create` | OPTIONAL `git worktree add` helper — wire into **user `settings.json`** as a `WorktreeCreate` hook for a non-git VCS (NOT a valid plugin-manifest event). Built-in `isolation: "worktree"` covers plain git. Also applies the same sparse-checkout + `.gitignore` bootstrap as `worktree-hygiene`. |
| `worktree-remove` | OPTIONAL `git worktree remove` cleanup companion for the above (user `settings.json` `WorktreeRemove`) |
Expand Down
1 change: 1 addition & 0 deletions plugin/bin/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"effort_planning_codex": "EFFORT_PLANNING_CODEX", "effort_worker_codex": "EFFORT_WORKER_CODEX",
"planning_opencode": "MODEL_PLANNING_OPENCODE", "worker_opencode": "MODEL_WORKER_OPENCODE"},
"provenance": {"impact_provider": "IMPACT_PROVIDER"},
"intent": {"auto_trigger": "AUTO_TRIGGER"},
"memory": {},
"git": {"gitignore": "GITIGNORE", "track_docs": "TRACK_DOCS", "commit_author_name": "COMMIT_AUTHOR_NAME",
"commit_author_email": "COMMIT_AUTHOR_EMAIL", "claude_coauthor": "CLAUDE_COAUTHOR"},
Expand Down
2 changes: 1 addition & 1 deletion plugin/bin/_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
# ALADDIN_<KEY> is the new spelling of every AGENTIC_<KEY> environment override (ROADMAP M1):
# both are read, the old one wins when both are set; scripts keep using the AGENTIC_ name.
ENV_KEYS = ("HOST", "CWD", "NO_AUTOINSTALL", "NO_BUDGET", "LSP_COMMAND", "IMPACT_PROVIDER" 8 , " 10BC8 SELFCHECK", "WORKTREE",
"AUTONOMY_MODE", "STUCK_BLOCKS", "SPRINT_LOCK_TTL", "TRANSCRIPTS_KEEP")
"AUTONOMY_MODE", "STUCK_BLOCKS", "SPRINT_LOCK_TTL", "TRANSCRIPTS_KEEP", "AUTO_TRIGGER")
for _k in ENV_KEYS:
if not os.environ.get("AGENTIC_" + _k) and os.environ.get("ALADDIN_" + _k):
os.environ["AGENTIC_" + _k] = os.environ["ALADDIN_" + _k]
Expand Down
107 changes: 107 additions & 0 deletions plugin/bin/intent-trigger
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""intent-trigger — natural-language auto-trigger (ROADMAP §4.11, M8).

intent-trigger hook UserPromptSubmit: stdin = the host's hook payload

A plain build/fix request typed into an aloop project, while no sprint is running, is the
loop's own entry point: this hook adds context telling the host to treat the prompt as
`/aladdin "<intent>"` (load the aladdin skill, which runs the aloop playbook). It decides
nothing else: the human still approves the plan, every gate still runs.

Quiet (no output) when: the prompt is a question, a slash command or a short reply; a sprint
lock is live (the prompt is feedback to the running loop); the repo is not an aloop project
(no docs/agents/ and no run-data dir); or it is switched off with AGENTIC_AUTO_TRIGGER=off
(ALADDIN_AUTO_TRIGGER) or `intent: {auto_trigger: false}` in .aladdin/config.yaml.
Always exit 0; never writes a file.
"""
import json
import os
import re
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))
import _paths # noqa: E402

OFF = ("off", "0", "false", "no")
# An imperative first clause, or a stated want. Kept narrow on purpose: a miss costs the user
# one `/aladdin`, a false hit costs them one paragraph of context.
VERBS = (r"add|build|implement|create|make|write|fix|repair|refactor|migrate|port|support|remove|replace|rename|"
r"extract|introduce|convert|upgrade|optimi[sz]e|speed up|improve|change|update|wire|expose|parse|handle|"
r"ship|deliver|develop|design|generate|automate|integrate|enable|allow|let|teach|get")
INTENT = re.compile(r"^(?:please\s+|can you\s+|could you\s+|let'?s\s+)?(?:%s)\b" % VERBS, re.I)
WANT = re.compile(r"\b(?:i|we)\s+(?:want|need|would like)\b|\bshould\s+(?:be able to|support|accept|handle)\b", re.I)
MIN_WORDS = 4


def switched_off(cwd):
v = _paths.env("AUTO_TRIGGER")
if v is None:
try:
import _config
v = (_config.load(cwd) or {}).get("AUTO_TRIGGER")
except Exception: # a broken config never blocks a prompt
v = None
return str(v).strip().lower() in OFF if v is not None else False


def aloop_project(cwd):
root = _paths.project(cwd)
return os.path.isdir(_paths.docs(cwd)) or any(os.path.isdir(os.path.join(root, h)) for h in (_paths.NEW_HOME, _paths.OLD_HOME))


def sprint_live(cwd):
root = _paths.project(cwd)
ttl = int(_paths.env("SPRINT_LOCK_TTL", "14400") or 14400)
for h in (_paths.NEW_HOME, _paths.OLD_HOME):
lock = os.path.join(root, h, "state", "sprint.lock")
try:
return time.time() - os.path.getmtime(lock) < ttl
except OSError:
continue
return False


def is_intent(prompt):
p = " ".join(prompt.strip().split())
if not p or p.startswith("/") or p.endswith("?") or len(p.split()) < MIN_WORDS:
return False
return bool(INTENT.match(p) or WANT.search(p))


def context(prompt, root):
skill = os.path.join(os.path.dirname(os.path.dirname(os.path.realpath(__file__))), "skills", "aladdin", "SKILL.md")
short = prompt.strip().replace("\n", " ")
if len(short) > 200:
short = short[:197] + "..."
return ("[aloop] This reads as a build intent and no sprint is running in %s. Treat it as "
"/aladdin \"%s\": load the aladdin skill (%s) and follow it from intake; the human still "
"approves the plan before any worker runs. If it is not an intent, answer normally. "
"Silence this with ALADDIN_AUTO_TRIGGER=off or `intent: {auto_trigger: false}` in .aladdin/config.yaml."
% (root, short.replace('"', "'"), skill))


def main():
if len(sys.argv) < 2 or sys.argv[1] != "hook":
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 0
try:
payload = json.loads(sys.stdin.read() or "{}")
except ValueError:
return 0
prompt = payload.get("prompt") if isinstance(payload, dict) else None
cwd = (payload.get("cwd") if isinstance(payload, dict) else None) or os.getcwd()
if not isinstance(prompt, str) or not is_intent(prompt):
return 0
try:
if switched_off(cwd) or not aloop_project(cwd) or sprint_live(cwd):
return 0
print(json.dumps({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": context(prompt, _paths.project(cwd))}}))
except Exception:
return 0
return 0


if __name__ == "__main__":
sys.exit(main())
3 changes: 2 additions & 1 deletion plugin/hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
],
"UserPromptSubmit": [
{ "matcher": "*", "hooks": [
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/transcripts\" prompt" } ] }
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/transcripts\" prompt" },
{ "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/bin/intent-trigger\" hook", "timeout": 10 } ] }
],
"SubagentStart": [
{ "matcher": "red-worker|aloop:red-worker|scaffolder|aloop:scaffolder|green-worker|aloop:green-worker|structural-reviewer|aloop:structural-reviewer|final-gate|aloop:final-gate", "hooks": [
Expand Down
11 changes: 9 additions & 2 deletions plugin/hosts/opencode/aloop.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
// Claude Code hook OpenCode equivalent used here
// SessionStart event session.created (root session) → ensure-tools, session-start;
// the context is added to that session's system prompt
// UserPromptSubmit chat.message on a root session → transcripts prompt
// UserPromptSubmit chat.message on a root session → transcripts prompt, intent-trigger
// (its context goes into the next turn's system prompt)
// PreToolUse tool.execute.before → host-adapter, gate-supervisor-scope,
// gate-commit-author, budget pre; a deny THROWS (the tool call fails
// with the reason). Bound workers' commands/edits are REWRITTEN into
Expand Down Expand Up @@ -46,6 +47,7 @@ export const AgenticAgile = async ({ client, directory, worktree }) => {
const env = { ...process.env, AGENTIC_HOST: "opencode", PATH: `${BIN}:${process.env.PATH || ""}` }
const sessions = new Map() // id -> { parentID, root, agent }
const startCtx = new Map() // root id -> SessionStart additionalContext
const promptCtx = new Map() // root id -> intent-trigger context for the next turn
// the run-data dir (bin/_paths.sh): .aladdin/, or the legacy .agentic/ until `aloop layout migrate`
const homeDir = (() => {
const nu = join(project, ".aladdin")
Expand Down Expand Up @@ -147,7 +149,10 @@ export const AgenticAgile = async ({ client, directory, worktree }) => {
if (!active() || s.parentID) return
const text = (output.parts || []).filter((x) => x.type === "text").map((x) => x.text).join("\n")
if (text.startsWith("[aloop")) return // our own injections
run("transcripts", ["prompt"], await payload("UserPromptSubmit", input.sessionID, { prompt: text }))
const pl = await payload("UserPromptSubmit", input.sessionID, { prompt: text })
run("transcripts", ["prompt"], pl)
const trig = run("intent-trigger", ["hook"], pl).json?.hookSpecificOutput?.additionalContext
if (trig) promptCtx.set(s.root, trig) // natural-language auto-trigger (§4.11)
},

"experimental.chat.system.transform": async (input, output) => {
Expand All @@ -156,6 +161,8 @@ export const AgenticAgile = async ({ client, directory, worktree }) => {
const s = await info(input.sessionID)
const ctx = startCtx.get(s.root)
if (ctx && !s.parentID) output.system.push(ctx)
const trig = promptCtx.get(s.root)
if (trig && !s.parentID) { output.system.push(trig); promptCtx.delete(s.root) }
},

"tool.execute.before": async (input, output) => {
Expand Down
3 changes: 3 additions & 0 deletions plugin/skills/aloop/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,9 @@ All of these are on PATH — call them by bare name.
own task slice.
- Lifecycle (wired in hooks.json, all non-blocking — exit 0):
UserPromptSubmit → `transcripts prompt` capture each human message.
UserPromptSubmit → `intent-trigger hook` a plain build/fix request in an aloop project with no
sprint running gets context to run it as `/aladdin "<intent>"` (quiet for questions,
slash commands, a live sprint; off with `ALADDIN_AUTO_TRIGGER=off` / `intent.auto_trigger: false`).
SubagentStart → `transcripts stage-in` copy this task's slice into the worktree
as READ-ONLY `.transcripts/` (a frozen pre-run snapshot).
PostToolUse * → `transcripts record` append the full tool payload to the store.
Expand Down
Loading
Loading
0