feat(moments): actions map onto moments, with in-session corrections (milestone 458 step 2, #4920)
CI & Build / Plugin hooks (push) Successful in 18s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / integration (push) Successful in 1m23s
CI & Build / Python tests (push) Successful in 2m0s
CI & Build / Build & push image (push) Successful in 36s

moment_actions.resolve(tool, input) names every moment a call reaches and
the action that reached it. One call can reach several: kubectl apply is
a run, a deliver and a reach outside the workspace. Command tools match by
how each segment of the line starts, with a word boundary; other tools by
field=value arguments. The MCP server prefix and case are ignored.

56 shipped defaults cover the harness tools, Scribe tools and common
command shapes. moment_mappings (migration 0116) holds what an install
adds and the defaults it switches off. A removal is a stored row, so an
upgrade does not switch the default back on.

Per the operator ruling, corrections happen in the session: map_action
and unmap_action (write tools) return now_reaches so the fix can be
confirmed in the same reply. list_moments now shows each moment's
actions on this install. REST mirrors both doors, recorded as human.
Backup v21 carries the mappings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 10:58:00 -04:00
co-authored by Claude Opus 5.5
parent 2ff7f2f34f
commit cf3de5bae1
13 changed files with 1039 additions and 29 deletions
+39 -5
View File
@@ -17,6 +17,7 @@ import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.services import moment_actions as moment_actions_svc
from scribe.services import moments as moments_svc
from scribe.services.retrieval_tuning import set_dial, current_settings, tuning_history
@@ -110,10 +111,43 @@ async def tuning_history_route():
@retrieval_bp.route("/moments", methods=["GET"])
@login_required
async def moments_route():
"""The moments of work that rules mount on (milestone 458).
"""The moments of work that rules mount on (milestone 458), with the
actions that reach each one on this install.
The same catalog `list_moments` returns, from the same service: the
Settings view of mounts and mappings reads its vocabulary here, so the two
doors cannot name different moments.
The same payload `list_moments` returns, from the same services, so the
session and the Settings view cannot name different moments.
"""
return jsonify(moments_svc.catalog())
out = moments_svc.catalog()
out.update(await moment_actions_svc.actions_by_moment(get_current_user_id()))
return jsonify(out)
async def _mapping_change(change):
"""map/unmap from the browser: the MCP tools' service, recorded as human."""
data = await request.get_json()
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
if not data.get("tool") or not data.get("moment"):
return jsonify({"error": "tool and moment are required"}), 400
try:
result = await change(
get_current_user_id(), str(data["tool"]), str(data.get("match") or ""),
str(data["moment"]), reason=str(data.get("reason") or ""), actor="human",
)
except ValueError as e:
# Unknown moment, a match the tool cannot take, nothing to remove —
# the service says what would work, so pass it through.
return jsonify({"error": str(e)}), 400
return jsonify(result)
@retrieval_bp.route("/moments/mappings", methods=["POST"])
@login_required
async def map_action_route():
return await _mapping_change(moment_actions_svc.map_action)
@retrieval_bp.route("/moments/mappings", methods=["DELETE"])
@login_required
async def unmap_action_route():
return await _mapping_change(moment_actions_svc.unmap_action)