8000 The mission loop hands out a goal's commands only when the gate takes them (M9.53) by adeelahmad · Pull Request #111 · adeelahmad/aloop · GitHub
[go: up one dir, main page]

Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ tag in lockstep. Tag releases as `vMAJOR.MINOR.PATCH`.
- DeepSeek Harness (dsh) host (ROADMAP §4.9): `aloop install dsh` (alias `deepseek`; `--project P` or `--global`) renders a `dsh --profile aloop` profile in `$DSH_HOME` and installs the skills. Its native plugin (`plugin/hosts/dsh/aloop.mjs`) runs the same gates as Claude Code on dsh's own events: a deny before a tool runs, a block after an invalid artifact write, and a stop gate that steers a role back up to 3 times before the supervisor is told it is BLOCKED (or STUCK, released unverified). Roles are agent presets on two subagent tools, one per model route (`deepseek-v4-pro` for planning, `deepseek-flash` for work by default; `MODEL_PLANNING_DSH` / `MODEL_WORKER_DSH` override). A writer's `aloop bind` moves its working directory into its worktree, and `host-adapter` confines its commands, edits and `working_directory` there. Usage is recorded Claude-shaped per role, so budgets, the ledger and `run-summary` work unchanged. `make e2e-live HOST=dsh` drives it live and exits 2 with the fix when no `DEEPSEEK_API_KEY` is set. Reason: DeepSeek's own harness is the cheapest route to a strong model. The profile records the project it serves (or `--global`), so another project's `aloop-init` never re-renders it, and gates get the worker shell's environment (no API key). The mission layer's six roles run on dsh too: presets on their route group's subagent tool, `gate-mission-scope` on every write and command, and `gate-mission-artifact` at each role's start and stop; the mission skill installs as `aloop-mission`. A second review (3 lenses; 11 confirmed findings, each fixed with a test) closed: a working directory that vanished (dsh's tools move it back to the main tree, so the plugin judges `ensure()`'s directory and `host-adapter` refuses to run from a missing one), gate switches from the project's `.env` (dsh loads it; `SKIP_HOOKS`, `GATE_RUN_TESTS`, `REPO_DIR`, `AGENTIC_*` from it never reach a gate), an interrupted Stop that dropped the sprint-close notice, and the shared profile: its owner is the main tree, an older unrecorded profile is claimed only by the project that installed it, and a project it does not serve is told so by `install status`, `aloop-init` and the session itself. Tests: `dsh-plugin.test.mjs` (46 checks, emulated), `dsh-runtime.test.mjs` (50 checks on the real pinned dsh 0.2.1-rc.2 with a scripted model; the CI hosts job installs it) and `dsh-install.test.py` (10).
- dsh follow-ups (ROADMAP §4.9). One `aloop install dsh` serves every project: the plugin routes each role's requests through dsh's `agent/request` to its own project's model (`aloop models resolve <role>`), so gate-dispatch no longer refuses another project's roles until a re-install, and a role whose model cannot be resolved is refused (fail closed). Roles are no longer offered `send_message`, so dsh adds no "send your result" guidance and no call is spent on a deny. dsh compaction is recorded as `context.compacted` and the handoff comes back after it; the capability matrix marks dsh compaction and live messaging as advertised, proven on the real runtime. Two mission roles (one per subagent tool) run through both of their gates on the real runtime. `run-summary --host dsh` reports a dollar cost: each logged call is priced from a dated, sourced DeepSeek table (`plugin/bin/_prices.py`; cache hit, cache miss, output, peak or off-peak), with `cost_by_model` in `summary.json`; an unpriced model id keeps its tokens and leaves the cost unmeasured, never guessed.
- Brand kit and repo front page: `docs/brand/` holds the mark (an **a** whose bowl is a red-to-green loop passing a gold gate, with a 16 px cut), lockups, light and dark README banners, favicons, app icons, the site's OG card and the GitHub social preview, all generated by `docs/brand/gen_brand.py` from one palette with WCAG checks (rules in `docs/brand/README.md`). The README leads with the mission layer, the agent principles and the door rule, the supported hosts and the measured demo runs; the site carries the favicon and Open Graph/Twitter card metadata; `SECURITY.md` and bug/feature issue forms are new.
- The mission loop hands out a goal's commands only when the gate takes them (ROADMAP §4.12, M9.53): before `PLAN_INTENTS`, `mission tick` runs the same `goal check` that `goal intents` runs, before `SPRINT` all of what `goal next` checks (the next intent's `intent check` included), and before a met goal's close it asks `goal close`'s own rule. The loop's check of the next intent runs in process, so `tick --peek` and `due` still record nothing (no `intent.normalized`); a goal with no intent to hand out, or a next intent whose check crashes, is `BLOCKED` with that reason. A refusal is `BLOCKED` with the gate's reason (for example "the mission changed since the goal was derived"), where the loop used to hand out the same command 3 times and then block naming no cause. `goal close` now checks the ledger and the goal's content before it trusts a `goal.met` record, records `goal.met` before it sets the status, and repairs a bare `met` status; `goal next` refuses a goal with a `goal.met` record. Test: `mission-tick` check 34 and `mission-goal` check 8; every goal in the tick suite that reaches a goal command is now a real one, derived through `aloop mission` on the project's clock.
- A goal its sprints did not meet is retired, and the loop moves on (ROADMAP §4.12, M9.50): `aloop mission goal retire GOAL --eval EVAL` records `goal.retired` in the committed ledger once every intent of the goal was sprinted and its sprints closed, and the newest eval of it, made after those closes from a state measured after them, still lists gaps. A retired goal takes no more intents, sprints or close. `mission evaluate` covers the whole mission again, and the retired goal's own open gaps are held from the next goal until a new baseline version or the human's next approval of the mission, so the loop does not sprint the same gaps again; a regression is never held. When every gap is held, `mission tick` says `BLOCKED` and the human's `aloop mission tick --force --by NAME` lifts it. `mission tick` hands this out as `RETIRE_GOAL` (role `mission-planner`) where it used to stop at `BLOCKED` for the human. It asks the command's own rule first: a stale state or eval becomes `ASSESS` or `EVALUATE`, and any other refusal is `BLOCKED` with its reason. 3 retired goals in a row still go to the human. The planner's stop takes a retired goal as its finding. Tests: `mission-goal` check 13, `mission-tick` checks 28 and 33, `mission-roles`.
- A mission's gap lists count only as `mission evaluate` computed them (ROADMAP §4.12, M9.27): `evaluate` records `eval.completed` in the committed ledger, and `mission tick`, `evaluate check` and `goal` take the newest eval whose content that record covers, with a matching seal, in the ledger's order. An eval file written by hand, or edited (or re-stamped) after it was recorded, no longer sends the loop to `RESEARCH` as "goal met" or makes `goal derive` refuse what tick handed out, a clock that is behind no longer reorders them, and a fresh checkout derives and closes goals from the evals it committed. An eval or state named as journey or baseline evidence needs its record too. Tests: `mission-tick` check 32, `mission-journeys`.
- A mission's stop at a spend cap holds on a fresh checkout (ROADMAP 8000 §4.12, M9.24): the first `STOP_BUDGET` tick records a `mission.budget_exhausted` ledger row for the mission's content (once per content), and `mission tick` stops on it whatever the checkout's token ledger says, until a raised cap changes the content.
Expand Down
3 changes: 2 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -503,9 +503,10 @@ Each task flips only its own row here and in TRACKING.md.
| M9.50 | A goal whose intents were all sprinted and whose eval still lists gaps gets a next step (re-plan the open gaps as intents, or a recorded retirement so a new goal can be derived). Reason: found wiring M9.28, `goal close` refuses an unmet goal and `goal next` has no intent left, so the loop can only hand it to the human | M9.11 | `plugin/mission/goal.py`, `mission-goal.test.py`, then `tick.py` | yes | done: `mission-goal.test.py` check 13, `mission-tick.test.py` checks 28 and 33, `mission-roles.test.py` (the planner's stop). The call: retirement, not a re-plan (a re-plan of the same gaps repeats work the sprints already tried; the 3-failed-goals breaker bounds a run of retirements). `mission goal retire GOAL --eval EVAL` records `goal.retired` (role `mission-planner`, in `LEDGER_EVENTS`, gate-owned) when the goal is the content `derive` recorded, active and not met; every gap has an intent and every intent of the goal (a second one for a gap included, which `goal next` hands out too) was sprinted and its sprints closed; and EVAL is the newest eval, narrowed to the goal and not met, made after the last close from the newest state report, itself measured after that close. The record is written before the status (a bare `retired` status is repaired, never trusted). A retired goal takes no intents, sprints or close; `evaluate` covers the whole mission again (`active_goal` is only ever the newest goal); and the retired goal's own open gaps (the record's `open`) are held from `derive` on that baseline until a new version or the human's next approval of the mission, while a regression is never held; every gap held is `BLOCKED` with grant `GOALS` on the newest retired goal, which `tick --force --by` and the human's `lift` line clear (the lift is dated by the goal id the block names, so an edited goal file still lifts). `goal next` hands every goal intent to `intent check`, so a retry intent whose file is gone or broken is named instead of skipped. `tick` hands out `RETIRE_GOAL` from the gate's own rule (`goal.retire_problems`): a refusal the loop fixes itself is its `ASSESS` or `EVALUATE`, any other is `BLOCKED` with the gate's reason; a retirement, by its record, counts toward the 3-failed-goals block. At the planner's stop the goal check's `retired` state is the planner's finding (`roles.json` verdicts) |
| M9.51 | Lifting a block (`mission tick --force --by`) is the human's own words: it asks (exit 3) for their typed `lift <mission> <code>` line, the code bound to this occurrence of the block (its key, the lifts of it so far, and the handout that tripped it or the newest failed goal), and records a `mission.lifted` ledger row (`APPROVAL_EVENTS`, `GRANT_VERBS`); a repeat restarts only at that row's own run-log event (first copy), and the 3-failed-goals count only up to the goals the human lifted; `/aloop:mission` counts a lift among the typed grants. Reason: found after M9.17 merged, `--by` alone still lifted a block and the lift was run data, so a fresh checkout blocked again (M9.24's first part) | M9.8, M9.12, M9.17 | `plugin/mission/tick.py`, `plugin/bin/_mission.py`, `plugin/bin/_events.py`, `plugin/skills/mission/`, `scripts/test/mission-{tick,skill}.test.py` | yes | done: `mission-tick.test.py` check 30 (`--by` alone, a run-log-only or replayed lift, a negated line, another code, an old line and a line typed for an earlier block lift nothing; a far `--now` lifts no later goals; a fresh checkout keeps a lift), `mission-skill.test.py` check 8 |
| M9.52 | `tick` reads a gap list only when it still recomputes from its inputs, as `evaluate check` does (an intact eval that a changed `compute`, an edited journey or goal no longer reproduces asks for `EVALUATE`). Reason: found in M9.27's review, `tick` reads the record, shape and seal but not the recomputation (the tick test's synthetic evals cannot recompute), so after an upgrade that changes `compute` `goal derive` refuses while `tick` hands out `DERIVE_GOAL` until it blocks | M9.27 | `plugin/mission/tick.py`, `mission-tick.test.py` (real evals in its synthetic sections) | yes | open |
| M9.53 | `tick` hands out a goal's commands (`PLAN_INTENTS`, `SPRINT`, its close) only when `goal check` passes, else names the goal's problem. Reason: found in M9.27's review, a goal whose source eval changed or is gone (or one derived before evals were ledger records) gets `PLAN_INTENTS` 3 times while `goal intents` refuses, then `BLOCKED` with no cause named (M9.43 is one case of it) | M9.11, M9.28 | `plugin/mission/tick.py`, `mission-tick.test.py` | yes | open |
| M9.53 | `tick` hands out a goal's commands (`PLAN_INTENTS`, `SPRINT`, its close) only when `goal check` passes, else names the goal's problem. Reason: found in M9.27's review, a goal whose source eval changed or is gone (or one derived before evals were ledger records) gets `PLAN_INTENTS` 3 times while `goal intents` refuses, then `BLOCKED` with no cause named (M9.43 is one case of it) | M9.11, M9.28 | `plugin/mission/tick.py`, `mission-tick.test.py` | yes | done: `mission-tick.test.py` check 34; every goal in that suite that reaches a goal command is now real (`Project.goal` runs `assess`, `evaluate` and `goal derive` on the project's clock); the synthetic ones left are goals tick stops at earlier (hard lines, retirement). Before `PLAN_INTENTS`, `tick` runs `goal.check`, the check `goal intents` runs; before `SPRINT` it runs `goal.next_intent`, all of what `goal next` checks (a `goal.met` record, the status, a next intent at all, and that intent's `intent check`, run in process so a decision emits no `intent.normalized`; a crash of the check is its refusal); before a met goal's close it runs `goal.close_problems`, the rule `goal close` now runs. A refusal is `BLOCKED` naming the gate's reason, keyed on it. `goal close` checks the ledger, then the goal's content, before it trusts a `goal.met` record, records `goal.met` before the status, and repairs a bare `met` status. The call: `BLOCKED`, not `DERIVE_GOAL`, for a goal the mission outgrew, since a second goal in flight is M9.55's to rule out and M9.43 decides which mission changes void a goal |
| M9.54 | What the scope gate's Bash reader still cannot follow, each a known residual: a `cd` to a computed dir (writes relative to it go unjudged), a `cd` that fails before a `;`, `cd`s that accumulate over loop iterations, a case `;&` fall-through, `eval` with a redirect, a function body's writes judged where it is defined, and interpreter code (`python3 -c`). Reason: the M9.9 round-4 review; the reader follows each state a command may leave the shell in (`walk`/`fork`), but these need running it, so M9.41's tree diff at a role's stop is the backstop | M9.9, M9.41 | `plugin/bin/gate-mission-scope`, `plugin/bin/_shellread.py`, `mission-scope-bash.test.py` | after its needs | open |
| M9.55 | One goal in flight: `goal derive` refuses while a goal is active, and `tick` and `evaluate.active_goal` pick the newest goal by one clock. Reason: found in M9.50's review, a retried `DERIVE_GOAL` can leave two goals, and tick orders goals by their id time while `active_goal` uses the newest `goal.derived`/`goal.approved` row, so a spoon-fed re-approval of an older goal can split them | M9.11, M9.28 | `plugin/mission/{goal,evaluate,tick}.py`, their tests | yes | open |
| M9.70 | `ids freeze` links a sprint's `plan.created` to 10000 the intent the supervisor handed off, not to the newest `intent.normalized` anywhere. Reason: found in M9.53's review, a human's own intake checked between a goal's `goal next` and the freeze links the human's sprint to the goal's intent (or the goal's sprint to the human's), so `tick` counts a goal intent as sprinted though no sprint worked on its gap; the handed-off intent is the newest intent file, which `gate-intake` re-checks | M1, M9.8 | `plugin/bin/ids`, `ids-integrity.test.py` | yes | open |

**Parallel tracks** (one thread each, from M9.0 on master): (1) Research + Judges, M9.1 + M9.5;
(2) Baseline + Journeys → Assess/Evaluate, M9.2 + M9.3 → M9.10; (3) Ethics + Principles → Goals,
Expand Down
2 changes: 1 addition & 1 deletion docs/TRACKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Status of every requirement in [ROADMAP.md](ROADMAP.md), in the four states §8
| M9.50 | A goal sprinted with gaps still open gets a next step | IMPLEMENTED_TESTED | `scripts/test/mission-goal.test.py` |
| M9.51 | Lifting a mission block is the human's typed line, kept in the ledger | IMPLEMENTED_TESTED | `scripts/test/mission-tick.test.py` |
| M9.52 | The mission loop reads a gap list only when it still recomputes | NOT_IMPLEMENTED | §4.12 |
| M9.53 | The mission loop hands out a goal's commands only when `goal check` passes | NOT_IMPLEMENTED | §4.12 |
| M9.53 | The mission loop hands out a goal's commands only when `goal check` passes | IMPLEMENTED_TESTED | `scripts/test/mission-tick.test.py` |
| M9.54 | Scope gate reader residuals (computed cd, failed cd, loop cd accumulation) | NOT_IMPLEMENTED | §4.12 |
| M9.55 | One goal in flight, picked by one clock | NOT_IMPLEMENTED | §4.12 |
| M10.1 | Workers run with the network off and writes confined to their worktree, via the host sandbox | NOT_IMPLEMENTED | §4.13 |
Expand Down
21 changes: 15 additions & 6 deletions plugin/bin/intent
Original file line number Diff line number Diff line change
Expand Up @@ -165,16 +165,18 @@ def load(path):
return None, str(e)


def cmd_check(cwd, path, mode="reviewed"):
def verdict(cwd, path, mode="reviewed"):
"""`intent check` without its event: (exit 0 · 2 · 3, the contract), the reasons on stderr. `mission
tick` and `due` ask it in process, so a decision that records nothing emits no `intent.normalized`."""
doc, err = load(path)
if doc is None:
print("BLOCK[intent]: cannot read %s: %s" % (path, err), file=sys.stderr)
return 2
return 2, None
bad = problems(doc, path)
for b in bad:
print("BLOCK[intent]: %s" % b, file=sys.stderr)
if bad:
return 2
return 2, doc
if doc.get("source") == "mission":
prov = doc["provenance"]
rec = [e for e in _events.read(cwd) if e.get("event") == "intent.received"
Expand All @@ -183,17 +185,24 @@ def cmd_check(cwd, path, mode="reviewed"):
if d.get("source") != "mission" or (d.get("goal_id"), d.get("gap_id")) != (prov["goal_id"], prov.get("gap_id")):
print("BLOCK[intent]: provenance (goal %s, gap %s) is not what its intent.received recorded (goal %s, gap %s)"
% (prov["goal_id"], prov.get("gap_id"), d.get("goal_id"), d.get("gap_id")), file=sys.stderr)
return 2
return 2, doc
rc = mission_gate(cwd, prov["goal_id"], prov.get("gap_id"), mode)
if rc:
return rc
return rc, doc
asks = unanswered(doc)
if asks:
for q in asks:
print("ASK[intent]: %s" % one_line(q), file=sys.stderr)
print("intent: %d question(s) need the human (irreversible, security-sensitive or intent-changing) — "
"%s mode does not proceed past them." % (len(asks), mode), file=sys.stderr)
return 3
return 3, doc
return 0, doc


def cmd_check(cwd, path, mode="reviewed"):
rc, doc = verdict(cwd, path, mode)
if rc:
return rc
_events.emit(cwd, "intent.normalized", role="intake", refs={"intent": path}, intent_id=doc["intent_id"], mode=mode)
print("[intent] %s ok (%s mode)" % (doc["intent_id"], mode), file=sys.stderr)
return 0
Expand Down
Loading
Loading
0