Skip to content
6 changes: 4 additions & 2 deletions .agents/skills/afk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,15 @@ The daemon still clears its buffer only on the backend's `empty` success verdict

The daemon wraps `fm-watch.sh`, runs the watcher as a child, presents every durable wake after each actionable watcher close, classifies each presented record in bash, and acknowledges the presented generation only after routing completes.
It self-handles the routine majority without consuming a firstmate turn.
Captain-relevant events, plus a bounded recheck of a declared external wait that is still declared, escalate to firstmate's context as one pre-read, single-line, batched digest.
Events selected by the routing below escalate to firstmate's context as one pre-read, single-line, batched digest.
The digest is byte-bounded so every transport can carry it; when it cuts an event or omits events past its budget, it names a `state/.subsuper-digests/` file that holds every buffered event verbatim, so read that file before acting on a cut event.
The captain-relevant verb set, declared-wait vocabulary, status-span classifier, and presentation-marker contract live in shared `bin/fm-classify-lib.sh`, while each supervisor owns its routing and fleet scan as a consumer of that policy.
While `state/.afk` exists the daemon owns the watcher, so the watcher reverts to one-shot and lets the daemon do the triage - the two never run their triage at the same time.

Classify each wake this way:
Classify each wake this way, applying the steering-inbox exception before status-based routing:

- `stale` whose detail begins `unread firstmate instruction: stuck-busy ` or `steering-inbox busy bookkeeping unwritable: ` -> buffer the explicit inbox escalation for supervision in away and quiet mode, without consuming worker status or entering transient-stale recovery.
[`bin/fm-task-inbox-lib.sh`](../../../bin/fm-task-inbox-lib.sh) owns the busy budget and bookkeeping contract; `tests/fm-daemon.test.sh` covers this consumer boundary.
- `signal` whose newly classified status span contains captain-relevant events -> escalate every event in source order.
A nonterminal progress verb remains nonterminal even when its prose contains a legacy free-text token such as `PR ready`, `checks green`, `ready in branch`, or `merged`; only a bare legacy line with such a token escalates.
Other signals with no captain-relevant event in the span -> self-handle.
Expand Down
62 changes: 59 additions & 3 deletions .pi/extensions/fm-branch-supervision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,10 +653,16 @@ export default function (pi: ExtensionAPI) {
// queued for the captain's next prompt. The durable truth is the store's
// processed marker; this only paces re-presentation and resets with the
// session generation.
type ProcessingState = { sequences: string; through: number; triggered: number; pending: boolean; nextTurnQueued: boolean };
type ProcessingState = { sequences: string; through: number; triggered: number; pending: boolean; nextTurnQueued: boolean; visibleFinals: Set<string> };
let processing: ProcessingState | null = null;
let queuedProcessingContent: string | null = null;
let processingOpenedThisRun = false;
// Compare only replies to the same consumed sequence set. A retry can be
// the first real handling, so only an empty or exact-repeat final is hidden.
// Buffer retry streaming until message_end can make that decision; tool
// messages always keep their prose, and a user message ends this scope.
let activeProcessing: { request: ProcessingState; retry: boolean } | null = null;
let userMessageThisTurn = false;
let processedInitializedGeneration = -1;
// One revision for BOTH selections: a model or effort change invalidates an
// in-flight branch build exactly the same way.
Expand Down Expand Up @@ -1095,7 +1101,7 @@ export default function (pi: ExtensionAPI) {
}
if (processing?.pending) return true;
if (!processing || processing.sequences !== sequences) {
processing = { sequences, through, triggered: 0, pending: false, nextTurnQueued: false };
processing = { sequences, through, triggered: 0, pending: false, nextTurnQueued: false, visibleFinals: new Set() };
}
// A presentation already sent is consumed by the run it joins or opens;
// until that run settles, sending a widened or identical copy would hand
Expand Down Expand Up @@ -1677,7 +1683,6 @@ ${context.command}
// duplicate suppression. Operational extension injections are not dialog.
const prompt = event.prompt;
processingOpenedThisRun = queuedProcessingContent !== null && prompt === queuedProcessingContent;
if (processingOpenedThisRun) queuedProcessingContent = null;
const trimmed = prompt.trim();
if (!trimmed || isOperationalUserText(trimmed)) return;
const file = currentMainSession.getSessionFile() ?? "";
Expand All @@ -1692,6 +1697,48 @@ ${context.command}
// so a fresh copy may be queued again once this run settles unacknowledged.
if (processing) processing.nextTurnQueued = false;
});
pi.on?.("turn_start", () => {
userMessageThisTurn = false;
});
pi.on?.("message_start", (event) => {
if (event.message.role === "user") {
userMessageThisTurn = true;
activeProcessing = null;
} else if (
event.message.role === "custom" &&
isProcessingCustomMessage(event.message) &&
queuedProcessingContent !== null &&
event.message.content === queuedProcessingContent
) {
// message_start covers both an idle custom prompt and a follow-up
// consumed inside an existing run; neither needs before_agent_start.
activeProcessing = !userMessageThisTurn && processing ? { request: processing, retry: processing.triggered > 1 } : null;
queuedProcessingContent = null;
}
});
pi.registerMarkdownTransformer?.((markdown, context) =>
activeProcessing?.retry && context.isStreaming && context.messageType !== "user" ? "" : markdown,
);
pi.on?.("message_end", (event) => {
if (!activeProcessing || event.message.role !== "assistant") return;
// message_end runs before tool execution. Keep the whole message when
// it carries a call, including prose alongside fm_branch_processed.
if (event.message.content.some((part) => part.type === "toolCall")) return;
const text = event.message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n").trim();
const { request, retry } = activeProcessing;
if (!retry || (text && !request.visibleFinals.has(text))) {
if (text) request.visibleFinals.add(text);
return;
}
// Pi applies the replacement before persistence and transcript rendering.
// Preserve the message envelope, including provider usage accounting.
return {
message: {
...event.message,
content: [],
},
};
});
pi.on?.("context", (event, ctx) => {
if (!afkPostureRecordPresent(state)) return;
const messages = event.messages ?? [];
Expand All @@ -1714,6 +1761,7 @@ ${context.command}
mainStreaming = false;
queuedProcessingContent = null;
processingOpenedThisRun = false;
activeProcessing = null;
if (processing) processing.pending = false;
const settledGeneration = generation;
await enqueueDelivery(async () => {
Expand Down Expand Up @@ -1773,6 +1821,8 @@ ${context.command}
consecutiveProviderErrors = 0;
providerRecovery = null;
generation += 1;
activeProcessing = null;
userMessageThisTurn = false;
mirrorCollection.collectAnchor = null;
mirrorCollection.pendingCursor = null;
mirrorCollection.stagedCaptain = null;
Expand Down Expand Up @@ -1821,6 +1871,9 @@ ${context.command}
shuttingDown = true;
generation += 1;
processing = null;
queuedProcessingContent = null;
activeProcessing = null;
userMessageThisTurn = false;
pendingMirror.length = 0;
currentMainSession = null;
mirrorCollection.collectAnchor = null;
Expand Down Expand Up @@ -2339,6 +2392,9 @@ ${context.command}
};
}
const remaining = await readUnprocessedOutcomes(acknowledgedGeneration);
if (acknowledgedGeneration === generation && activeProcessing && through >= activeProcessing.request.through) {
activeProcessing = null;
}
if (remaining !== null && remaining.length === 0) processing = null;
const open = remaining === null
? "the remaining outcomes could not be read"
Expand Down
4 changes: 3 additions & 1 deletion bin/fm-brief.sh
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,9 @@ If the top-level path is the primary checkout or not the worktree you were launc

# Rules
$RULE1
2. Stay inside this worktree; modify nothing outside it.
2. Keep project edits inside this worktree; keep proof and scratch output outside it, under \`$DATA/$ID/\` or a temporary directory.
Outside the worktree, write only that task material and the status and steering-inbox records authorized below.
Leave the worktree clean before reporting done.
3. Use gh-axi for GitHub operations and chrome-devtools-axi for browser operations.
4. Report status by appending one line:
\`$STATUS_APPEND\`
Expand Down
69 changes: 58 additions & 11 deletions bin/fm-procevent-quota.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
# registered through `bin/fm-procevent.sh register`.
# poll The blocking child the generic runner executes; never run this
# directly in a conversational turn. It polls `quota-axi --json`
# until quota drops below the threshold or an error stops the watch.
# until quota drops below the threshold, invalid quota data stops
# the watch, or three consecutive transient command failures stop
# it. Missing or incompatible tools stop it immediately, and a
# successful read resets the command-failure streak.
# classify Print the captured outcome class: low, exhausted, error, or unknown.
# terminal Every quota poll is terminal because the source fires at most once.
# source-id Print the canonical source id.
Expand Down Expand Up @@ -52,6 +55,9 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

DEFAULT_INTERVAL=60
DEFAULT_THRESHOLD=10
# Consecutive transient quota-axi read failures before poll goes terminal.
# Missing and incompatible tools bypass this budget. No config knob on purpose.
MAX_CONSECUTIVE_READ_FAILURES=3

SOURCE_ID_BASE=quota

Expand Down Expand Up @@ -98,16 +104,43 @@ valid_percent() {
}

# quota_json [timeout]
# Run `quota-axi --json` bounded by the given timeout. A missing or incompatible
# quota-axi is an error condition, not a signal to fire.
# Run `quota-axi --json` bounded by the given timeout.
# Exit status: 0 prints JSON; 1 timed out; 2 missing; 3 incompatible; 4 other failure.
# A missing or incompatible quota-axi is an error condition, not a signal to fire.
# Callers tolerate a bounded streak of 1/4 before going terminal; 2/3 stay distinct.
# Each path probes --version once, validates that captured text through
# fm_quota_axi_version_compatible, then probes --json once.
# A slow or failing probe stays 1/4; "incompatible" is reserved for an actual
# unsupported or unparseable version string.
quota_json() {
local timeout=${1:-} output
local timeout=${1:-} output rc=0
if ! command -v quota-axi >/dev/null 2>&1; then
return 2
fi
if [ -n "$timeout" ]; then
fm_quota_axi_compatible "$timeout" >/dev/null 2>&1 || return 2
output=$(fm_run_timed "$timeout" quota-axi --json 2>/dev/null </dev/null) || return 2
rc=0
output=$(fm_run_timed "$timeout" quota-axi --version 2>/dev/null </dev/null) || rc=$?
if [ "$rc" -ne 0 ]; then
if fm_timed_out "$rc"; then
return 1
fi
return 4
fi
fm_quota_axi_version_compatible "$output" || return 3
rc=0
output=$(fm_run_timed "$timeout" quota-axi --json 2>/dev/null </dev/null) || rc=$?
if [ "$rc" -ne 0 ]; then
if fm_timed_out "$rc"; then
return 1
fi
return 4
fi
else
fm_quota_axi_compatible >/dev/null 2>&1 || return 2
output=$(quota-axi --json 2>/dev/null </dev/null) || return 2
rc=0
output=$(quota-axi --version 2>/dev/null </dev/null) || rc=$?
[ "$rc" -eq 0 ] || return 4
fm_quota_axi_version_compatible "$output" || return 3
output=$(quota-axi --json 2>/dev/null </dev/null) || return 4
fi
printf '%s\n' "$output"
}
Expand Down Expand Up @@ -209,16 +242,30 @@ cmd_poll() {
valid_percent "$threshold" || die "--threshold needs a percent 0-100"
[ -z "$timeout" ] || positive_int "$timeout" || die "--timeout needs a positive integer"
resolve_provider "$PROVIDER"
local json detail status polls=0
local json detail status polls=0 consecutive_failures=0 read_rc detail_msg
while :; do
polls=$((polls + 1))
if ! json=$(quota_json "${timeout:-}"); then
json=$(quota_json "${timeout:-}") && read_rc=0 || read_rc=$?
if [ "$read_rc" -ne 0 ]; then
consecutive_failures=$((consecutive_failures + 1))
if [ "$read_rc" -ne 2 ] && [ "$read_rc" -ne 3 ] && \
[ "$consecutive_failures" -lt "$MAX_CONSECUTIVE_READ_FAILURES" ]; then
sleep "$interval"
continue
fi
case "$read_rc" in
1) detail_msg="${consecutive_failures} consecutive read failures; last quota-axi read timed out" ;;
2) detail_msg="quota-axi is missing" ;;
3) detail_msg="quota-axi is incompatible" ;;
*) detail_msg="${consecutive_failures} consecutive read failures; last quota-axi read failed" ;;
esac
printf 'quota: %s\n' "$CANONICAL_SOURCE_ID"
printf 'status: error\n'
printf 'detail: quota-axi --json failed or quota-axi is missing/incompatible\n'
printf 'detail: %s\n' "$detail_msg"
printf 'condition_polls: %s\n' "$polls"
exit 0
fi
consecutive_failures=0
status=$(condition_status "$json" "$PROVIDER" "$threshold")
case "$status" in
healthy) sleep "$interval"; continue ;;
Expand Down
4 changes: 4 additions & 0 deletions bin/fm-promote.sh
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ This task is now kind=ship with mode=$MODE$PROMOTE_FORGE_WORDS.
This section supersedes every earlier brief instruction about delivery mode.
These current ship instructions supersede the scout delivery rules and report-based Definition of done.
Any earlier "Never push" or scout-only delivery language in this file is superseded.
This replaces the scout rule limiting outside-worktree writes to the report and status file.
Keep project edits inside this worktree; keep proof and scratch output outside it, under \`$DATA/$ID/\` or a temporary directory.
Outside the worktree, write only that task material and the status and steering-inbox records authorized below.
Leave the worktree clean before reporting done.
The mode-specific Definition of done below is the current delivery contract.

# Current ship safety rule
Expand Down
33 changes: 21 additions & 12 deletions bin/fm-quota-axi-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,13 @@ FM_QUOTA_ROW_JQ='
end;
'

fm_quota_axi_compatible() {
local timeout=${1:-} output parts major minor patch extra
# fm_quota_axi_version_compatible <version-output>
# True when the printed `quota-axi --version` text meets FM_QUOTA_AXI_MIN.
# Callers that already captured a bounded --version pass that text here so they
# do not launch a second, unbounded version probe.
fm_quota_axi_version_compatible() {
local output=${1-} parts major minor patch extra
local min_major min_minor min_patch min_extra
command -v quota-axi >/dev/null 2>&1 || return 1
if [ -n "$timeout" ]; then
case "$timeout" in
''|*[!0-9]*|0) return 1 ;;
esac
[ "$(type -t fm_run_timed)" = function ] || return 1
output=$(fm_run_timed "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1
else
output=$(quota-axi --version 2>/dev/null </dev/null) || return 1
fi
parts=$(printf '%s\n' "$output" |
sed -n 's/.*\([0-9][0-9]*\)\.\([0-9][0-9]*\)\.\([0-9][0-9]*\).*/\1 \2 \3/p' |
head -1)
Expand All @@ -74,6 +68,21 @@ fm_quota_axi_compatible() {
[ "$patch" -ge "$min_patch" ]
}

fm_quota_axi_compatible() {
local timeout=${1:-} output
command -v quota-axi >/dev/null 2>&1 || return 1
if [ -n "$timeout" ]; then
case "$timeout" in
''|*[!0-9]*|0) return 1 ;;
esac
[ "$(type -t fm_run_timed)" = function ] || return 1
output=$(fm_run_timed "$timeout" quota-axi --version 2>/dev/null </dev/null) || return 1
else
output=$(quota-axi --version 2>/dev/null </dev/null) || return 1
fi
fm_quota_axi_version_compatible "$output"
}

fm_quota_json_valid() {
jq -se --arg provider_re "$FM_QUOTA_PROVIDER_ID_RE" '
length == 1 and
Expand Down
Loading
Loading