mandrel 1.92.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/file-assumptions.js +68 -7
- 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-context.js +189 -3
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +3 -2
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +99 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +38 -1
- package/.agents/scripts/lib/orchestration/plan-persist/summary.js +16 -1
- 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 +19 -2
- package/.agents/scripts/lib/provider-factory.js +1 -1
- package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
- package/.agents/scripts/plan-context.js +28 -10
- 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-reference.md +19 -8
- package/.agents/workflows/helpers/plan-epic.md +95 -27
- package/.agents/workflows/helpers/scope-triage-gate.md +9 -0
- package/.agents/workflows/mandrel-update.md +1 -1
- package/.agents/workflows/plan.md +16 -4
- package/docs/CHANGELOG.md +23 -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 -227
- 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,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* slice-phase.js — single-delivery slice lifecycle + checkpoint writer
|
|
5
|
+
* (Epic #4475, M4-B). The single-delivery analogue of `story-phase.js`.
|
|
6
|
+
*
|
|
7
|
+
* A single-delivery run collapses the whole Epic into one long guarded session
|
|
8
|
+
* walking the Epic body's `## Delivery Slicing` table on `epic/<id>` (there is
|
|
9
|
+
* no per-Story fan-out, so no `story.heartbeat`). The `deliver-epic-single.md`
|
|
10
|
+
* executor calls this CLI at each slice boundary so the run has:
|
|
11
|
+
*
|
|
12
|
+
* 1. A ledger signal — one `slice.start` / `slice.end` / `slice.heartbeat`
|
|
13
|
+
* record appended to `temp/epic-<epicId>/lifecycle.ndjson` (via the M4-A
|
|
14
|
+
* `emit-slice-lifecycle.js` emitters). `slice.heartbeat` is the
|
|
15
|
+
* forward-progress signal the `/deliver` §2e Idle Watchdog reads to tell
|
|
16
|
+
* the one long session apart from a dead one; `slice.start`/`.end` bracket
|
|
17
|
+
* each slice.
|
|
18
|
+
* 2. A durable checkpoint flip — when `--record <status>` is passed (the
|
|
19
|
+
* executor passes `--record done` after a slice commits to `epic/<id>`),
|
|
20
|
+
* `slices[sliceId].status` is spliced `pending → done` on the
|
|
21
|
+
* `epic-run-state` checkpoint so a resumed run SKIPS the already-landed
|
|
22
|
+
* slice (the branch already carries the work — no re-pay).
|
|
23
|
+
*
|
|
24
|
+
* The ledger emit is best-effort: a missing/unreachable ledger or a schema
|
|
25
|
+
* hiccup is logged and swallowed — the checkpoint is the source of truth, the
|
|
26
|
+
* ledger record is observability (mirrors `story-phase.js`). The checkpoint
|
|
27
|
+
* flip (when requested) is authoritative and its failure propagates.
|
|
28
|
+
*
|
|
29
|
+
* CLI:
|
|
30
|
+
* --epic <id> Epic ID (required).
|
|
31
|
+
* --slice <sliceId> Slice-map key, e.g. slice-1 (required).
|
|
32
|
+
* --event <start|end|heartbeat> Lifecycle event to emit (required).
|
|
33
|
+
* --outcome <done|blocked|failed|skipped>
|
|
34
|
+
* Required for --event end.
|
|
35
|
+
* --record <pending|done|blocked|failed>
|
|
36
|
+
* Flip slices[sliceId].status on the
|
|
37
|
+
* checkpoint. Omit to leave it untouched.
|
|
38
|
+
* --slice-index <n> Zero-based table position (metadata).
|
|
39
|
+
* --title <str> Human-readable slice label.
|
|
40
|
+
* --phase <init|implementing|closing|blocked|done>
|
|
41
|
+
* Heartbeat phase (default implementing).
|
|
42
|
+
* --duration-ms <n> Slice duration for --event end.
|
|
43
|
+
* --work-cwd <path> Single worktree root for the
|
|
44
|
+
* active-slice env export (--event
|
|
45
|
+
* start). Defaults to the process cwd.
|
|
46
|
+
* --no-emit Suppress the ledger emit (tests).
|
|
47
|
+
*
|
|
48
|
+
* Stdout: a single JSON envelope
|
|
49
|
+
* { ok, epicId, sliceId, event, emitted, ledgerPath, recorded, status }
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { parseArgs } from 'node:util';
|
|
53
|
+
|
|
54
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
55
|
+
import { resolveConfig } from './lib/config-resolver.js';
|
|
56
|
+
import { Logger } from './lib/Logger.js';
|
|
57
|
+
import { setActiveSliceEnv } from './lib/observability/active-story-env.js';
|
|
58
|
+
import { recordSliceStatus } from './lib/orchestration/epic-run-state-store.js';
|
|
59
|
+
import {
|
|
60
|
+
emitSliceEnd,
|
|
61
|
+
emitSliceHeartbeat,
|
|
62
|
+
emitSliceStart,
|
|
63
|
+
} from './lib/orchestration/lifecycle/emit-slice-lifecycle.js';
|
|
64
|
+
import { normalizeOperatorHandle } from './lib/orchestration/ticket-lease.js';
|
|
65
|
+
import { createProvider } from './lib/provider-factory.js';
|
|
66
|
+
|
|
67
|
+
const VALID_EVENTS = new Set(['start', 'end', 'heartbeat']);
|
|
68
|
+
const VALID_OUTCOMES = new Set(['done', 'blocked', 'failed', 'skipped']);
|
|
69
|
+
const VALID_HEARTBEAT_PHASES = new Set([
|
|
70
|
+
'init',
|
|
71
|
+
'implementing',
|
|
72
|
+
'closing',
|
|
73
|
+
'blocked',
|
|
74
|
+
'done',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
const HELP = `Usage: node .agents/scripts/slice-phase.js \\
|
|
78
|
+
--epic <id> --slice <sliceId> --event <start|end|heartbeat> \\
|
|
79
|
+
[--outcome <done|blocked|failed|skipped>] [--record <pending|done|blocked|failed>] \\
|
|
80
|
+
[--slice-index <n>] [--title <str>] [--phase <phase>] [--duration-ms <n>] [--no-emit]
|
|
81
|
+
|
|
82
|
+
Emits one slice.start / slice.end / slice.heartbeat record to the Epic's
|
|
83
|
+
lifecycle ledger and, when --record is supplied, flips slices[sliceId].status
|
|
84
|
+
on the epic-run-state checkpoint (the M4-B single-delivery slice walk uses
|
|
85
|
+
--record done after each slice commits to epic/<id> so resume skips it).
|
|
86
|
+
`;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Best-effort ledger emit for one slice boundary. Dispatches to the matching
|
|
90
|
+
* `emit-slice-lifecycle.js` emitter and swallows any append failure (logged,
|
|
91
|
+
* never fatal) — the checkpoint is state, the ledger is observability. A
|
|
92
|
+
* programming error (bad event / missing outcome) still throws so it surfaces
|
|
93
|
+
* in tests.
|
|
94
|
+
*
|
|
95
|
+
* @param {object} args
|
|
96
|
+
* @returns {{ emitted: boolean, ledgerPath: string|null }}
|
|
97
|
+
*/
|
|
98
|
+
function emitSliceEventBestEffort({
|
|
99
|
+
event,
|
|
100
|
+
epicId,
|
|
101
|
+
sliceId,
|
|
102
|
+
sliceIndex,
|
|
103
|
+
title,
|
|
104
|
+
outcome,
|
|
105
|
+
durationMs,
|
|
106
|
+
phase,
|
|
107
|
+
operator,
|
|
108
|
+
config,
|
|
109
|
+
ledgerPath,
|
|
110
|
+
timestamp,
|
|
111
|
+
}) {
|
|
112
|
+
try {
|
|
113
|
+
let res;
|
|
114
|
+
if (event === 'start') {
|
|
115
|
+
res = emitSliceStart({
|
|
116
|
+
epicId,
|
|
117
|
+
sliceId,
|
|
118
|
+
...(Number.isInteger(sliceIndex) ? { sliceIndex } : {}),
|
|
119
|
+
...(typeof title === 'string' ? { title } : {}),
|
|
120
|
+
timestamp,
|
|
121
|
+
config: config ?? undefined,
|
|
122
|
+
ledgerPath,
|
|
123
|
+
});
|
|
124
|
+
} else if (event === 'end') {
|
|
125
|
+
res = emitSliceEnd({
|
|
126
|
+
epicId,
|
|
127
|
+
sliceId,
|
|
128
|
+
outcome,
|
|
129
|
+
...(Number.isInteger(sliceIndex) ? { sliceIndex } : {}),
|
|
130
|
+
...(Number.isInteger(durationMs) ? { durationMs } : {}),
|
|
131
|
+
timestamp,
|
|
132
|
+
config: config ?? undefined,
|
|
133
|
+
ledgerPath,
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
res = emitSliceHeartbeat({
|
|
137
|
+
epicId,
|
|
138
|
+
sliceId,
|
|
139
|
+
phase,
|
|
140
|
+
timestamp,
|
|
141
|
+
...(operator !== null && operator !== undefined ? { operator } : {}),
|
|
142
|
+
config: config ?? undefined,
|
|
143
|
+
ledgerPath,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { emitted: true, ledgerPath: res.ledgerPath };
|
|
147
|
+
} catch (err) {
|
|
148
|
+
Logger.warn(
|
|
149
|
+
`[slice-phase] slice.${event} emit failed (continuing): ${err.message}`,
|
|
150
|
+
);
|
|
151
|
+
return { emitted: false, ledgerPath: null };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* End-to-end slice-phase writer. DI-friendly: tests pass `provider`, override
|
|
157
|
+
* the ledger path, and skip the emit as needed.
|
|
158
|
+
*
|
|
159
|
+
* @param {{
|
|
160
|
+
* epicId: number,
|
|
161
|
+
* sliceId: string,
|
|
162
|
+
* event: string,
|
|
163
|
+
* outcome?: string,
|
|
164
|
+
* record?: string,
|
|
165
|
+
* sliceIndex?: number,
|
|
166
|
+
* title?: string,
|
|
167
|
+
* phase?: string,
|
|
168
|
+
* durationMs?: number,
|
|
169
|
+
* noEmit?: boolean,
|
|
170
|
+
* provider?: object,
|
|
171
|
+
* config?: object,
|
|
172
|
+
* ledgerPath?: string,
|
|
173
|
+
* now?: Date,
|
|
174
|
+
* }} args
|
|
175
|
+
*/
|
|
176
|
+
export async function runSlicePhase(args) {
|
|
177
|
+
const {
|
|
178
|
+
epicId,
|
|
179
|
+
sliceId,
|
|
180
|
+
event,
|
|
181
|
+
outcome,
|
|
182
|
+
record,
|
|
183
|
+
sliceIndex,
|
|
184
|
+
title,
|
|
185
|
+
phase = 'implementing',
|
|
186
|
+
durationMs,
|
|
187
|
+
noEmit = false,
|
|
188
|
+
provider: providerOverride,
|
|
189
|
+
config: configOverride,
|
|
190
|
+
ledgerPath: ledgerPathOverride,
|
|
191
|
+
workCwd: workCwdOverride,
|
|
192
|
+
now = new Date(),
|
|
193
|
+
} = args ?? {};
|
|
194
|
+
|
|
195
|
+
if (!Number.isInteger(epicId) || epicId <= 0) {
|
|
196
|
+
throw new Error('runSlicePhase: --epic must be a positive integer');
|
|
197
|
+
}
|
|
198
|
+
if (typeof sliceId !== 'string' || sliceId.length === 0) {
|
|
199
|
+
throw new Error('runSlicePhase: --slice must be a non-empty string');
|
|
200
|
+
}
|
|
201
|
+
if (!VALID_EVENTS.has(event)) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`runSlicePhase: --event "${event}" must be one of: ${[...VALID_EVENTS].join(', ')}`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (event === 'end' && !VALID_OUTCOMES.has(outcome)) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`runSlicePhase: --event end requires --outcome one of: ${[...VALID_OUTCOMES].join(', ')}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
if (event === 'heartbeat' && !VALID_HEARTBEAT_PHASES.has(phase)) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`runSlicePhase: --phase "${phase}" must be one of: ${[...VALID_HEARTBEAT_PHASES].join(', ')}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const config = configOverride ?? (providerOverride ? null : resolveConfig());
|
|
218
|
+
const operator = normalizeOperatorHandle(config?.github?.operatorHandle);
|
|
219
|
+
const timestamp = now.toISOString();
|
|
220
|
+
|
|
221
|
+
// Epic #4476 (M5): at each slice boundary START, export the active-slice
|
|
222
|
+
// env (`CC_EPIC_ID` / `CC_SLICE_ID` / `CC_OPERATOR`) into the single
|
|
223
|
+
// worktree's `.env.local` so the PostToolUse hook emits `slice.heartbeat`
|
|
224
|
+
// off the token stream for the duration of this slice — no per-step
|
|
225
|
+
// heartbeat CLI turn. Best-effort: a write failure never blocks the slice.
|
|
226
|
+
// Gated on an explicit `workCwd` (the CLI supplies `process.cwd()`); a bare
|
|
227
|
+
// `runSlicePhase` call in a unit test passes none and never touches the env
|
|
228
|
+
// or writes a `.env.local`.
|
|
229
|
+
let sliceEnvWritten = false;
|
|
230
|
+
if (
|
|
231
|
+
event === 'start' &&
|
|
232
|
+
typeof workCwdOverride === 'string' &&
|
|
233
|
+
workCwdOverride.length > 0
|
|
234
|
+
) {
|
|
235
|
+
try {
|
|
236
|
+
const res = setActiveSliceEnv({
|
|
237
|
+
epicId,
|
|
238
|
+
sliceId,
|
|
239
|
+
...(operator ? { operator } : {}),
|
|
240
|
+
workCwd: workCwdOverride,
|
|
241
|
+
logger: Logger,
|
|
242
|
+
});
|
|
243
|
+
sliceEnvWritten = res.fileWritten;
|
|
244
|
+
} catch (err) {
|
|
245
|
+
Logger.warn(
|
|
246
|
+
`[slice-phase] active-slice env export failed (continuing): ${err.message}`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
let emitted = false;
|
|
252
|
+
let ledgerPath = null;
|
|
253
|
+
if (!noEmit) {
|
|
254
|
+
({ emitted, ledgerPath } = emitSliceEventBestEffort({
|
|
255
|
+
event,
|
|
256
|
+
epicId,
|
|
257
|
+
sliceId,
|
|
258
|
+
sliceIndex,
|
|
259
|
+
title,
|
|
260
|
+
outcome,
|
|
261
|
+
durationMs,
|
|
262
|
+
phase,
|
|
263
|
+
operator,
|
|
264
|
+
config,
|
|
265
|
+
ledgerPath: ledgerPathOverride,
|
|
266
|
+
timestamp,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Authoritative checkpoint flip — only when --record is supplied. Unlike the
|
|
271
|
+
// best-effort emit, a record failure propagates (the slice-map marker is the
|
|
272
|
+
// resume contract; a silent failure would re-pay the slice on resume).
|
|
273
|
+
let recorded = false;
|
|
274
|
+
let status = null;
|
|
275
|
+
if (typeof record === 'string' && record.length > 0) {
|
|
276
|
+
const provider =
|
|
277
|
+
providerOverride ?? createProvider(config ?? resolveConfig());
|
|
278
|
+
await recordSliceStatus({
|
|
279
|
+
provider,
|
|
280
|
+
epicId,
|
|
281
|
+
sliceId,
|
|
282
|
+
status: record,
|
|
283
|
+
...(typeof title === 'string' && title ? { title } : {}),
|
|
284
|
+
});
|
|
285
|
+
recorded = true;
|
|
286
|
+
status = record;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
ok: true,
|
|
291
|
+
epicId,
|
|
292
|
+
sliceId,
|
|
293
|
+
event,
|
|
294
|
+
emitted,
|
|
295
|
+
ledgerPath,
|
|
296
|
+
recorded,
|
|
297
|
+
status,
|
|
298
|
+
sliceEnvWritten,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function parseArgv(argv) {
|
|
303
|
+
const { values } = parseArgs({
|
|
304
|
+
args: argv,
|
|
305
|
+
options: {
|
|
306
|
+
epic: { type: 'string' },
|
|
307
|
+
slice: { type: 'string' },
|
|
308
|
+
event: { type: 'string' },
|
|
309
|
+
outcome: { type: 'string' },
|
|
310
|
+
record: { type: 'string' },
|
|
311
|
+
'slice-index': { type: 'string' },
|
|
312
|
+
title: { type: 'string' },
|
|
313
|
+
phase: { type: 'string' },
|
|
314
|
+
'duration-ms': { type: 'string' },
|
|
315
|
+
'work-cwd': { type: 'string' },
|
|
316
|
+
'no-emit': { type: 'boolean' },
|
|
317
|
+
help: { type: 'boolean' },
|
|
318
|
+
},
|
|
319
|
+
strict: false,
|
|
320
|
+
});
|
|
321
|
+
const parsed = {
|
|
322
|
+
help: Boolean(values.help),
|
|
323
|
+
epicId: Number.parseInt(values.epic ?? '', 10),
|
|
324
|
+
sliceId: values.slice,
|
|
325
|
+
event: values.event,
|
|
326
|
+
noEmit: Boolean(values['no-emit']),
|
|
327
|
+
};
|
|
328
|
+
if (typeof values.outcome === 'string') parsed.outcome = values.outcome;
|
|
329
|
+
if (typeof values.record === 'string') parsed.record = values.record;
|
|
330
|
+
if (typeof values.title === 'string') parsed.title = values.title;
|
|
331
|
+
if (typeof values.phase === 'string') parsed.phase = values.phase;
|
|
332
|
+
if (typeof values['work-cwd'] === 'string' && values['work-cwd']) {
|
|
333
|
+
parsed.workCwd = values['work-cwd'];
|
|
334
|
+
}
|
|
335
|
+
if (values['slice-index'] !== undefined) {
|
|
336
|
+
parsed.sliceIndex = Number.parseInt(values['slice-index'], 10);
|
|
337
|
+
}
|
|
338
|
+
if (values['duration-ms'] !== undefined) {
|
|
339
|
+
parsed.durationMs = Number.parseInt(values['duration-ms'], 10);
|
|
340
|
+
}
|
|
341
|
+
return parsed;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
345
|
+
const parsed = parseArgv(argv);
|
|
346
|
+
if (parsed.help) {
|
|
347
|
+
process.stdout.write(HELP);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// The CLI runs from inside the single worktree (the executor `cd`s there),
|
|
351
|
+
// so default the active-slice env target to the process cwd unless an
|
|
352
|
+
// explicit --work-cwd overrides it. runSlicePhase only writes the
|
|
353
|
+
// `.env.local` for --event start.
|
|
354
|
+
const envelope = await runSlicePhase({
|
|
355
|
+
...parsed,
|
|
356
|
+
workCwd: parsed.workCwd ?? process.cwd(),
|
|
357
|
+
});
|
|
358
|
+
process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
runAsCli(import.meta.url, main, { source: 'slice-phase' });
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Projects .agents/agents/ (and .agents/local/agents/ when present) into a flat
|
|
5
|
+
* `.claude/agents/` tree so Claude Code exposes each role definition as a
|
|
6
|
+
* role-scoped sub-agent (`subagent_type: <name>`). Exact sibling of
|
|
7
|
+
* `sync-claude-commands.js` — same header, same local-shadow policy, same
|
|
8
|
+
* orphan-reap, same prune-exempt handling for the consumer-authored
|
|
9
|
+
* `.agents/local/agents/` source. Two source directories are enumerated in
|
|
10
|
+
* order:
|
|
11
|
+
*
|
|
12
|
+
* 1. PAYLOAD_SRC — `.agents/agents/` (the installed Mandrel payload)
|
|
13
|
+
* 2. LOCAL_SRC — `.agents/local/agents/` (consumer-authored, prune-exempt)
|
|
14
|
+
*
|
|
15
|
+
* The payload directory wins on basename collision: if both sources supply
|
|
16
|
+
* `foo.md`, the payload copy is projected and the local copy is ignored with a
|
|
17
|
+
* `shadowed` warning. Because both sources are unioned into `sourceSet`, local
|
|
18
|
+
* agent defs are also protected from the orphan-reap — they survive
|
|
19
|
+
* `npm install`, `mandrel sync`, and `mandrel update` with no manual re-sync.
|
|
20
|
+
*
|
|
21
|
+
* Only top-level `.md` files project (`.claude/agents/<name>.md`). Unlike the
|
|
22
|
+
* commands sync there is no `loops/` namespace and no plugin tree to reap — the
|
|
23
|
+
* agent surface is flat.
|
|
24
|
+
*
|
|
25
|
+
* A `.claude/agents/<name>.md` runs on its **own** system prompt — it does NOT
|
|
26
|
+
* inherit the `CLAUDE.md` @-import closure — so a spawn routed to a role agent
|
|
27
|
+
* stops re-paying the always-loaded context (issue #4478). The role defs
|
|
28
|
+
* @-import `security-baseline.md` so the inviolable security MUSTs stay
|
|
29
|
+
* single-sourced.
|
|
30
|
+
*
|
|
31
|
+
* Usage: node .agents/scripts/sync-claude-agents.js
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
// cli-opt-out: top-level-await script with no main() function — runAsCli wraps an async main, which doesn't apply here.
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
|
|
38
|
+
import { applyHeader } from './lib/command-header.js';
|
|
39
|
+
import { Logger } from './lib/Logger.js';
|
|
40
|
+
|
|
41
|
+
// Resolve the project root from the invocation cwd — the consumer project where
|
|
42
|
+
// `.agents/` is materialized and where Claude Code loads `.claude/agents/`.
|
|
43
|
+
// It MUST NOT use `__dirname/../..`: in an npm-installed consumer this script
|
|
44
|
+
// runs from `node_modules/mandrel/.agents/scripts/`, so that climb lands on the
|
|
45
|
+
// package dir and the defs would be written *inside node_modules* rather than
|
|
46
|
+
// the consumer. Every real invocation runs with cwd at the project root
|
|
47
|
+
// (`npm run sync:agents`, the prepare hook). Tests drive fixture trees via the
|
|
48
|
+
// SYNC_CLAUDE_AGENTS_SRC/DEST overrides below.
|
|
49
|
+
const PROJECT_ROOT = process.cwd();
|
|
50
|
+
|
|
51
|
+
// Env-var overrides exist so the sync logic can be exercised against a fixture
|
|
52
|
+
// agents tree in isolation. When unset, behaviour is unchanged — the script
|
|
53
|
+
// defaults to the real agents / .claude/agents directories. SYNC_CLAUDE_AGENTS_SRC
|
|
54
|
+
// overrides the PAYLOAD source only; LOCAL_SRC is always derived from the project
|
|
55
|
+
// root so fixture tests can isolate the payload source.
|
|
56
|
+
const PAYLOAD_SRC =
|
|
57
|
+
process.env.SYNC_CLAUDE_AGENTS_SRC ??
|
|
58
|
+
path.join(PROJECT_ROOT, '.agents', 'agents');
|
|
59
|
+
const LOCAL_SRC = path.join(PROJECT_ROOT, '.agents', 'local', 'agents');
|
|
60
|
+
|
|
61
|
+
const DEST_DIR =
|
|
62
|
+
process.env.SYNC_CLAUDE_AGENTS_DEST ??
|
|
63
|
+
path.join(PROJECT_ROOT, '.claude', 'agents');
|
|
64
|
+
|
|
65
|
+
export const HEADER =
|
|
66
|
+
'<!-- AUTO-GENERATED — do not edit. Source of truth: .agents/agents/ -->\n<!-- Re-run: npm run sync:agents -->\n\n';
|
|
67
|
+
|
|
68
|
+
export const LOCAL_HEADER =
|
|
69
|
+
'<!-- AUTO-GENERATED from .agents/local/ — do not edit. Source of truth: .agents/local/agents/ -->\n<!-- Re-run: npm run sync:agents -->\n\n';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Return true when the given directory path exists and is accessible.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} dir
|
|
75
|
+
* @returns {boolean}
|
|
76
|
+
*/
|
|
77
|
+
function dirExists(dir) {
|
|
78
|
+
try {
|
|
79
|
+
return fs.statSync(dir).isDirectory();
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fs.mkdirSync(DEST_DIR, { recursive: true });
|
|
86
|
+
|
|
87
|
+
// Top-level .md files project flat. Subdirectories are skipped.
|
|
88
|
+
const isTopLevelAgent = (entry) => entry.isFile() && entry.name.endsWith('.md');
|
|
89
|
+
|
|
90
|
+
// Enumerate sources: payload first, then local (if it exists). Payload wins on
|
|
91
|
+
// basename collision — a consumer must not silently shadow a core role def.
|
|
92
|
+
const SRC_DIRS = [PAYLOAD_SRC, LOCAL_SRC].filter(dirExists);
|
|
93
|
+
|
|
94
|
+
/** @type {Array<{dir: string, name: string}>} */
|
|
95
|
+
const entries = SRC_DIRS.flatMap((dir) =>
|
|
96
|
+
fs
|
|
97
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
98
|
+
.filter(isTopLevelAgent)
|
|
99
|
+
.map((e) => ({ dir, name: e.name })),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Collision policy: payload wins, warn on a shadowed local file.
|
|
103
|
+
const byName = new Map();
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
if (byName.has(e.name)) {
|
|
106
|
+
Logger.warn(` shadowed ${e.name} (local copy ignored; payload wins)`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
byName.set(e.name, e);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// sourceSet drives the orphan-reap: any existing agent def not in this set is
|
|
113
|
+
// removed.
|
|
114
|
+
const sourceSet = new Set(byName.keys());
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* List the `.md` agent defs currently on disk in the destination.
|
|
118
|
+
*
|
|
119
|
+
* @returns {string[]}
|
|
120
|
+
*/
|
|
121
|
+
function listExistingAgents() {
|
|
122
|
+
try {
|
|
123
|
+
return fs.readdirSync(DEST_DIR).filter((f) => f.endsWith('.md'));
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const name of listExistingAgents()) {
|
|
130
|
+
if (!sourceSet.has(name)) {
|
|
131
|
+
fs.unlinkSync(path.join(DEST_DIR, name));
|
|
132
|
+
Logger.info(` removed ${name} (no longer in .agents/agents)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Copy each role def, injecting the auto-generated header after any leading
|
|
137
|
+
// frontmatter (so the `---` block stays on line 1 and Claude Code parses the
|
|
138
|
+
// agent's name/description). Use a distinct header comment for local-origin files.
|
|
139
|
+
let synced = 0;
|
|
140
|
+
const resolvedEntries = Array.from(byName.values());
|
|
141
|
+
await Promise.all(
|
|
142
|
+
resolvedEntries.map(async ({ dir, name }) => {
|
|
143
|
+
const isLocal = dir === LOCAL_SRC;
|
|
144
|
+
const header = isLocal ? LOCAL_HEADER : HEADER;
|
|
145
|
+
const content = await fs.promises.readFile(path.join(dir, name), 'utf8');
|
|
146
|
+
const dest = path.join(DEST_DIR, name);
|
|
147
|
+
const target = applyHeader(content, header);
|
|
148
|
+
|
|
149
|
+
// Skip write if content is already identical (avoid noisy git diffs).
|
|
150
|
+
try {
|
|
151
|
+
const existingContent = await fs.promises.readFile(dest, 'utf8');
|
|
152
|
+
if (existingContent === target) return;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if (err.code !== 'ENOENT') throw err;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await fs.promises.writeFile(dest, target, 'utf8');
|
|
158
|
+
synced++;
|
|
159
|
+
Logger.info(` synced ${name}`);
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
Logger.info(
|
|
164
|
+
`\n✔ ${synced} file(s) synced, ${sourceSet.size} total agents in .claude/agents/`,
|
|
165
|
+
);
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
import { parseArgs } from 'node:util';
|
|
14
14
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
15
15
|
import { Logger } from './lib/Logger.js';
|
|
16
|
+
import {
|
|
17
|
+
enqueueLabel,
|
|
18
|
+
outboxPathFor,
|
|
19
|
+
} from './lib/orchestration/bookkeeping-outbox.js';
|
|
16
20
|
import {
|
|
17
21
|
cascadeCompletion,
|
|
18
22
|
STATE_LABELS,
|
|
@@ -33,6 +37,8 @@ if (
|
|
|
33
37
|
ticket: { type: 'string' },
|
|
34
38
|
state: { type: 'string' },
|
|
35
39
|
'remove-label': { type: 'string' },
|
|
40
|
+
buffer: { type: 'boolean' },
|
|
41
|
+
epic: { type: 'string' },
|
|
36
42
|
},
|
|
37
43
|
strict: false,
|
|
38
44
|
});
|
|
@@ -69,6 +75,31 @@ if (
|
|
|
69
75
|
return;
|
|
70
76
|
}
|
|
71
77
|
|
|
78
|
+
// Headless buffering (Epic #4476 M5): buffer an intermediate state flip to
|
|
79
|
+
// the per-Epic outbox instead of a live GitHub round-trip; finalize's
|
|
80
|
+
// bookkeeping-reconcile.js drains it once. `agent::blocked` (the HITL gate,
|
|
81
|
+
// §1.J) and `agent::done` (its cascade must run live) ALWAYS surface
|
|
82
|
+
// immediately and are never buffered. Requires --epic to locate the outbox.
|
|
83
|
+
if (
|
|
84
|
+
values.buffer &&
|
|
85
|
+
state !== STATE_LABELS.BLOCKED &&
|
|
86
|
+
state !== STATE_LABELS.DONE
|
|
87
|
+
) {
|
|
88
|
+
const epicId = Number.parseInt(values.epic ?? '', 10);
|
|
89
|
+
if (!Number.isInteger(epicId) || epicId <= 0) {
|
|
90
|
+
throw new Error('--buffer requires --epic <id>');
|
|
91
|
+
}
|
|
92
|
+
enqueueLabel({
|
|
93
|
+
outboxPath: outboxPathFor(epicId, config),
|
|
94
|
+
ticketId,
|
|
95
|
+
state,
|
|
96
|
+
});
|
|
97
|
+
Logger.info(
|
|
98
|
+
`[State-Sync] Buffered #${ticketId} → ${state} (headless; drained at finalize)`,
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
Logger.info(
|
|
73
104
|
`[State-Sync] Transitioning ticket #${ticketId} to ${state}...`,
|
|
74
105
|
);
|