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
|
@@ -34,6 +34,47 @@
|
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
36
|
import { reconcileAcceptanceSpec as defaultReconcileAcceptanceSpec } from '../../../../acceptance-spec-reconciler.js';
|
|
37
|
+
import { DELIVERY_SINGLE_LABEL } from '../../deliver-route.js';
|
|
38
|
+
import { read as readEpicRunState } from '../../epic-run-state-store.js';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Default single-delivery resolver (Epic #4475, M4-B, design §2c). Decides
|
|
42
|
+
* whether the Epic under reconciliation is the single-delivery shape — the
|
|
43
|
+
* signal that flips a `waived` reconcile into a hard failure (the back gate
|
|
44
|
+
* of the non-waivable epic-level acceptance contract). Two sources, primary
|
|
45
|
+
* first:
|
|
46
|
+
*
|
|
47
|
+
* 1. `epic-run-state.deliveryShape === 'single'` — the durable checkpoint
|
|
48
|
+
* the single-delivery prepare writes.
|
|
49
|
+
* 2. The `delivery::single` label on the Epic ticket — the plan-time marker.
|
|
50
|
+
*
|
|
51
|
+
* Best-effort + fail-open-to-fan-out: any probe failure (null provider in a
|
|
52
|
+
* unit fixture, a GitHub read error) degrades to `false` (treat as fan-out),
|
|
53
|
+
* so the defence-in-depth back gate never *invents* a block — the front gate
|
|
54
|
+
* (prepare refusing `acceptance::n-a`) is the primary guard. Tests inject
|
|
55
|
+
* `resolveSingleFn` directly to bypass the probes.
|
|
56
|
+
*
|
|
57
|
+
* @param {{ provider?: object|null, epicId: number, config?: object|null }} args
|
|
58
|
+
* @returns {Promise<boolean>}
|
|
59
|
+
*/
|
|
60
|
+
export async function defaultResolveSingle({ provider, epicId }) {
|
|
61
|
+
if (!provider) return false;
|
|
62
|
+
try {
|
|
63
|
+
const checkpoint = await readEpicRunState({ provider, epicId });
|
|
64
|
+
if (checkpoint?.deliveryShape === 'single') return true;
|
|
65
|
+
} catch {
|
|
66
|
+
// Fall through to the label probe.
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const epic = await provider.getTicket(epicId);
|
|
70
|
+
const labels = Array.isArray(epic?.labels) ? epic.labels : [];
|
|
71
|
+
return labels.some(
|
|
72
|
+
(l) => (typeof l === 'string' ? l : l?.name) === DELIVERY_SINGLE_LABEL,
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
37
78
|
|
|
38
79
|
/**
|
|
39
80
|
* Classify a `reconcileAcceptanceSpec` result envelope into the typed
|
|
@@ -49,6 +90,16 @@ import { reconcileAcceptanceSpec as defaultReconcileAcceptanceSpec } from '../..
|
|
|
49
90
|
* `.waived` and route waived Epics through to PR
|
|
50
91
|
* creation, while empty-spec Epics still
|
|
51
92
|
* terminate without a PR via `.skipped`.
|
|
93
|
+
* **Non-waivable under single delivery (Epic #4475,
|
|
94
|
+
* M4-B, design §2c):** when `opts.single` is true the
|
|
95
|
+
* epic-level reconcile is the ONLY acceptance gate
|
|
96
|
+
* left, so a `waived` status is treated as a hard
|
|
97
|
+
* `failed` (reason `single-delivery-non-waivable`)
|
|
98
|
+
* instead of passing through — the back gate that
|
|
99
|
+
* forecloses the cohort's dilution (waived reconcile
|
|
100
|
+
* as the sole gate). The front gate (prepare refusing
|
|
101
|
+
* `acceptance::n-a`) already blocks this at seed time;
|
|
102
|
+
* this is defence in depth.
|
|
52
103
|
* - `'empty-spec'` → the linked spec exists but declares zero AC IDs.
|
|
53
104
|
* Treated as "no work to do"; emit `.skipped` with
|
|
54
105
|
* reason `'empty-spec'` so operators see the
|
|
@@ -61,14 +112,23 @@ import { reconcileAcceptanceSpec as defaultReconcileAcceptanceSpec } from '../..
|
|
|
61
112
|
* the Epic ticket.
|
|
62
113
|
*
|
|
63
114
|
* @param {object|undefined|null} result reconciler envelope.
|
|
115
|
+
* @param {{ single?: boolean }} [opts] `single: true` flips a `waived` status
|
|
116
|
+
* into a hard `failed` (the non-waivable epic reconcile back gate).
|
|
64
117
|
* @returns {{ outcome: 'ok'|'waived'|'skipped'|'failed', reason?: string }}
|
|
65
118
|
*/
|
|
66
|
-
export function classifyReconcileResult(result) {
|
|
119
|
+
export function classifyReconcileResult(result, opts = {}) {
|
|
120
|
+
const single = opts?.single === true;
|
|
67
121
|
if (!result || typeof result !== 'object') {
|
|
68
122
|
return { outcome: 'failed', reason: 'reconciler-no-result' };
|
|
69
123
|
}
|
|
70
124
|
const status = result.status;
|
|
71
125
|
if (status === 'waived') {
|
|
126
|
+
if (single) {
|
|
127
|
+
return {
|
|
128
|
+
outcome: 'failed',
|
|
129
|
+
reason: 'single-delivery-non-waivable',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
72
132
|
return { outcome: 'waived', reason: 'waiver' };
|
|
73
133
|
}
|
|
74
134
|
if (status === 'empty-spec') {
|
|
@@ -112,6 +172,9 @@ export class AcceptanceReconciler {
|
|
|
112
172
|
* injected for the same reason.
|
|
113
173
|
* @param {Function} [opts.reconcileAcceptanceSpecFn] Override of the
|
|
114
174
|
* helper for tests; defaults to the production export.
|
|
175
|
+
* @param {Function} [opts.resolveSingleFn] Override of the single-delivery
|
|
176
|
+
* resolver for tests (Epic #4475, M4-B); defaults to `defaultResolveSingle`
|
|
177
|
+
* (checkpoint `deliveryShape` → `delivery::single` label).
|
|
115
178
|
* @param {{ info?: Function, warn?: Function, debug?: Function }} [opts.logger]
|
|
116
179
|
*/
|
|
117
180
|
constructor(opts = {}) {
|
|
@@ -134,6 +197,7 @@ export class AcceptanceReconciler {
|
|
|
134
197
|
this.config = opts.config ?? null;
|
|
135
198
|
this.reconcileAcceptanceSpecFn =
|
|
136
199
|
opts.reconcileAcceptanceSpecFn ?? defaultReconcileAcceptanceSpec;
|
|
200
|
+
this.resolveSingleFn = opts.resolveSingleFn ?? defaultResolveSingle;
|
|
137
201
|
this.logger = opts.logger ?? console;
|
|
138
202
|
/** @type {Set<string>} `${event}:${seqId}` keys we've handled. */
|
|
139
203
|
this._seen = new Set();
|
|
@@ -256,7 +320,24 @@ export class AcceptanceReconciler {
|
|
|
256
320
|
return;
|
|
257
321
|
}
|
|
258
322
|
|
|
259
|
-
|
|
323
|
+
// Resolve the delivery shape before classifying: under single delivery a
|
|
324
|
+
// `waived` reconcile is non-waivable (design §2c). Best-effort — a probe
|
|
325
|
+
// failure degrades to `false` (fan-out pass-through), never inventing a
|
|
326
|
+
// block.
|
|
327
|
+
let single = false;
|
|
328
|
+
try {
|
|
329
|
+
single = await this.resolveSingleFn({
|
|
330
|
+
provider: this.provider,
|
|
331
|
+
epicId,
|
|
332
|
+
config: this.config,
|
|
333
|
+
});
|
|
334
|
+
} catch (err) {
|
|
335
|
+
this.logger.warn?.(
|
|
336
|
+
`[AcceptanceReconciler] single-delivery probe failed (treating as fan-out): ${err?.message ?? err}`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const classification = classifyReconcileResult(result, { single });
|
|
260
341
|
if (classification.outcome === 'ok') {
|
|
261
342
|
await this._emitOk({ event, seqId, baseRead });
|
|
262
343
|
return;
|
|
@@ -81,6 +81,12 @@ export const SUBSCRIBED_END_EVENTS = Object.freeze([
|
|
|
81
81
|
'epic.snapshot.end',
|
|
82
82
|
'epic.watch.end',
|
|
83
83
|
'retro.end',
|
|
84
|
+
// Epic #4475 (M4-A) — single-delivery slice boundary. A completed slice is
|
|
85
|
+
// a resume checkpoint for the one long guarded session (the analogue of
|
|
86
|
+
// story.dispatch.end for fan-out). Subscribed now so the pointer updates
|
|
87
|
+
// once the M4-B executor emits slice.end through the bus; inert until then
|
|
88
|
+
// (nothing emits it in M4-A).
|
|
89
|
+
'slice.end',
|
|
84
90
|
'story.dispatch.end',
|
|
85
91
|
]);
|
|
86
92
|
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
* skip decision this module produces is logged to the plan-metrics ledger
|
|
29
29
|
* (`appendCriticSkip`) by the callers so under-firing is auditable.
|
|
30
30
|
*
|
|
31
|
-
* Pure, synchronous, no I/O — the
|
|
32
|
-
* authored artifacts and the resolved
|
|
31
|
+
* Pure, synchronous, no I/O — the folded pre-write phase inside
|
|
32
|
+
* `plan-persist.js` owns reading the authored artifacts and the resolved
|
|
33
|
+
* config.
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
36
|
import { evaluateConsolidationPrecondition } from './consolidation-precondition.js';
|
|
@@ -140,6 +140,7 @@ export async function runPreMergeGates({
|
|
|
140
140
|
config,
|
|
141
141
|
epicBranch,
|
|
142
142
|
cwd: worktreePath || cwd,
|
|
143
|
+
log: (m) => logger.info?.(m),
|
|
143
144
|
});
|
|
144
145
|
const gateCount = Array.isArray(gates) ? gates.length : 0;
|
|
145
146
|
// Story #2250 — emit `close-validate.start` only when both an epicId
|
|
@@ -272,7 +272,7 @@ export function validateAcFreshness({
|
|
|
272
272
|
* Epic #2501 introduced this guard after the legacy `baseline-refresh`
|
|
273
273
|
* leading-token prescription created a wave of commit-msg hook failures
|
|
274
274
|
* across story-deliver sub-agents. See
|
|
275
|
-
* `.agents/skills/core/
|
|
275
|
+
* `.agents/skills/core/gates-and-baselines/SKILL.md` for the canonical refresh
|
|
276
276
|
* shape (Conventional-Commits subject + `baseline-refresh: true` body
|
|
277
277
|
* trailer).
|
|
278
278
|
*/
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Accepts the canonical resolved config object (the wrapper returned by
|
|
5
5
|
* `resolveConfig()` with `config.github` populated). The legacy
|
|
6
6
|
* `orchestration`-shaped argument is no longer supported as part of the
|
|
7
|
-
* Epic #2880 hard cutover; see `.agents/rules/git-conventions.md#contract-cutovers-—-no-shim-layer`.
|
|
7
|
+
* Epic #2880 hard cutover; see `.agents/rules/git-conventions-reference.md#contract-cutovers-—-no-shim-layer`.
|
|
8
8
|
*
|
|
9
9
|
* @see docs/v5-implementation-plan.md Sprint 1B
|
|
10
10
|
*/
|
|
@@ -88,7 +88,7 @@ Your job is to take an Epic (including its inline User Stories) and a Technical
|
|
|
88
88
|
|
|
89
89
|
### LABEL CONVENTIONS:
|
|
90
90
|
- Every ticket must have the \`type::story\` label. No other type label is allowed — the retired Feature and Task tiers have no labels under this hierarchy.
|
|
91
|
-
- Every ticket must have a \`persona::[engineer|architect|qa-engineer|engineer
|
|
91
|
+
- Every ticket must have a \`persona::[engineer|architect|qa-engineer|security-engineer|etc]\` label indicating WHO should execute it.
|
|
92
92
|
|
|
93
93
|
### OUTPUT FORMAT:
|
|
94
94
|
You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown blocks.
|
|
@@ -22,6 +22,10 @@ import fs from 'node:fs/promises';
|
|
|
22
22
|
import { parseArgs } from 'node:util';
|
|
23
23
|
import { runAsCli } from './lib/cli-utils.js';
|
|
24
24
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
25
|
+
import {
|
|
26
|
+
outboxPathFor,
|
|
27
|
+
postCommentOrBuffer,
|
|
28
|
+
} from './lib/orchestration/bookkeeping-outbox.js';
|
|
25
29
|
import {
|
|
26
30
|
assertValidStructuredCommentType,
|
|
27
31
|
upsertStructuredComment,
|
|
@@ -37,6 +41,11 @@ Flags:
|
|
|
37
41
|
retro, epic-run-state, wave-0-start) (required).
|
|
38
42
|
--body-file Path to a file containing the markdown body (required).
|
|
39
43
|
--provider Provider name (default: inferred from .agentrc.json github block).
|
|
44
|
+
--buffer Headless mode: buffer this upsert to the per-Epic bookkeeping
|
|
45
|
+
outbox instead of posting live. Requires --epic. Drained to
|
|
46
|
+
GitHub once at finalize by bookkeeping-reconcile.js (Epic
|
|
47
|
+
#4476). Omit for attended runs (posts live, unchanged).
|
|
48
|
+
--epic <id> Epic id owning the outbox (required with --buffer).
|
|
40
49
|
--help Show this message.
|
|
41
50
|
`;
|
|
42
51
|
|
|
@@ -64,6 +73,8 @@ export function parseArgv(argv) {
|
|
|
64
73
|
marker: { type: 'string' },
|
|
65
74
|
'body-file': { type: 'string' },
|
|
66
75
|
provider: { type: 'string' },
|
|
76
|
+
buffer: { type: 'boolean' },
|
|
77
|
+
epic: { type: 'string' },
|
|
67
78
|
help: { type: 'boolean' },
|
|
68
79
|
},
|
|
69
80
|
strict: false,
|
|
@@ -115,6 +126,33 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
115
126
|
: config;
|
|
116
127
|
const provider = createProvider(effectiveConfig);
|
|
117
128
|
|
|
129
|
+
// Headless buffering (Epic #4476 M5): route the upsert to the per-Epic
|
|
130
|
+
// outbox instead of a live GitHub round-trip. Requires --epic to locate the
|
|
131
|
+
// outbox; finalize's bookkeeping-reconcile.js drains it once. Attended runs
|
|
132
|
+
// (no --buffer) are unchanged.
|
|
133
|
+
if (values.buffer) {
|
|
134
|
+
const epicId = Number.parseInt(values.epic ?? '', 10);
|
|
135
|
+
if (!Number.isInteger(epicId) || epicId <= 0) {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
'[post-structured-comment] --buffer requires --epic <id>.\n',
|
|
138
|
+
);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
assertValidStructuredCommentType(values.marker);
|
|
142
|
+
const { buffered } = await postCommentOrBuffer({
|
|
143
|
+
provider,
|
|
144
|
+
ticketId,
|
|
145
|
+
marker: values.marker,
|
|
146
|
+
body,
|
|
147
|
+
headless: true,
|
|
148
|
+
outboxPath: outboxPathFor(epicId, config),
|
|
149
|
+
});
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
`${JSON.stringify({ success: true, ticketId, type: values.marker, buffered })}\n`,
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
118
156
|
const envelope = await runPostStructuredComment({
|
|
119
157
|
ticketId,
|
|
120
158
|
type: values.marker,
|
|
@@ -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' });
|