mandrel 1.93.0 → 1.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/agents/acceptance-critic.md +129 -0
- package/.agents/agents/retro.md +42 -0
- package/.agents/agents/story-worker.md +162 -0
- package/.agents/docs/configuration.md +7 -1
- package/.agents/docs/execution-reference.md +27 -2
- package/.agents/instructions.md +43 -33
- package/.agents/personas/engineer.md +26 -112
- package/.agents/personas/security-engineer.md +1 -2
- package/.agents/rules/git-conventions-reference.md +225 -0
- package/.agents/rules/git-conventions.md +25 -200
- package/.agents/rules/security-baseline.md +5 -0
- package/.agents/rules/testing-standards.md +106 -13
- package/.agents/schemas/agentrc.schema.json +31 -1
- package/.agents/schemas/lifecycle/slice.end.schema.json +21 -0
- package/.agents/schemas/lifecycle/slice.heartbeat.schema.json +20 -0
- package/.agents/schemas/lifecycle/slice.start.schema.json +17 -0
- package/.agents/scripts/acceptance-eval.js +62 -18
- package/.agents/scripts/agents-bootstrap-github.js +1 -1
- package/.agents/scripts/bookkeeping-reconcile.js +117 -0
- package/.agents/scripts/check-context-budget.js +62 -5
- package/.agents/scripts/diagnose-friction.js +0 -6
- package/.agents/scripts/epic-deliver-prepare.js +272 -10
- package/.agents/scripts/lib/bootstrap/project-bootstrap.js +56 -18
- package/.agents/scripts/lib/close-validation/gates.js +159 -21
- package/.agents/scripts/lib/config/acceptance-eval.js +52 -5
- package/.agents/scripts/lib/config/delivery-routing.js +87 -0
- package/.agents/scripts/lib/config/explain.js +2 -0
- package/.agents/scripts/lib/config-resolver.js +1 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +37 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +9 -0
- package/.agents/scripts/lib/doc-tiers.js +37 -2
- package/.agents/scripts/lib/observability/active-story-env.js +111 -2
- package/.agents/scripts/lib/observability/hook-heartbeat.js +219 -0
- package/.agents/scripts/lib/observability/tool-trace-hook.js +15 -4
- package/.agents/scripts/lib/orchestration/acceptance-clusters.js +111 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +32 -4
- package/.agents/scripts/lib/orchestration/bookkeeping-outbox.js +270 -0
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +141 -0
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +3 -124
- package/.agents/scripts/lib/orchestration/deliver-route.js +173 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-run-state-store.js +233 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-slice-lifecycle.js +270 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +83 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/checkpoint-pointer-writer.js +6 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +1 -0
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +1 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +1 -1
- package/.agents/scripts/lib/provider-factory.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/post-structured-comment.js +38 -0
- package/.agents/scripts/slice-phase.js +361 -0
- package/.agents/scripts/sync-claude-agents.js +165 -0
- package/.agents/scripts/update-ticket-state.js +31 -0
- package/.agents/scripts/wave-tick.js +138 -9
- package/.agents/skills/core/api-and-interface-design/SKILL.md +5 -3
- package/.agents/skills/core/code-review-and-quality/SKILL.md +63 -7
- package/.agents/skills/core/debugging-and-error-recovery/SKILL.md +1 -1
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +5 -5
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +8 -8
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +4 -4
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +26 -56
- package/.agents/skills/core/gates-and-baselines/SKILL.md +149 -0
- package/.agents/skills/core/idea-refinement/SKILL.md +2 -8
- package/.agents/skills/core/qa-coverage-mapping/SKILL.md +7 -7
- package/.agents/skills/skills.index.json +11 -381
- package/.agents/workflows/deliver.md +47 -4
- package/.agents/workflows/helpers/acceptance-self-eval.md +38 -13
- package/.agents/workflows/helpers/deliver-epic-reference.md +18 -5
- package/.agents/workflows/helpers/deliver-epic-single.md +331 -0
- package/.agents/workflows/helpers/deliver-epic.md +51 -8
- package/.agents/workflows/helpers/deliver-stories.md +15 -5
- package/.agents/workflows/helpers/epic-deliver-story.md +12 -3
- package/.agents/workflows/helpers/mandrel-sync-config.md +1 -1
- package/.agents/workflows/helpers/plan-epic.md +25 -23
- package/.agents/workflows/mandrel-update.md +1 -1
- package/docs/CHANGELOG.md +16 -0
- package/lib/cli/registry.js +95 -0
- package/package.json +4 -2
- package/.agents/personas/engineer-mobile.md +0 -120
- package/.agents/personas/engineer-web.md +0 -111
- package/.agents/personas/product.md +0 -94
- package/.agents/personas/refactorer.md +0 -113
- package/.agents/personas/sre.md +0 -86
- package/.agents/personas/ux-designer.md +0 -95
- package/.agents/scripts/epic-plan-decompose.js +0 -54
- package/.agents/scripts/epic-plan-spec.js +0 -64
- package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +0 -109
- package/.agents/scripts/plan-critics.js +0 -199
- package/.agents/skills/core/baseline-refresh/SKILL.md +0 -181
- package/.agents/skills/core/ci-cd-and-automation/SKILL.md +0 -274
- package/.agents/skills/core/ci-cd-and-automation/examples.md +0 -211
- package/.agents/skills/core/code-simplification/SKILL.md +0 -389
- package/.agents/skills/core/context-engineering/SKILL.md +0 -309
- package/.agents/skills/core/context-engineering/examples.md +0 -58
- package/.agents/skills/core/deprecation-and-migration/SKILL.md +0 -250
- package/.agents/skills/core/frontend-ui-engineering/SKILL.md +0 -357
- package/.agents/skills/core/hydrate-context/SKILL.md +0 -123
- package/.agents/skills/core/idea-refinement/examples.md +0 -437
- package/.agents/skills/core/idea-refinement/frameworks.md +0 -135
- package/.agents/skills/core/incremental-implementation/SKILL.md +0 -271
- package/.agents/skills/core/introducing-a-baseline-gate/SKILL.md +0 -213
- package/.agents/skills/core/knowledge-transfer/SKILL.md +0 -180
- package/.agents/skills/core/mutation-survivor-remediation/SKILL.md +0 -117
- package/.agents/skills/core/performance-optimization/SKILL.md +0 -314
- package/.agents/skills/core/planning-and-task-breakdown/SKILL.md +0 -277
- package/.agents/skills/core/property-based-testing/SKILL.md +0 -148
- package/.agents/skills/core/refactoring-discipline/SKILL.md +0 -111
- package/.agents/skills/core/shipping-and-launch/SKILL.md +0 -328
- package/.agents/skills/core/spec-driven-development/SKILL.md +0 -252
- package/.agents/skills/core/test-driven-development/SKILL.md +0 -475
- package/.agents/skills/core/using-agent-skills/SKILL.md +0 -232
- package/.agents/skills/stack/architecture/monorepo-path-strategist/SKILL.md +0 -31
- package/.agents/skills/stack/architecture/structured-output-zod/SKILL.md +0 -51
- package/.agents/skills/stack/architecture/subagent-orchestration/SKILL.md +0 -76
- package/.agents/skills/stack/backend/cloudflare-hono-architect/SKILL.md +0 -31
- package/.agents/skills/stack/backend/cloudflare-hono-architect/examples/route-template.ts +0 -33
- package/.agents/skills/stack/backend/cloudflare-queue-manager/SKILL.md +0 -31
- package/.agents/skills/stack/backend/cloudflare-workers/SKILL.md +0 -51
- package/.agents/skills/stack/backend/highlevel-crm/SKILL.md +0 -54
- package/.agents/skills/stack/backend/sqlite-drizzle-expert/SKILL.md +0 -29
- package/.agents/skills/stack/backend/sqlite-drizzle-expert/examples/schema-template.ts +0 -30
- package/.agents/skills/stack/backend/stripe-integration/SKILL.md +0 -57
- package/.agents/skills/stack/backend/stripe-integration/scripts/listen-stripe.sh +0 -9
- package/.agents/skills/stack/backend/turso-sqlite/SKILL.md +0 -48
- package/.agents/skills/stack/frontend/astro/SKILL.md +0 -62
- package/.agents/skills/stack/frontend/astro-react-island-strategist/SKILL.md +0 -30
- package/.agents/skills/stack/frontend/expo-react-native-developer/SKILL.md +0 -29
- package/.agents/skills/stack/frontend/google-analytics-v4/SKILL.md +0 -50
- package/.agents/skills/stack/frontend/tailwind-v4/SKILL.md +0 -58
- package/.agents/skills/stack/frontend/ui-accessibility-engineer/SKILL.md +0 -34
- package/.agents/skills/stack/qa/audit-accessibility/SKILL.md +0 -51
- package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +0 -199
- package/.agents/skills/stack/security/backend-security-patterns/SKILL.md +0 -68
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bookkeeping-outbox.js — Epic #4476 (M5): buffered GitHub bookkeeping.
|
|
3
|
+
*
|
|
4
|
+
* Delivery bookkeeping — structured-comment upserts (progress, friction,
|
|
5
|
+
* wave-stall, …) and `agent::*` label flips — is human-visible surface that,
|
|
6
|
+
* in an **unattended** (`--yes` / headless) run, no operator is watching in
|
|
7
|
+
* real time. Emitting each one as a live GitHub round-trip mid-run costs an
|
|
8
|
+
* LLM turn per transition for a surface nobody reads until the run finishes.
|
|
9
|
+
*
|
|
10
|
+
* This module lets those non-urgent mutations be **buffered to a local NDJSON
|
|
11
|
+
* outbox** during the run and **reconciled to GitHub once at finalize**.
|
|
12
|
+
* GitHub stays the source of truth *at rest* (post-reconcile); only the
|
|
13
|
+
* per-transition chatter moves off the token stream. The outbox file survives
|
|
14
|
+
* a crash, so crash recovery drains whatever was buffered before the finalize
|
|
15
|
+
* reconcile ran.
|
|
16
|
+
*
|
|
17
|
+
* ## What is NEVER buffered
|
|
18
|
+
* The `agent::blocked` HITL gate (`.agents/instructions.md` §1.J) is the single
|
|
19
|
+
* authoritative runtime pause point. A genuine blocker MUST surface on GitHub
|
|
20
|
+
* **immediately**, not batched to finalize — an operator can only resume a run
|
|
21
|
+
* they can see is blocked. The {@link transitionStateOrBuffer} facade forces
|
|
22
|
+
* `agent::blocked` (and any caller-marked `urgent`) transition through the
|
|
23
|
+
* live path regardless of headless mode.
|
|
24
|
+
*
|
|
25
|
+
* ## Attended runs are unchanged
|
|
26
|
+
* Buffering is gated on the explicit `headless` signal (Story #4427's
|
|
27
|
+
* `--headless`/`--yes` plumbing). An attended run passes `headless: false` and
|
|
28
|
+
* every comment/label posts live exactly as before — byte-for-byte behaviour.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
appendFileSync,
|
|
33
|
+
existsSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
readFileSync,
|
|
36
|
+
writeFileSync,
|
|
37
|
+
} from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
|
|
40
|
+
import { epicTempDir } from '../config/temp-paths.js';
|
|
41
|
+
import { STATE_LABELS } from './ticketing/reads.js';
|
|
42
|
+
import { upsertStructuredComment } from './ticketing/state.js';
|
|
43
|
+
import { transitionTicketState } from './ticketing/transition.js';
|
|
44
|
+
|
|
45
|
+
/** Canonical basename for the per-Epic bookkeeping outbox. */
|
|
46
|
+
const OUTBOX_BASENAME = 'bookkeeping-outbox.ndjson';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the canonical outbox path for an Epic:
|
|
50
|
+
* `temp/epic-<id>/bookkeeping-outbox.ndjson`.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} epicId
|
|
53
|
+
* @param {object} [config] Resolved config (tempRoot).
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function outboxPathFor(epicId, config) {
|
|
57
|
+
return path.join(epicTempDir(epicId, config), OUTBOX_BASENAME);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append one operation record to the outbox, creating the parent dir on
|
|
62
|
+
* demand. Best-effort at the storage layer is the caller's concern; here we
|
|
63
|
+
* let a genuine fs failure propagate so a mis-configured outbox path surfaces
|
|
64
|
+
* loudly in tests.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} outboxPath
|
|
67
|
+
* @param {object} op
|
|
68
|
+
*/
|
|
69
|
+
function appendOp(outboxPath, op) {
|
|
70
|
+
mkdirSync(path.dirname(outboxPath), { recursive: true });
|
|
71
|
+
appendFileSync(outboxPath, `${JSON.stringify(op)}\n`, 'utf8');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Buffer a structured-comment upsert. The op captures everything
|
|
76
|
+
* `upsertStructuredComment` needs at drain time so the reconcile is a pure
|
|
77
|
+
* replay with no re-derivation.
|
|
78
|
+
*
|
|
79
|
+
* @param {{ outboxPath: string, ticketId: number, marker: string,
|
|
80
|
+
* body: string, attrs?: Record<string, string|number>|null,
|
|
81
|
+
* ts?: string }} args
|
|
82
|
+
*/
|
|
83
|
+
export function enqueueComment({
|
|
84
|
+
outboxPath,
|
|
85
|
+
ticketId,
|
|
86
|
+
marker,
|
|
87
|
+
body,
|
|
88
|
+
attrs = null,
|
|
89
|
+
ts = new Date().toISOString(),
|
|
90
|
+
}) {
|
|
91
|
+
appendOp(outboxPath, {
|
|
92
|
+
kind: 'comment',
|
|
93
|
+
ts,
|
|
94
|
+
ticketId,
|
|
95
|
+
marker,
|
|
96
|
+
body,
|
|
97
|
+
...(attrs ? { attrs } : {}),
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Buffer an `agent::*` label transition.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ outboxPath: string, ticketId: number, state: string,
|
|
105
|
+
* ts?: string }} args
|
|
106
|
+
*/
|
|
107
|
+
export function enqueueLabel({
|
|
108
|
+
outboxPath,
|
|
109
|
+
ticketId,
|
|
110
|
+
state,
|
|
111
|
+
ts = new Date().toISOString(),
|
|
112
|
+
}) {
|
|
113
|
+
appendOp(outboxPath, { kind: 'label', ts, ticketId, state });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Parse the outbox into an ordered array of operation records. Malformed
|
|
118
|
+
* lines are skipped (never throw). A missing / empty outbox yields `[]`.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} outboxPath
|
|
121
|
+
* @returns {Array<object>}
|
|
122
|
+
*/
|
|
123
|
+
export function readOutbox(outboxPath) {
|
|
124
|
+
if (!outboxPath || !existsSync(outboxPath)) return [];
|
|
125
|
+
let raw;
|
|
126
|
+
try {
|
|
127
|
+
raw = readFileSync(outboxPath, 'utf8');
|
|
128
|
+
} catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
if (!raw) return [];
|
|
132
|
+
const ops = [];
|
|
133
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
134
|
+
if (!line) continue;
|
|
135
|
+
try {
|
|
136
|
+
const op = JSON.parse(line);
|
|
137
|
+
if (op && typeof op === 'object') ops.push(op);
|
|
138
|
+
} catch {
|
|
139
|
+
// skip a torn / malformed line — a partial write from a crash is not
|
|
140
|
+
// fatal to the rest of the batch.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return ops;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Drain the outbox to GitHub in FIFO order and clear it on success. Both sink
|
|
148
|
+
* operations are idempotent (comment upserts are marker-scoped;
|
|
149
|
+
* `transitionTicketState` applies one state via the canonical
|
|
150
|
+
* remove-all-then-add path), so a re-run after a partial drain converges. A
|
|
151
|
+
* per-op failure is recorded and the drain continues — a single bad ticket
|
|
152
|
+
* must not strand the rest of the batch. The outbox file is only truncated
|
|
153
|
+
* when EVERY op succeeded, so a crash mid-drain leaves the un-applied
|
|
154
|
+
* remainder for the next reconcile.
|
|
155
|
+
*
|
|
156
|
+
* @param {{ outboxPath: string,
|
|
157
|
+
* provider: import('../ITicketingProvider.js').ITicketingProvider,
|
|
158
|
+
* logger?: { warn?: (m: string) => void } }} args
|
|
159
|
+
* @returns {Promise<{ drained: number, comments: number, labels: number,
|
|
160
|
+
* errors: Array<{ op: object, error: string }>, cleared: boolean }>}
|
|
161
|
+
*/
|
|
162
|
+
export async function reconcileOutbox({ outboxPath, provider, logger }) {
|
|
163
|
+
const ops = readOutbox(outboxPath);
|
|
164
|
+
const result = {
|
|
165
|
+
drained: 0,
|
|
166
|
+
comments: 0,
|
|
167
|
+
labels: 0,
|
|
168
|
+
errors: [],
|
|
169
|
+
cleared: false,
|
|
170
|
+
};
|
|
171
|
+
if (ops.length === 0) {
|
|
172
|
+
result.cleared = true;
|
|
173
|
+
return result;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const op of ops) {
|
|
177
|
+
try {
|
|
178
|
+
if (op.kind === 'comment') {
|
|
179
|
+
await upsertStructuredComment(
|
|
180
|
+
provider,
|
|
181
|
+
op.ticketId,
|
|
182
|
+
op.marker,
|
|
183
|
+
op.body,
|
|
184
|
+
op.attrs ?? null,
|
|
185
|
+
);
|
|
186
|
+
result.comments += 1;
|
|
187
|
+
result.drained += 1;
|
|
188
|
+
} else if (op.kind === 'label') {
|
|
189
|
+
await transitionTicketState(provider, op.ticketId, op.state);
|
|
190
|
+
result.labels += 1;
|
|
191
|
+
result.drained += 1;
|
|
192
|
+
} else {
|
|
193
|
+
result.errors.push({ op, error: `unknown op kind "${op.kind}"` });
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
197
|
+
result.errors.push({ op, error: message });
|
|
198
|
+
logger?.warn?.(
|
|
199
|
+
`[bookkeeping-outbox] reconcile op failed (${op.kind} #${op.ticketId}): ${message}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Only truncate when the whole batch landed — otherwise the un-applied
|
|
205
|
+
// ops must survive for the next reconcile (crash-recovery contract).
|
|
206
|
+
if (result.errors.length === 0) {
|
|
207
|
+
try {
|
|
208
|
+
writeFileSync(outboxPath, '', 'utf8');
|
|
209
|
+
result.cleared = true;
|
|
210
|
+
} catch {
|
|
211
|
+
// A clear failure is non-fatal: the ops all landed, and the idempotent
|
|
212
|
+
// sinks make a redundant re-drain a no-op.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return result;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Facade: post a structured comment live, OR buffer it to the outbox when the
|
|
220
|
+
* run is headless. Comments are never urgent (the operator reads them at
|
|
221
|
+
* finalize), so headless always buffers when an `outboxPath` is available.
|
|
222
|
+
*
|
|
223
|
+
* @param {{ provider: object, ticketId: number, marker: string, body: string,
|
|
224
|
+
* attrs?: Record<string, string|number>|null, headless?: boolean,
|
|
225
|
+
* outboxPath?: string|null }} args
|
|
226
|
+
* @returns {Promise<{ buffered: boolean }>}
|
|
227
|
+
*/
|
|
228
|
+
export async function postCommentOrBuffer({
|
|
229
|
+
provider,
|
|
230
|
+
ticketId,
|
|
231
|
+
marker,
|
|
232
|
+
body,
|
|
233
|
+
attrs = null,
|
|
234
|
+
headless = false,
|
|
235
|
+
outboxPath = null,
|
|
236
|
+
}) {
|
|
237
|
+
if (headless && outboxPath) {
|
|
238
|
+
enqueueComment({ outboxPath, ticketId, marker, body, attrs });
|
|
239
|
+
return { buffered: true };
|
|
240
|
+
}
|
|
241
|
+
await upsertStructuredComment(provider, ticketId, marker, body, attrs);
|
|
242
|
+
return { buffered: false };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Facade: flip an `agent::*` state live, OR buffer it when headless — EXCEPT
|
|
247
|
+
* `agent::blocked` (and any caller-marked `urgent` flip), which ALWAYS goes
|
|
248
|
+
* live so the HITL gate surfaces immediately (§1.J).
|
|
249
|
+
*
|
|
250
|
+
* @param {{ provider: object, ticketId: number, state: string,
|
|
251
|
+
* headless?: boolean, outboxPath?: string|null,
|
|
252
|
+
* urgent?: boolean }} args
|
|
253
|
+
* @returns {Promise<{ buffered: boolean }>}
|
|
254
|
+
*/
|
|
255
|
+
export async function transitionStateOrBuffer({
|
|
256
|
+
provider,
|
|
257
|
+
ticketId,
|
|
258
|
+
state,
|
|
259
|
+
headless = false,
|
|
260
|
+
outboxPath = null,
|
|
261
|
+
urgent = false,
|
|
262
|
+
}) {
|
|
263
|
+
const mustSurfaceNow = urgent || state === STATE_LABELS.BLOCKED;
|
|
264
|
+
if (headless && outboxPath && !mustSurfaceNow) {
|
|
265
|
+
enqueueLabel({ outboxPath, ticketId, state });
|
|
266
|
+
return { buffered: true };
|
|
267
|
+
}
|
|
268
|
+
await transitionTicketState(provider, ticketId, state);
|
|
269
|
+
return { buffered: false };
|
|
270
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/ceremony-routing.js — risk-routed acceptance ceremony
|
|
3
|
+
* resolver (Epic #4478, M7-B, Part 2).
|
|
4
|
+
*
|
|
5
|
+
* The sibling of `review-depth.js` (risk → review depth) and
|
|
6
|
+
* `code-review.js#resolveAuditLenses` (risk → audit lens): it folds the
|
|
7
|
+
* planner-judged risk envelope into a per-cluster ceremony decision for the
|
|
8
|
+
* single-delivery acceptance critic — **fresh-context spawn** vs the
|
|
9
|
+
* contract-identical **inline** critic. It does NOT invent a new risk score
|
|
10
|
+
* and it does NOT own clustering.
|
|
11
|
+
*
|
|
12
|
+
* ## The load-bearing invariant (M4-B acceptance floor — DO NOT VIOLATE)
|
|
13
|
+
*
|
|
14
|
+
* Risk-routing chooses fresh-vs-inline **PER CLUSTER**. It NEVER changes the
|
|
15
|
+
* cluster COUNT. The cluster count is `ceil(totalACs / clusterCeiling)` with
|
|
16
|
+
* the non-disableable `[1, 8]` clamp, owned entirely by
|
|
17
|
+
* `acceptance-clusters.js` and untouched here. A low-risk Epic still gets one
|
|
18
|
+
* verdict per cluster — just possibly authored inline instead of by a fresh
|
|
19
|
+
* sub-agent. This module takes the cluster index as an INPUT and returns a
|
|
20
|
+
* decision for that one cluster; it has no way to add or remove clusters.
|
|
21
|
+
*
|
|
22
|
+
* ## Tier rules (per cluster)
|
|
23
|
+
*
|
|
24
|
+
* - `high` risk → `fresh` (a fresh-context maker-blind spawn).
|
|
25
|
+
* - `medium` risk → `fresh` (fail toward more ceremony, never less —
|
|
26
|
+
* matches `review-depth`'s fail-to-middle).
|
|
27
|
+
* - `low` risk → `inline` (the contract-identical inline critic),
|
|
28
|
+
* UNLESS the maker-checker sampling floor
|
|
29
|
+
* selects this cluster → `fresh`.
|
|
30
|
+
* - missing / unknown → `fresh` (fail-safe: an Epic that skipped `/plan`
|
|
31
|
+
* has no risk verdict; treat it as needing
|
|
32
|
+
* the full fresh-context ceremony, exactly
|
|
33
|
+
* as `review-depth` degrades to `standard`
|
|
34
|
+
* and `deriveRiskEnvelope` degrades to
|
|
35
|
+
* review-required).
|
|
36
|
+
*
|
|
37
|
+
* ## Maker-checker sampling floor
|
|
38
|
+
*
|
|
39
|
+
* Even at `low` risk, a fraction of clusters (`freshCriticSampleRate`, default
|
|
40
|
+
* 0.2) is forced `fresh` so low risk never means zero independent checking. The
|
|
41
|
+
* selection is **deterministic** in the cluster index (a fixed stride), so it
|
|
42
|
+
* is stable across re-runs and — critically — never changes the cluster count:
|
|
43
|
+
* it only re-labels which of the fixed set of clusters run fresh.
|
|
44
|
+
*
|
|
45
|
+
* Pure and total: inputs in, decision out. No I/O, no throws. `null` /
|
|
46
|
+
* `undefined` / malformed inputs degrade to `fresh` + `full` ceremony.
|
|
47
|
+
*
|
|
48
|
+
* @typedef {'fresh'|'inline'} CeremonyMode
|
|
49
|
+
* @typedef {'low'|'medium'|'high'} RiskLevel
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Decide whether the sampling floor forces this low-risk cluster fresh.
|
|
54
|
+
*
|
|
55
|
+
* Deterministic in the cluster index: with rate `r` (0 < r ≤ 1) the stride is
|
|
56
|
+
* `round(1 / r)` and every `stride`-th cluster (0-based indices 0, stride,
|
|
57
|
+
* 2·stride, …) is forced fresh, yielding ≈`r` of clusters fresh. `r <= 0`
|
|
58
|
+
* disables the floor (no cluster forced); `r >= 1` forces every cluster.
|
|
59
|
+
*
|
|
60
|
+
* @param {number} clusterIndex Zero-based cluster position (from the fixed
|
|
61
|
+
* `ceil(totalACs / clusterCeiling)` fan-out — an INPUT, never mutated here).
|
|
62
|
+
* @param {number} rate Sampling rate, already clamped into [0, 1] by
|
|
63
|
+
* `getDeliveryRouting`.
|
|
64
|
+
* @returns {boolean} `true` when the floor forces this cluster fresh.
|
|
65
|
+
*/
|
|
66
|
+
export function sampledFresh(clusterIndex, rate) {
|
|
67
|
+
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
if (rate >= 1) return true;
|
|
71
|
+
const idx =
|
|
72
|
+
typeof clusterIndex === 'number' &&
|
|
73
|
+
Number.isInteger(clusterIndex) &&
|
|
74
|
+
clusterIndex >= 0
|
|
75
|
+
? clusterIndex
|
|
76
|
+
: 0;
|
|
77
|
+
const stride = Math.max(1, Math.round(1 / rate));
|
|
78
|
+
return idx % stride === 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the acceptance ceremony for one cluster from the judged risk level
|
|
83
|
+
* and the maker-checker sampling floor. See the module header for the tier
|
|
84
|
+
* rules and the untouchable cluster-count invariant.
|
|
85
|
+
*
|
|
86
|
+
* @param {{
|
|
87
|
+
* overallLevel?: (RiskLevel|string|null|undefined),
|
|
88
|
+
* clusterIndex?: (number|null|undefined),
|
|
89
|
+
* freshCriticSampleRate?: (number|null|undefined),
|
|
90
|
+
* }} [input]
|
|
91
|
+
* @returns {{ mode: CeremonyMode, reason: string, sampled: boolean }}
|
|
92
|
+
*/
|
|
93
|
+
export function resolveCeremonyForRisk(input = {}) {
|
|
94
|
+
const overallLevel =
|
|
95
|
+
input && typeof input === 'object' ? input.overallLevel : undefined;
|
|
96
|
+
const clusterIndex =
|
|
97
|
+
input && typeof input === 'object' ? input.clusterIndex : undefined;
|
|
98
|
+
const rate =
|
|
99
|
+
input && typeof input === 'object'
|
|
100
|
+
? input.freshCriticSampleRate
|
|
101
|
+
: undefined;
|
|
102
|
+
|
|
103
|
+
if (overallLevel === 'high') {
|
|
104
|
+
return {
|
|
105
|
+
mode: 'fresh',
|
|
106
|
+
reason: 'high-risk: fresh-context critic',
|
|
107
|
+
sampled: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (overallLevel === 'medium') {
|
|
111
|
+
return {
|
|
112
|
+
mode: 'fresh',
|
|
113
|
+
reason: 'medium-risk: fresh-context critic (fail toward more ceremony)',
|
|
114
|
+
sampled: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (overallLevel === 'low') {
|
|
118
|
+
if (sampledFresh(clusterIndex, rate)) {
|
|
119
|
+
return {
|
|
120
|
+
mode: 'fresh',
|
|
121
|
+
reason:
|
|
122
|
+
'low-risk cluster forced fresh by the maker-checker sampling floor',
|
|
123
|
+
sampled: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
mode: 'inline',
|
|
128
|
+
reason: 'low-risk: contract-identical inline critic',
|
|
129
|
+
sampled: false,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Missing / unknown / malformed risk → fail-safe fresh + full ceremony,
|
|
133
|
+
// matching how review-depth.js and deriveRiskEnvelope degrade on an
|
|
134
|
+
// unjudged Epic.
|
|
135
|
+
return {
|
|
136
|
+
mode: 'fresh',
|
|
137
|
+
reason:
|
|
138
|
+
'risk absent/unknown: fail-safe fresh-context critic + full ceremony',
|
|
139
|
+
sampled: false,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
@@ -35,12 +35,10 @@ import {
|
|
|
35
35
|
elideEnvelope,
|
|
36
36
|
envelopeToPrompt,
|
|
37
37
|
} from './context-envelope.js';
|
|
38
|
-
import { loadSkillCapsule } from './skill-capsule-loader.js';
|
|
39
38
|
|
|
40
39
|
// ---------------------------------------------------------------------------
|
|
41
|
-
// File-content cache — the agent-protocol template
|
|
42
|
-
//
|
|
43
|
-
// `skills.index.json` + `loadSkillCapsule` (not cached here).
|
|
40
|
+
// File-content cache — the agent-protocol template is read-only during a
|
|
41
|
+
// dispatch run.
|
|
44
42
|
// ---------------------------------------------------------------------------
|
|
45
43
|
|
|
46
44
|
const _fileCache = new Map();
|
|
@@ -52,81 +50,6 @@ function readFileCached(absPath) {
|
|
|
52
50
|
return content;
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
let _skillsIndexCache = null;
|
|
56
|
-
|
|
57
|
-
function loadSkillsIndex() {
|
|
58
|
-
if (!_skillsIndexCache) {
|
|
59
|
-
const indexPath = path.join(
|
|
60
|
-
PROJECT_ROOT,
|
|
61
|
-
'.agents',
|
|
62
|
-
'skills',
|
|
63
|
-
'skills.index.json',
|
|
64
|
-
);
|
|
65
|
-
_skillsIndexCache = JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
66
|
-
}
|
|
67
|
-
return _skillsIndexCache;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Test-only seam: clear the persona/skill/template cache between runs.
|
|
72
|
-
* The `__` prefix matches the project convention for test-only exports
|
|
73
|
-
* (see `git-utils.__setGitRunners`, `git-utils.__setSleep`).
|
|
74
|
-
*/
|
|
75
|
-
export function __resetContextCache() {
|
|
76
|
-
_fileCache.clear();
|
|
77
|
-
_skillsIndexCache = null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Resolve activated skills to Policy Capsule payloads via `skills.index.json`.
|
|
82
|
-
*
|
|
83
|
-
* Capsule-only is the contract (Story #3863, hard cutover): only the Policy
|
|
84
|
-
* Capsule is hydrated. The full `SKILL.md` body is never inlined into a task
|
|
85
|
-
* prompt — the sub-agent reads it on demand via the rendered pointer path.
|
|
86
|
-
*
|
|
87
|
-
* @param {object} task - Normalized task (skills[]).
|
|
88
|
-
* @param {object} skillsIndex - Parsed `skills.index.json` body.
|
|
89
|
-
* @returns {Array<{ skill: string, capsule: string, source: string, path: string }>}
|
|
90
|
-
*/
|
|
91
|
-
export function buildSkillCapsuleSections(task, skillsIndex) {
|
|
92
|
-
const entries = [];
|
|
93
|
-
|
|
94
|
-
for (const skill of task.skills ?? []) {
|
|
95
|
-
try {
|
|
96
|
-
const {
|
|
97
|
-
capsule,
|
|
98
|
-
source,
|
|
99
|
-
path: skillPath,
|
|
100
|
-
} = loadSkillCapsule(skill, skillsIndex);
|
|
101
|
-
entries.push({ skill, capsule, source, path: skillPath });
|
|
102
|
-
} catch (err) {
|
|
103
|
-
Logger.warn(`[Hydrator] Failed to load skill ${skill}: ${err.message}`);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return entries;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Render skill capsule entries for the prose prompt and envelope
|
|
112
|
-
* `skillCapsules` section. Each entry carries its capsule plus a pointer
|
|
113
|
-
* instruction so the sub-agent knows to `Read` the full `SKILL.md` when the
|
|
114
|
-
* task needs the playbook beyond the capsule's non-negotiables. The source is
|
|
115
|
-
* recorded per skill for auditors.
|
|
116
|
-
*
|
|
117
|
-
* @param {Array<{ skill: string, capsule: string, source: string, path: string }>} entries
|
|
118
|
-
* @returns {string}
|
|
119
|
-
*/
|
|
120
|
-
export function formatSkillCapsulesSection(entries) {
|
|
121
|
-
if (!entries.length) return '';
|
|
122
|
-
let out = '## Activated Skills\n\n';
|
|
123
|
-
for (const { skill, capsule, source, path: skillPath } of entries) {
|
|
124
|
-
out += `### Skill: ${skill} (source: ${source})\n${capsule}\n\n`;
|
|
125
|
-
out += `Read the full playbook on demand: \`Read ${skillPath}\`.\n\n`;
|
|
126
|
-
}
|
|
127
|
-
return out.trimEnd();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
53
|
// ---------------------------------------------------------------------------
|
|
131
54
|
// Internal helpers
|
|
132
55
|
// ---------------------------------------------------------------------------
|
|
@@ -377,8 +300,6 @@ function envelopeTaskFrom(task) {
|
|
|
377
300
|
return {
|
|
378
301
|
id: task.id,
|
|
379
302
|
title: task.title,
|
|
380
|
-
persona: task.persona,
|
|
381
|
-
skills: task.skills,
|
|
382
303
|
protocolVersion: task.protocolVersion,
|
|
383
304
|
};
|
|
384
305
|
}
|
|
@@ -446,7 +367,7 @@ async function buildHierarchySections(task, provider, epicId, agentSettings) {
|
|
|
446
367
|
|
|
447
368
|
/**
|
|
448
369
|
* @param {object} task
|
|
449
|
-
* @param {{ templatesRoot: string
|
|
370
|
+
* @param {{ templatesRoot: string }} paths
|
|
450
371
|
* @param {object} agentSettings - Legacy-shim settings bag or resolved config.
|
|
451
372
|
* @param {string} currentVersion
|
|
452
373
|
* @param {string} taskBranch
|
|
@@ -491,48 +412,6 @@ function buildStaticSections(
|
|
|
491
412
|
});
|
|
492
413
|
}
|
|
493
414
|
|
|
494
|
-
if (task.persona) {
|
|
495
|
-
try {
|
|
496
|
-
const pPath = path.join(
|
|
497
|
-
PROJECT_ROOT,
|
|
498
|
-
paths.personasRoot,
|
|
499
|
-
`${task.persona}.md`,
|
|
500
|
-
);
|
|
501
|
-
if (fs.existsSync(pPath)) {
|
|
502
|
-
sections.push({
|
|
503
|
-
name: 'persona',
|
|
504
|
-
priority: DEFAULT_SECTION_PRIORITIES.persona,
|
|
505
|
-
elideWhenOverBudget: DEFAULT_ELIDE_POLICIES.persona,
|
|
506
|
-
content: `## Persona: ${task.persona}\n\n${readFileCached(pPath)}`,
|
|
507
|
-
source: { kind: 'file', ref: `personas/${task.persona}.md` },
|
|
508
|
-
});
|
|
509
|
-
}
|
|
510
|
-
} catch (err) {
|
|
511
|
-
Logger.warn(
|
|
512
|
-
`[Hydrator] Failed to load persona ${task.persona}: ${err.message}`,
|
|
513
|
-
);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
if (task.skills?.length > 0) {
|
|
518
|
-
try {
|
|
519
|
-
const skillsIndex = loadSkillsIndex();
|
|
520
|
-
const entries = buildSkillCapsuleSections(task, skillsIndex);
|
|
521
|
-
const skillsContext = formatSkillCapsulesSection(entries);
|
|
522
|
-
if (skillsContext) {
|
|
523
|
-
sections.push({
|
|
524
|
-
name: 'skillCapsules',
|
|
525
|
-
priority: DEFAULT_SECTION_PRIORITIES.skillCapsules,
|
|
526
|
-
elideWhenOverBudget: DEFAULT_ELIDE_POLICIES.skillCapsules,
|
|
527
|
-
content: skillsContext,
|
|
528
|
-
source: { kind: 'derived', ref: 'activated-skills' },
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
|
-
} catch (err) {
|
|
532
|
-
Logger.warn(`[Hydrator] Failed to load skills index: ${err.message}`);
|
|
533
|
-
}
|
|
534
|
-
}
|
|
535
|
-
|
|
536
415
|
// Track which dedicated section(s) were emitted so taskInstructions drops
|
|
537
416
|
// only the inline sections that were actually reproduced elsewhere in the
|
|
538
417
|
// envelope — keeping each binding acceptance/verify item present exactly
|