mandrel 2.8.0 → 2.10.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.
Files changed (73) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +39 -1
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +44 -1
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +1 -1
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/audit-to-stories.js +51 -0
  17. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  18. package/.agents/scripts/deliver-recover.js +23 -6
  19. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  20. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  21. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  23. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  24. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  25. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  26. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  28. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  29. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  30. package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
  31. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  34. package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
  35. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  36. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  37. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  38. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  39. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  40. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  41. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  42. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  43. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  45. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  46. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  47. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  48. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  50. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  51. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  52. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  53. package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
  54. package/.agents/scripts/lib/test-env.js +65 -0
  55. package/.agents/scripts/plan-context.js +66 -9
  56. package/.agents/scripts/plan-critics.js +115 -3
  57. package/.agents/scripts/plan-persist.js +11 -1
  58. package/.agents/scripts/plan-run-epilogue.js +1 -1
  59. package/.agents/scripts/providers/github/issues.js +54 -7
  60. package/.agents/scripts/providers/github/search-budget.js +124 -0
  61. package/.agents/scripts/providers/github/search-query.js +71 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +79 -10
  63. package/.agents/scripts/single-story-init.js +19 -3
  64. package/.agents/scripts/stories-wave-tick.js +1 -1
  65. package/.agents/scripts/sync-branch-from-base.js +9 -3
  66. package/.agents/workflows/deliver.md +86 -230
  67. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  68. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  69. package/.agents/workflows/helpers/deliver-story.md +114 -422
  70. package/.agents/workflows/helpers/plan-reference.md +211 -0
  71. package/.agents/workflows/plan.md +107 -279
  72. package/docs/CHANGELOG.md +47 -0
  73. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ /**
2
+ * GitHub Provider — `/search/issues` fan-out budget (Story #4678).
3
+ *
4
+ * The 30-requests-per-minute cap is a property of GitHub's `/search/issues`
5
+ * endpoint, not of any one caller. A single audit-to-stories scan issues ~2
6
+ * search calls per finding; a 22-group scan therefore blows well past the cap
7
+ * with no throttle of any kind, and every exhausted call then spends its whole
8
+ * transient-retry budget re-issuing a request against an already-empty window.
9
+ *
10
+ * This module owns the throttle at the endpoint seam: a pure, injectable token
11
+ * bucket that `IssuesGateway#searchIssues` awaits before every call, so every
12
+ * caller (audit-to-stories dedup, `lib/duplicate-search.js`, the tickets
13
+ * gateway's `_searchIssues`) inherits one shared budget for free.
14
+ *
15
+ * `now` and `sleep` are injected so unit tests drive the bucket deterministically
16
+ * without wall-clock time.
17
+ */
18
+
19
+ /**
20
+ * Default budget: 30 tokens per 60s window, matching GitHub's authenticated
21
+ * Search API cap. When a rate limit is reported with no readable reset, the
22
+ * bucket pauses for one full window before the next `take()` resolves.
23
+ */
24
+ const SEARCH_BUDGET_DEFAULTS = Object.freeze({
25
+ capacity: 30,
26
+ windowMs: 60_000,
27
+ cooldownMs: 60_000,
28
+ });
29
+
30
+ /**
31
+ * Create a token-bucket search budget.
32
+ *
33
+ * `take()` resolves once a token is available, consuming it; it awaits an
34
+ * accruing token (and any active rate-limit cooldown) rather than failing.
35
+ * `noteRateLimited(resetAtMs)` drains the bucket and blocks every subsequent
36
+ * `take()` until the reported reset (or a fixed cooldown when no reset is
37
+ * readable), so the whole batch pauses **once** instead of each call retrying
38
+ * independently into the empty window.
39
+ *
40
+ * @param {object} [opts]
41
+ * @param {number} [opts.capacity] — max tokens (and burst size).
42
+ * @param {number} [opts.windowMs] — window over which `capacity` tokens accrue.
43
+ * @param {number} [opts.cooldownMs] — pause applied when a rate limit reports
44
+ * no readable reset time.
45
+ * @param {() => number} [opts.now] — millisecond clock (injected for tests).
46
+ * @param {(ms: number) => Promise<void>} [opts.sleep] — delay primitive.
47
+ * @returns {{ take: () => Promise<void>, noteRateLimited: (resetAtMs?: number) => void }}
48
+ */
49
+ export function createSearchBudget({
50
+ capacity = SEARCH_BUDGET_DEFAULTS.capacity,
51
+ windowMs = SEARCH_BUDGET_DEFAULTS.windowMs,
52
+ cooldownMs = SEARCH_BUDGET_DEFAULTS.cooldownMs,
53
+ now = () => Date.now(),
54
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
55
+ } = {}) {
56
+ const refillPerMs = capacity / windowMs;
57
+ let tokens = capacity;
58
+ let lastRefillAt = now();
59
+ let blockedUntil = 0;
60
+
61
+ function refill() {
62
+ const at = now();
63
+ const elapsed = at - lastRefillAt;
64
+ if (elapsed > 0) {
65
+ tokens = Math.min(capacity, tokens + elapsed * refillPerMs);
66
+ lastRefillAt = at;
67
+ }
68
+ }
69
+
70
+ async function take() {
71
+ for (;;) {
72
+ const at = now();
73
+ if (blockedUntil > at) {
74
+ await sleep(blockedUntil - at);
75
+ continue;
76
+ }
77
+ refill();
78
+ if (tokens >= 1) {
79
+ tokens -= 1;
80
+ return;
81
+ }
82
+ // Sleep just long enough for one token to accrue, then re-check.
83
+ const waitMs = Math.max(1, Math.ceil((1 - tokens) / refillPerMs));
84
+ await sleep(waitMs);
85
+ }
86
+ }
87
+
88
+ function noteRateLimited(resetAtMs) {
89
+ const at = now();
90
+ tokens = 0;
91
+ lastRefillAt = at;
92
+ const until =
93
+ typeof resetAtMs === 'number' && resetAtMs > at
94
+ ? resetAtMs
95
+ : at + cooldownMs;
96
+ if (until > blockedUntil) blockedUntil = until;
97
+ }
98
+
99
+ return { take, noteRateLimited };
100
+ }
101
+
102
+ /**
103
+ * Process-wide singleton shared by every `searchIssues` caller. Per-process,
104
+ * matching the one-scan-per-checkout model — there is deliberately no
105
+ * cross-process budget.
106
+ */
107
+ export const searchBudget = createSearchBudget();
108
+
109
+ /**
110
+ * Best-effort extract of a rate-limit reset time (epoch ms) from a thrown
111
+ * error's stderr. GitHub surfaces the reset as an `x-ratelimit-reset` epoch
112
+ * (seconds) header; `gh` echoes response headers onto stderr on failure.
113
+ * Returns `undefined` when no reset is readable so the bucket falls back to a
114
+ * fixed cooldown. Pure — no I/O.
115
+ *
116
+ * @param {unknown} err
117
+ * @returns {number|undefined} reset time in epoch milliseconds, or undefined.
118
+ */
119
+ export function parseRateLimitResetMs(err) {
120
+ const haystack = [err?.stderr, err?.message].filter(Boolean).join('\n');
121
+ const match = haystack.match(/x-ratelimit-reset:\s*(\d{10})/i);
122
+ if (!match) return undefined;
123
+ return Number.parseInt(match[1], 10) * 1000;
124
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * GitHub Provider — composed `/search/issues` query bound (Story #4678).
3
+ *
4
+ * `IssuesGateway#searchIssues` is the only place that knows the *composed* `q`
5
+ * — the caller's free text plus the `repo:<owner>/<repo> type:issue` qualifiers
6
+ * it appends. GitHub Search rejects a query over 256 characters with HTTP 422,
7
+ * which is neither transient nor caught, so an over-long title over a deep path
8
+ * would abort the whole scan. This module owns the defensive guard: it truncates
9
+ * the free-text portion on a whole-token boundary until the composed `q` fits.
10
+ *
11
+ * Pure and unit-testable — no I/O.
12
+ */
13
+
14
+ /**
15
+ * GitHub Search's documented maximum query length, in characters. Module-private
16
+ * — `composeBoundedQuery` is the only supported way to apply it, so the bound
17
+ * cannot drift between call sites.
18
+ */
19
+ const GITHUB_SEARCH_MAX_QUERY = 256;
20
+
21
+ /**
22
+ * Compose a `/search/issues` query from free text plus fixed qualifiers,
23
+ * truncating the free-text portion on a whole-token boundary so the whole
24
+ * composed string is at most `max` characters. Qualifiers are never dropped —
25
+ * they are the load-bearing scope (`repo:` / `type:`) — so when even the
26
+ * qualifiers alone exceed the budget the qualifier string is returned as-is.
27
+ *
28
+ * @param {string} freeText — the caller's free-text search term(s).
29
+ * @param {string[]} qualifiers — fixed qualifier tokens (e.g. `repo:o/r`).
30
+ * @param {number} [max] — character ceiling for the composed query.
31
+ * @returns {string} the composed query, at most `max` characters.
32
+ */
33
+ export function composeBoundedQuery(
34
+ freeText,
35
+ qualifiers,
36
+ max = GITHUB_SEARCH_MAX_QUERY,
37
+ ) {
38
+ const quals = qualifiers.join(' ');
39
+ const free = String(freeText ?? '').trim();
40
+ const full = `${free} ${quals}`.trim();
41
+ if (full.length <= max) return full;
42
+
43
+ // Reserve space for the qualifiers (and the joining space) and fit as many
44
+ // leading free-text tokens as the remaining budget allows.
45
+ const reserve = quals.length + (quals.length > 0 ? 1 : 0);
46
+ const budget = max - reserve;
47
+ const bounded = fitTokens(free.split(/\s+/).filter(Boolean), budget);
48
+ return bounded.length > 0 ? `${bounded} ${quals}`.trim() : quals;
49
+ }
50
+
51
+ /**
52
+ * Join as many leading `tokens` as fit within `budget` characters, on a
53
+ * whole-token boundary (space-separated). Returns '' when the budget cannot fit
54
+ * even the first token. Module-private — exercised through
55
+ * {@link composeBoundedQuery}, the only caller.
56
+ *
57
+ * @param {string[]} tokens
58
+ * @param {number} budget
59
+ * @returns {string}
60
+ */
61
+ function fitTokens(tokens, budget) {
62
+ const kept = [];
63
+ let length = 0;
64
+ for (const token of tokens) {
65
+ const cost = kept.length === 0 ? token.length : token.length + 1;
66
+ if (length + cost > budget) break;
67
+ kept.push(token);
68
+ length += cost;
69
+ }
70
+ return kept.join(' ');
71
+ }
@@ -27,7 +27,8 @@
27
27
  * short-circuits to a `noop` envelope.
28
28
  *
29
29
  * Usage:
30
- * node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>]
30
+ * node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>] [--wait]
31
+ * [--max-wait-seconds <n>]
31
32
  * [--cwd <main-repo>]
32
33
  *
33
34
  * Exit codes: 0 ok (merged, pending, or noop), 1 error.
@@ -40,11 +41,13 @@ import { parseSprintArgs } from './lib/cli-args.js';
40
41
  import { runAsCli } from './lib/cli-utils.js';
41
42
  import { resolveConfig } from './lib/config-resolver.js';
42
43
  import { formatCliError } from './lib/error-redactor.js';
43
- import { gh as defaultGh } from './lib/gh-exec.js';
44
+ import { createGh } from './lib/gh-exec.js';
44
45
  import { getStoryBranch } from './lib/git-utils.js';
45
46
  import { Logger } from './lib/Logger.js';
46
47
  import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
48
+ import { emitTerseResult } from './lib/observability/terse-result.js';
47
49
  import { MERGED_FLIP_FAILED_BLOCK_CLASS } from './lib/orchestration/lifecycle/emit-merge-flip-failed.js';
50
+ import { MERGE_WAIT_GH_TIMEOUT_MS } from './lib/orchestration/merge-poll.js';
48
51
  import { parsePrNumber } from './lib/orchestration/single-story-close/phases/code-review.js';
49
52
  import { runConfirmMergePhase as defaultRunConfirmMergePhase } from './lib/orchestration/single-story-close/phases/confirm-merge.js';
50
53
  import { parseCloseOptions } from './lib/orchestration/single-story-close/phases/options.js';
@@ -63,6 +66,25 @@ const progress = Logger.createProgress('single-story-confirm-merge', {
63
66
  stderr: true,
64
67
  });
65
68
 
69
+ /**
70
+ * Default `gh` facade for this CLI, bound to the merge wait's spawn-level
71
+ * timeout (Story #4710). This CLI is the resume surface async mode hands the
72
+ * merge wait to — a background invocation with no host tool ceiling — so an
73
+ * un-timeboxed `gh pr list` / `gh pr view` here could strand the resume the
74
+ * same way an un-timeboxed probe stranded the in-close wait.
75
+ */
76
+ const defaultGh = createGh(undefined, { timeoutMs: MERGE_WAIT_GH_TIMEOUT_MS });
77
+
78
+ /** One usage string for the throw path and `--help` (Story #4710). */
79
+ const USAGE =
80
+ 'Usage: node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>] [--wait] ' +
81
+ '[--max-wait-seconds <n>] [--cwd <main-repo>]\n\n' +
82
+ ' --wait resume the bounded merge wait instead of probing once\n' +
83
+ ' --max-wait-seconds per-invocation wait bound override, threaded to\n' +
84
+ ' resolveMergeWaitConfig exactly as the close does (wins\n' +
85
+ ' over delivery.mergeWatch.maxWaitSeconds and the async\n' +
86
+ ' probe-window cap; only meaningful with --wait)';
87
+
66
88
  /**
67
89
  * Read the `--pr <n>` flag from `process.argv` for the direct-CLI path.
68
90
  * Injection callers pass `pr` directly and never reach this. Returns the
@@ -105,6 +127,33 @@ function readWaitFlag() {
105
127
  }
106
128
  }
107
129
 
130
+ /**
131
+ * `--max-wait-seconds <n>`: per-invocation wait-bound override for the
132
+ * `--wait` resume path (Story #4710). The close CLI already accepted this
133
+ * flag, but the resume CLI — the exact command async mode's `pending`
134
+ * terminal hands off to — did not, so the documented per-run override was
135
+ * unreachable where it mattered most and a slow-CI landing depended on an
136
+ * unbounded chain of short invocations. Threaded to `runConfirmMergePhase`
137
+ * (and thence `resolveMergeWaitConfig`) exactly as close threads its own
138
+ * flag. Returns `undefined` when absent or not a positive integer — the
139
+ * phase's config/default resolution owns that case.
140
+ *
141
+ * @returns {number|undefined}
142
+ */
143
+ function readMaxWaitSecondsFlag() {
144
+ try {
145
+ const { values } = parseArgs({
146
+ args: process.argv.slice(2),
147
+ options: { 'max-wait-seconds': { type: 'string' } },
148
+ strict: false,
149
+ });
150
+ const parsed = Number.parseInt(String(values['max-wait-seconds']), 10);
151
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
152
+ } catch {
153
+ return undefined;
154
+ }
155
+ }
156
+
108
157
  /**
109
158
  * Resolve the PR number for the Story branch when one was not passed on
110
159
  * the CLI. Probes `gh pr list --head <branch> --state all` (the merged PR
@@ -142,11 +191,19 @@ async function resolvePrNumber({ cwd, storyBranch, gh }) {
142
191
  * exits via `process.exit` the moment `main` resolves.
143
192
  */
144
193
  async function logConfirmResult(result, terminal, config) {
145
- // Human-facing dump stays level-gated; the envelope is the machine
146
- // contract and must survive AGENT_LOG_LEVEL=silent.
147
- Logger.info(
148
- `\n--- CONFIRM MERGE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
149
- );
194
+ // Story #4685 full detail to a temp log; the agent acts on the terminal
195
+ // envelope emitted below. The summary line keeps the at-a-glance fields.
196
+ emitTerseResult({
197
+ label: 'CONFIRM MERGE RESULT',
198
+ result,
199
+ scope: result?.storyId,
200
+ summary: {
201
+ storyId: result?.storyId,
202
+ action: result?.action,
203
+ reason: result?.reason,
204
+ status: terminal?.status,
205
+ },
206
+ });
150
207
  emitTerminalEnvelope(terminal);
151
208
  await emitTerminalFriction({ envelope: terminal, config });
152
209
  return { success: terminal.status !== 'failed', result, terminal };
@@ -253,6 +310,7 @@ export async function runConfirmMerge({
253
310
  cwd: cwdParam,
254
311
  pr: prParam,
255
312
  wait: waitParam,
313
+ maxWaitSeconds: maxWaitSecondsParam,
256
314
  injectedProvider,
257
315
  injectedConfig,
258
316
  injectedGh,
@@ -266,11 +324,10 @@ export async function runConfirmMerge({
266
324
  });
267
325
 
268
326
  if (!storyId) {
269
- throw new Error(
270
- 'Usage: node single-story-confirm-merge.js --story <STORY_ID> [--pr <n>] [--wait] [--cwd <main-repo>]',
271
- );
327
+ throw new Error(USAGE);
272
328
  }
273
329
  const wait = waitParam ?? readWaitFlag();
330
+ const maxWaitSeconds = maxWaitSecondsParam ?? readMaxWaitSecondsFlag();
274
331
 
275
332
  const startedAtMs = Date.now();
276
333
  const config = injectedConfig || resolveConfig({ cwd });
@@ -337,6 +394,10 @@ export async function runConfirmMerge({
337
394
  // The close already armed it; this CLI is resuming that wait, not
338
395
  // deciding whether to arm.
339
396
  autoMergeEnabled: true,
397
+ // The per-run override (`--max-wait-seconds`), resolved by
398
+ // `resolveMergeWaitConfig` exactly as the close resolves its own flag —
399
+ // it wins over the config value and the async probe-window cap.
400
+ maxWaitSeconds,
340
401
  provider,
341
402
  config,
342
403
  progress,
@@ -441,6 +502,14 @@ export async function runConfirmMerge({
441
502
  * or none at all; "none at all" is what Story #4543 removes.
442
503
  */
443
504
  async function main() {
505
+ if (process.argv.includes('--help')) {
506
+ // Print usage (including --max-wait-seconds) and exit cleanly — the
507
+ // resume-path override is only discoverable if the CLI can say it exists.
508
+ // `process.stdout.write` (not console.log) keeps the CLI within the
509
+ // no-console repo invariant while still writing help to stdout.
510
+ process.stdout.write(`${USAGE}\n`);
511
+ return 0;
512
+ }
444
513
  try {
445
514
  const outcome = await runConfirmMerge();
446
515
  return exitCodeForTerminal(outcome?.terminal ?? { status: 'failed' });
@@ -55,6 +55,7 @@ import { getStoryBranch, gitSpawn, gitSync } from './lib/git-utils.js';
55
55
  import { Logger } from './lib/Logger.js';
56
56
  import { TYPE_LABELS } from './lib/label-constants.js';
57
57
  import { setActiveStoryEnv } from './lib/observability/active-story-env.js';
58
+ import { emitTerseResult } from './lib/observability/terse-result.js';
58
59
  import {
59
60
  executeFastForward,
60
61
  planFastForward,
@@ -769,9 +770,24 @@ export async function runSingleStoryInit({
769
770
  }
770
771
  }
771
772
 
772
- Logger.info('\n--- STORY INIT RESULT ---');
773
- Logger.info(JSON.stringify(result, null, 2));
774
- Logger.info('--- END RESULT ---\n');
773
+ // Story #4685 route the full result to a temp log and emit a single-line
774
+ // summary carrying the fields the orchestrating agent acts on (workCwd,
775
+ // remoteVerified). The `## Spec` names this the hot-path stdout to quiet.
776
+ emitTerseResult({
777
+ label: 'STORY INIT RESULT',
778
+ result,
779
+ scope: storyId,
780
+ logDir: path.join(cwd, 'temp', 'orchestration'),
781
+ summary: {
782
+ storyId,
783
+ storyBranch,
784
+ workCwd,
785
+ worktreeCreated,
786
+ dependenciesInstalled,
787
+ remoteVerified: result.remoteVerified,
788
+ dryRun,
789
+ },
790
+ });
775
791
  progress(
776
792
  'DONE',
777
793
  dryRun
@@ -862,7 +862,7 @@ async function main(argv) {
862
862
  inFlight: values['in-flight'],
863
863
  });
864
864
 
865
- process.stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
865
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
866
866
 
867
867
  if (exitCode !== 0) {
868
868
  Logger.error(
@@ -34,6 +34,7 @@ import { runAsCli } from './lib/cli-utils.js';
34
34
  import { syncBranchFromBase } from './lib/git/sync-from-base.js';
35
35
  import { gitSpawn, gitSync } from './lib/git-utils.js';
36
36
  import { Logger } from './lib/Logger.js';
37
+ import { emitTerseResult } from './lib/observability/terse-result.js';
37
38
  import { PROJECT_ROOT } from './lib/project-root.js';
38
39
 
39
40
  const progress = Logger.createProgress('sync-branch-from-base', {
@@ -93,9 +94,14 @@ export async function runSyncBranchFromBase(opts = {}) {
93
94
  gitSpawn,
94
95
  });
95
96
 
96
- Logger.info(
97
- `\n--- SYNC RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
98
- );
97
+ // Story #4685 — full detail to a temp log; emit a single summary line.
98
+ emitTerseResult({
99
+ label: 'SYNC RESULT',
100
+ result,
101
+ scope: branch,
102
+ logDir: path.join(cwd, 'temp', 'orchestration'),
103
+ summary: { branch, base, synced: result.synced, kind: result.kind },
104
+ });
99
105
 
100
106
  if (!result.synced) {
101
107
  const detail =