@ikunin/sprintpilot 2.0.10 → 2.1.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 (42) hide show
  1. package/README.md +245 -10
  2. package/_Sprintpilot/Sprintpilot.md +1 -1
  3. package/_Sprintpilot/bin/autopilot.js +581 -0
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
  6. package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
  7. package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
  8. package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
  9. package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
  10. package/_Sprintpilot/lib/orchestrator/land.js +155 -0
  11. package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
  12. package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
  13. package/_Sprintpilot/lib/orchestrator/report.js +95 -0
  14. package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
  15. package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
  16. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
  17. package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
  18. package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
  19. package/_Sprintpilot/manifest.yaml +1 -1
  20. package/_Sprintpilot/modules/git/config.yaml +26 -0
  21. package/_Sprintpilot/scripts/agent-adapter.js +4 -5
  22. package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
  23. package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
  24. package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
  25. package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
  26. package/_Sprintpilot/scripts/land-this-pr.js +110 -0
  27. package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
  28. package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
  29. package/_Sprintpilot/scripts/log-timing.js +12 -3
  30. package/_Sprintpilot/scripts/merge-shards.js +32 -12
  31. package/_Sprintpilot/scripts/post-green-gates.js +187 -0
  32. package/_Sprintpilot/scripts/preflight-merge.js +2 -1
  33. package/_Sprintpilot/scripts/resolve-dag.js +3 -1
  34. package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
  35. package/_Sprintpilot/scripts/state-shard.js +8 -1
  36. package/_Sprintpilot/scripts/summarize-timings.js +30 -12
  37. package/_Sprintpilot/scripts/with-retry.js +17 -5
  38. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
  39. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
  40. package/lib/core/update-check.js +11 -1
  41. package/package.json +1 -1
  42. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +0 -1388
@@ -0,0 +1,108 @@
1
+ // impact-classifier.js — classify the impact of a propose_alternative signal.
2
+ //
3
+ // The LLM proposes an alternative action. The orchestrator decides whether
4
+ // to auto-accept (low impact) or escalate to user_prompt (medium / high).
5
+ //
6
+ // Design (from the plan):
7
+ // - Different action `type` → high
8
+ // - Same type, different skill / script → medium
9
+ // - Same skill, args differ:
10
+ // * all differing args are in LOW_RISK_ARG_WHITELIST → low
11
+ // * otherwise → medium
12
+ // - LLM-supplied `urgency_hint` can only RAISE the classification,
13
+ // never lower it.
14
+ //
15
+ // Pure module. No I/O.
16
+
17
+ 'use strict';
18
+
19
+ const LOW_RISK_ARG_WHITELIST = new Set([
20
+ 'retry_budget',
21
+ 'action_id',
22
+ 'rationale',
23
+ 'branch_name_suffix',
24
+ ]);
25
+
26
+ const URGENCY_RANK = { low: 0, medium: 1, high: 2 };
27
+ const RANK_TO_URGENCY = ['low', 'medium', 'high'];
28
+
29
+ function isPlainObject(v) {
30
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
31
+ }
32
+
33
+ function diffArgs(planned, alternative) {
34
+ const a = isPlainObject(planned) ? planned : {};
35
+ const b = isPlainObject(alternative) ? alternative : {};
36
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
37
+ const diffs = [];
38
+ for (const k of keys) {
39
+ if (!deepEqual(a[k], b[k])) diffs.push(k);
40
+ }
41
+ return diffs;
42
+ }
43
+
44
+ function deepEqual(a, b) {
45
+ if (a === b) return true;
46
+ if (a === null || b === null) return false;
47
+ if (typeof a !== typeof b) return false;
48
+ if (typeof a !== 'object') return false;
49
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
50
+ if (Array.isArray(a)) {
51
+ if (a.length !== b.length) return false;
52
+ for (let i = 0; i < a.length; i += 1) {
53
+ if (!deepEqual(a[i], b[i])) return false;
54
+ }
55
+ return true;
56
+ }
57
+ const ak = Object.keys(a);
58
+ const bk = Object.keys(b);
59
+ if (ak.length !== bk.length) return false;
60
+ for (const k of ak) {
61
+ if (!deepEqual(a[k], b[k])) return false;
62
+ }
63
+ return true;
64
+ }
65
+
66
+ function bumpByUrgency(level, urgencyHint) {
67
+ if (!urgencyHint || !(urgencyHint in URGENCY_RANK)) return level;
68
+ const baseRank = URGENCY_RANK[level];
69
+ const hintRank = URGENCY_RANK[urgencyHint];
70
+ return RANK_TO_URGENCY[Math.max(baseRank, hintRank)];
71
+ }
72
+
73
+ // classifyImpact(planned, alternative, llmUrgencyHint?) → 'low' | 'medium' | 'high'
74
+ function classifyImpact(planned, alternative, llmUrgencyHint) {
75
+ if (!isPlainObject(planned) || !isPlainObject(alternative)) {
76
+ return bumpByUrgency('high', llmUrgencyHint);
77
+ }
78
+ if (planned.type !== alternative.type) {
79
+ return bumpByUrgency('high', llmUrgencyHint);
80
+ }
81
+
82
+ // Same type. For invoke_skill we look at skill identity then args.
83
+ // For run_script we look at command[0] then args. For git_op we look at
84
+ // git subcommand.
85
+ if (planned.type === 'invoke_skill') {
86
+ if (planned.skill !== alternative.skill) return bumpByUrgency('medium', llmUrgencyHint);
87
+ } else if (planned.type === 'run_script') {
88
+ const pcmd = Array.isArray(planned.command) ? planned.command[0] : undefined;
89
+ const acmd = Array.isArray(alternative.command) ? alternative.command[0] : undefined;
90
+ if (pcmd !== acmd) return bumpByUrgency('medium', llmUrgencyHint);
91
+ } else if (planned.type === 'git_op') {
92
+ if (planned.op !== alternative.op) return bumpByUrgency('medium', llmUrgencyHint);
93
+ }
94
+
95
+ const argDiffs = diffArgs(planned.args, alternative.args);
96
+ if (argDiffs.length === 0) {
97
+ // No arg differences at all — only metadata changed; treat as low.
98
+ return bumpByUrgency('low', llmUrgencyHint);
99
+ }
100
+ const allWhitelisted = argDiffs.every((k) => LOW_RISK_ARG_WHITELIST.has(k));
101
+ return bumpByUrgency(allWhitelisted ? 'low' : 'medium', llmUrgencyHint);
102
+ }
103
+
104
+ module.exports = {
105
+ classifyImpact,
106
+ diffArgs,
107
+ LOW_RISK_ARG_WHITELIST: Array.from(LOW_RISK_ARG_WHITELIST),
108
+ };
@@ -0,0 +1,155 @@
1
+ // land.js — orchestrator helper for `merge_strategy: land_as_you_go`.
2
+ //
3
+ // This module composes existing scripts (stack-snapshot.js +
4
+ // land-this-pr.js) into a step plan the autopilot CLI executes after
5
+ // STORY_DONE. It does NOT define a BMad workflow — BMad's domain is
6
+ // story creation, dev, review, retrospective. This is orchestrator
7
+ // plumbing for the git layer.
8
+ //
9
+ // Pure: planLand(state, profile) → { steps, blocking_user_prompt? }
10
+ //
11
+ // Step shape mirrors git-plan.js: { args, description, retry? }. The CLI
12
+ // executes steps sequentially via execFileSync.
13
+
14
+ 'use strict';
15
+
16
+ const path = require('node:path');
17
+
18
+ function escapeRe(s) {
19
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
20
+ }
21
+
22
+ // planLand(state, profile, options) → { steps, halt?, prompt? }
23
+ // state.user_branch / state.story_key / state.current_epic → branch identity
24
+ // profile.land_when → no_wait | ci_pass | ci_and_review
25
+ // profile.land_wait_minutes → polling budget
26
+ // profile.squash_on_merge → forwarded to land-this-pr.js
27
+ // options.scriptsDir → absolute path to _Sprintpilot/scripts
28
+ // options.snapshotPath → tmp file path the snapshot will be written to
29
+ // options.branch → resolved branch name (from git-plan.branchName)
30
+ // options.platform → 'github' | 'gitlab' | 'git_only'
31
+ function planLand(state, profile, options) {
32
+ if (!state || !state.story_key) {
33
+ throw new Error('planLand: state.story_key required');
34
+ }
35
+ if (!profile) throw new Error('planLand: profile required');
36
+ if (!options || !options.scriptsDir || !options.snapshotPath) {
37
+ throw new Error('planLand: options.scriptsDir + snapshotPath required');
38
+ }
39
+
40
+ const branch = options.branch;
41
+ if (!branch) throw new Error('planLand: options.branch required');
42
+
43
+ const baseBranch = profile.base_branch || 'main';
44
+ const landWhen = profile.land_when || 'ci_pass';
45
+ const waitMinutes = Number.isFinite(profile.land_wait_minutes) ? profile.land_wait_minutes : 30;
46
+ const squash = !!profile.squash_on_merge;
47
+
48
+ const stackSnapshot = path.join(options.scriptsDir, 'stack-snapshot.js');
49
+ const landThisPr = path.join(options.scriptsDir, 'land-this-pr.js');
50
+ const createPr = path.join(options.scriptsDir, 'create-pr.js');
51
+
52
+ const steps = [];
53
+
54
+ // Step 1: capture the current stack snapshot so land-this-pr can reason
55
+ // about the active PR + remaining branches.
56
+ steps.push({
57
+ args: [
58
+ 'node',
59
+ stackSnapshot,
60
+ '--project-root',
61
+ options.projectRoot || '.',
62
+ '--base-branch',
63
+ baseBranch,
64
+ '--active-branch',
65
+ branch,
66
+ '--story-key',
67
+ state.story_key,
68
+ '--output',
69
+ options.snapshotPath,
70
+ ],
71
+ description: `snapshot stack for ${branch}`,
72
+ });
73
+
74
+ // Step 2: wait for CI / review depending on land_when.
75
+ if (landWhen === 'ci_pass' || landWhen === 'ci_and_review') {
76
+ const checkArgs = [
77
+ 'node',
78
+ createPr,
79
+ '--mode',
80
+ 'checks',
81
+ '--branch',
82
+ branch,
83
+ '--wait-minutes',
84
+ String(waitMinutes),
85
+ ];
86
+ if (landWhen === 'ci_and_review') {
87
+ checkArgs.push('--require-approved-review');
88
+ }
89
+ steps.push({
90
+ args: checkArgs,
91
+ description: `wait for ${landWhen === 'ci_and_review' ? 'CI green + approved review' : 'CI green'} (max ${waitMinutes}m)`,
92
+ retry: { attempts: 1, on: 'never' },
93
+ });
94
+ }
95
+
96
+ // Step 3: generate the merge plan via land-this-pr.js.
97
+ const landArgs = [
98
+ 'node',
99
+ landThisPr,
100
+ '--snapshot',
101
+ options.snapshotPath,
102
+ '--base',
103
+ baseBranch,
104
+ ];
105
+ if (squash) landArgs.push('--squash');
106
+ steps.push({
107
+ args: landArgs,
108
+ description: `land PR for ${branch} onto ${baseBranch}${squash ? ' (squash)' : ''}`,
109
+ });
110
+
111
+ return { steps, branch, base: baseBranch, land_when: landWhen };
112
+ }
113
+
114
+ // planRebaseRecovery(state, profile, options) → { steps }
115
+ // Called when a land step hits `git merge --ff-only` failure. Attempts
116
+ // an auto-rebase of the story branch onto latest origin/<base>; on
117
+ // rebase conflict the CLI emits a user_prompt halt (caller's job).
118
+ function planRebaseRecovery(state, profile, options) {
119
+ if (!options || !options.branch) throw new Error('planRebaseRecovery: branch required');
120
+ const baseBranch = profile.base_branch || 'main';
121
+ return {
122
+ steps: [
123
+ { args: ['git', 'fetch', 'origin'], description: 'fetch latest base' },
124
+ {
125
+ args: ['git', 'rebase', `origin/${baseBranch}`, options.branch],
126
+ description: `rebase ${options.branch} onto origin/${baseBranch}`,
127
+ },
128
+ ],
129
+ on_conflict: {
130
+ type: 'user_prompt',
131
+ reason: 'rebase_conflict',
132
+ prompt: `Rebase conflict on ${options.branch} against origin/${baseBranch}. Resolve manually then resume autopilot — it will retry the land step.`,
133
+ },
134
+ };
135
+ }
136
+
137
+ // Classify a stderr blob from `git rebase` / `git merge` as a conflict or
138
+ // a transient/other failure. Used by the CLI to decide whether to halt
139
+ // or to retry.
140
+ function isRebaseConflict(stderrText) {
141
+ if (typeof stderrText !== 'string') return false;
142
+ const conflictMarkers = [
143
+ /^CONFLICT \(.*\):/m,
144
+ /\bAutomatic merge failed; fix conflicts/i,
145
+ /\bCould not apply/i,
146
+ ];
147
+ return conflictMarkers.some((re) => re.test(stderrText));
148
+ }
149
+
150
+ module.exports = {
151
+ planLand,
152
+ planRebaseRecovery,
153
+ isRebaseConflict,
154
+ escapeRe,
155
+ };
@@ -0,0 +1,99 @@
1
+ // parallel-batch.js — plan a parallel batch of child Actions.
2
+ //
3
+ // The orchestrator emits a `parallel_batch` action when (and only when):
4
+ // - profile.parallel_stories === true
5
+ // - host_supports_parallel === true
6
+ // - There are independent stories ready (per the inferred DAG)
7
+ //
8
+ // This module is pure. It does NOT spawn subprocesses; it produces the
9
+ // batch structure the CLI/host dispatcher consumes.
10
+ //
11
+ // Shape returned:
12
+ // {
13
+ // type: 'parallel_batch',
14
+ // concurrency: number, // capped at profile.max_parallel_stories
15
+ // children: ChildAction[], // each a regular Action (invoke_skill, run_script, ...)
16
+ // fallback: 'sequential', // hosts without parallel support degrade to sequential
17
+ // }
18
+
19
+ 'use strict';
20
+
21
+ function planBatch(childActions, profile, hostSupportsParallel) {
22
+ if (!Array.isArray(childActions)) {
23
+ throw new Error('planBatch: childActions must be an array');
24
+ }
25
+ if (childActions.length === 0) {
26
+ return { type: 'parallel_batch', concurrency: 0, children: [], fallback: 'sequential' };
27
+ }
28
+
29
+ const requested = childActions.length;
30
+ const cap = Math.max(1, Math.min(profile?.max_parallel_stories ?? 2, requested));
31
+ const allowed = !!(profile?.parallel_stories && hostSupportsParallel);
32
+
33
+ if (!allowed) {
34
+ // Degrade: emit the same children as a sequence (concurrency=1).
35
+ return {
36
+ type: 'parallel_batch',
37
+ concurrency: 1,
38
+ children: childActions,
39
+ fallback: 'sequential',
40
+ degraded: true,
41
+ degraded_reason: !profile?.parallel_stories
42
+ ? 'profile.parallel_stories=false'
43
+ : 'host_supports_parallel=false',
44
+ };
45
+ }
46
+
47
+ return {
48
+ type: 'parallel_batch',
49
+ concurrency: cap,
50
+ children: childActions,
51
+ fallback: 'sequential',
52
+ };
53
+ }
54
+
55
+ // classifyResults — given the per-child results, compute an aggregate
56
+ // signal for the orchestrator to record at batch boundary.
57
+ // children: { id, status, output?, reason? }[]
58
+ // Aggregate semantics:
59
+ // - all success → 'success'
60
+ // - any block → 'blocked' with kind unknown + user_input_needed=true
61
+ // - any failure → 'failure' (recoverable=true iff every failure was recoverable)
62
+ // - else (mixed) → 'failure' (recoverable=false) — surfaces to user
63
+ function classifyResults(children) {
64
+ if (!Array.isArray(children) || children.length === 0) {
65
+ return { status: 'success', count: 0 };
66
+ }
67
+ const statuses = children.map((c) => c.status);
68
+ if (statuses.every((s) => s === 'success')) {
69
+ return { status: 'success', count: statuses.length };
70
+ }
71
+ if (statuses.some((s) => s === 'blocked')) {
72
+ return {
73
+ status: 'blocked',
74
+ blocker_kind: 'unknown',
75
+ user_input_needed: true,
76
+ details: 'parallel batch had a blocked child',
77
+ children_blocked: children.filter((c) => c.status === 'blocked').map((c) => c.id),
78
+ };
79
+ }
80
+ const failures = children.filter((c) => c.status === 'failure');
81
+ if (failures.length > 0) {
82
+ const allRecoverable = failures.every((c) => c.recoverable !== false);
83
+ return {
84
+ status: 'failure',
85
+ reason: `${failures.length}/${children.length} children failed`,
86
+ diagnosis: failures.map((c) => c.reason || 'unknown').join('; '),
87
+ recoverable: allRecoverable,
88
+ };
89
+ }
90
+ // Mixed (none of the above) — surface as non-recoverable for safety.
91
+ return {
92
+ status: 'failure',
93
+ reason: `unexpected mixed statuses: ${statuses.join(',')}`,
94
+ diagnosis: 'classifyResults could not aggregate',
95
+ recoverable: false,
96
+ };
97
+ }
98
+
99
+ module.exports = { planBatch, classifyResults };
@@ -0,0 +1,167 @@
1
+ // profile-rules.js — typed Profile, flat→typed adapter, mid-sprint escalation.
2
+ //
3
+ // Pure module. No I/O. Consumes the flat tree produced by resolve-profile.js
4
+ // and produces a typed Profile object the orchestrator can consume directly.
5
+ //
6
+ // Mid-sprint escalation honors AGENTS.md:
7
+ // "if quick-dev's tests fail or its Classify severity is `high`, the
8
+ // autopilot escalates the session (session-scoped only — never written
9
+ // back to config) to `full` flow"
10
+ //
11
+ // See plan: BMad sequence § Profile rules.
12
+
13
+ 'use strict';
14
+
15
+ const VALID_PROFILE_NAMES = ['nano', 'small', 'medium', 'large', 'legacy'];
16
+ const VALID_FLOWS = ['full', 'quick'];
17
+ const VALID_RETRO_MODES = ['auto', 'stop', 'skip'];
18
+ const VALID_GRANULARITIES = ['story', 'epic'];
19
+ const VALID_MERGE_STRATEGIES = ['stacked', 'land_as_you_go'];
20
+ const VALID_LAND_WHENS = ['no_wait', 'ci_pass', 'ci_and_review'];
21
+
22
+ // Per-profile defaults for fields the orchestrator manages directly
23
+ // (verify_reject_budget, retry_budget_per_action). These are orchestrator-
24
+ // internal — not in the shipping YAML — so they're seeded here.
25
+ const ORCHESTRATOR_DEFAULTS_BY_PROFILE = {
26
+ nano: { retry_budget_per_action: 1, verify_reject_budget: 2 },
27
+ small: { retry_budget_per_action: 2, verify_reject_budget: 3 },
28
+ medium: { retry_budget_per_action: 2, verify_reject_budget: 3 },
29
+ large: { retry_budget_per_action: 3, verify_reject_budget: 3 },
30
+ legacy: { retry_budget_per_action: 2, verify_reject_budget: 3 },
31
+ };
32
+
33
+ function get(obj, dottedKey) {
34
+ if (obj === null || obj === undefined) return undefined;
35
+ const parts = dottedKey.split('.');
36
+ let cur = obj;
37
+ for (const p of parts) {
38
+ if (cur === null || cur === undefined || typeof cur !== 'object') return undefined;
39
+ cur = cur[p];
40
+ }
41
+ return cur;
42
+ }
43
+
44
+ function coerceBool(v, fallback) {
45
+ if (typeof v === 'boolean') return v;
46
+ if (v === 'true') return true;
47
+ if (v === 'false') return false;
48
+ return fallback;
49
+ }
50
+
51
+ function coerceInt(v, fallback) {
52
+ if (typeof v === 'number' && Number.isFinite(v)) return v;
53
+ if (typeof v === 'string' && /^-?\d+$/.test(v)) return Number.parseInt(v, 10);
54
+ return fallback;
55
+ }
56
+
57
+ function coerceEnum(v, allowed, fallback) {
58
+ if (typeof v === 'string' && allowed.includes(v)) return v;
59
+ return fallback;
60
+ }
61
+
62
+ // Convert the flat resolved-config tree (from resolve-profile.js) into a
63
+ // typed Profile. Missing keys fall back to documented defaults.
64
+ function flatToProfile(resolved, profileName) {
65
+ const name = VALID_PROFILE_NAMES.includes(profileName) ? profileName : 'medium';
66
+ const orch = ORCHESTRATOR_DEFAULTS_BY_PROFILE[name];
67
+
68
+ return {
69
+ name,
70
+ implementation_flow: coerceEnum(
71
+ get(resolved, 'autopilot.implementation_flow'),
72
+ VALID_FLOWS,
73
+ 'full',
74
+ ),
75
+ session_story_limit: coerceInt(get(resolved, 'autopilot.session_story_limit'), 3),
76
+ retrospective_mode: coerceEnum(
77
+ get(resolved, 'autopilot.retrospective_mode'),
78
+ VALID_RETRO_MODES,
79
+ 'auto',
80
+ ),
81
+ coalesce_state_writes: coerceBool(get(resolved, 'autopilot.coalesce_state_writes'), false),
82
+ conditional_boot_work: coerceBool(get(resolved, 'autopilot.conditional_boot_work'), false),
83
+ granularity: coerceEnum(get(resolved, 'git.granularity'), VALID_GRANULARITIES, 'story'),
84
+ worktree_enabled: coerceBool(get(resolved, 'git.worktree.enabled'), true),
85
+ squash_on_merge: coerceBool(get(resolved, 'git.squash_on_merge'), false),
86
+ reuse_user_branch: coerceBool(get(resolved, 'git.reuse_user_branch'), false),
87
+ merge_strategy: coerceEnum(
88
+ get(resolved, 'git.merge_strategy'),
89
+ VALID_MERGE_STRATEGIES,
90
+ 'stacked',
91
+ ),
92
+ land_when: coerceEnum(get(resolved, 'git.land_when'), VALID_LAND_WHENS, 'ci_pass'),
93
+ land_wait_minutes: coerceInt(get(resolved, 'git.land_wait_minutes'), 30),
94
+ base_branch:
95
+ typeof get(resolved, 'git.base_branch') === 'string'
96
+ ? get(resolved, 'git.base_branch')
97
+ : 'main',
98
+ branch_prefix:
99
+ typeof get(resolved, 'git.branch_prefix') === 'string'
100
+ ? get(resolved, 'git.branch_prefix')
101
+ : 'story/',
102
+ parallel_stories: coerceBool(get(resolved, 'ma.parallel_stories'), false),
103
+ max_parallel_stories: coerceInt(get(resolved, 'ma.max_parallel_stories'), 2),
104
+ fallback_on_tests_fail: coerceBool(
105
+ get(resolved, 'autopilot.nano.fallback_on_tests_fail'),
106
+ name === 'nano',
107
+ ),
108
+ fallback_on_quick_dev_high_severity: coerceBool(
109
+ get(resolved, 'autopilot.nano.fallback_on_quick_dev_high_severity'),
110
+ name === 'nano',
111
+ ),
112
+ fallback_target: coerceEnum(
113
+ get(resolved, 'autopilot.nano.fallback_target'),
114
+ ['small', 'medium', 'large'],
115
+ 'small',
116
+ ),
117
+ retry_budget_per_action: orch.retry_budget_per_action,
118
+ verify_reject_budget: orch.verify_reject_budget,
119
+ };
120
+ }
121
+
122
+ // Session-scoped mid-sprint escalation. Called when a nano `bmad-quick-dev`
123
+ // returns failure indicators. Returns a NEW Profile object — never mutates.
124
+ // Returns the original profile unchanged when escalation conditions are not met
125
+ // or the profile is not nano.
126
+ function escalateOnFailure(profile, signalOutput) {
127
+ if (!profile || profile.name !== 'nano') return profile;
128
+ if (!signalOutput || typeof signalOutput !== 'object') return profile;
129
+
130
+ const testsFailed =
131
+ typeof signalOutput.tests_failed === 'number' && signalOutput.tests_failed > 0;
132
+ const highSeverity = signalOutput.severity === 'high';
133
+
134
+ const shouldEscalate =
135
+ (testsFailed && profile.fallback_on_tests_fail) ||
136
+ (highSeverity && profile.fallback_on_quick_dev_high_severity);
137
+
138
+ if (!shouldEscalate) return profile;
139
+
140
+ const targetName = profile.fallback_target || 'small';
141
+ const targetDefaults =
142
+ ORCHESTRATOR_DEFAULTS_BY_PROFILE[targetName] || ORCHESTRATOR_DEFAULTS_BY_PROFILE.small;
143
+
144
+ return {
145
+ ...profile,
146
+ name: targetName,
147
+ implementation_flow: 'full',
148
+ retry_budget_per_action: targetDefaults.retry_budget_per_action,
149
+ verify_reject_budget: targetDefaults.verify_reject_budget,
150
+ fallback_on_tests_fail: false,
151
+ fallback_on_quick_dev_high_severity: false,
152
+ escalated_from: 'nano',
153
+ escalation_reason: testsFailed ? 'tests_failed' : 'high_severity',
154
+ };
155
+ }
156
+
157
+ module.exports = {
158
+ VALID_PROFILE_NAMES,
159
+ VALID_FLOWS,
160
+ VALID_RETRO_MODES,
161
+ VALID_GRANULARITIES,
162
+ VALID_MERGE_STRATEGIES,
163
+ VALID_LAND_WHENS,
164
+ ORCHESTRATOR_DEFAULTS_BY_PROFILE,
165
+ flatToProfile,
166
+ escalateOnFailure,
167
+ };
@@ -0,0 +1,95 @@
1
+ // report.js — session report markdown generator.
2
+ //
3
+ // Pure. Consumes a state snapshot + the action ledger and produces a
4
+ // human-readable markdown report. Used by `autopilot report` and as the
5
+ // handoff message when `session_story_limit` is hit.
6
+
7
+ 'use strict';
8
+
9
+ const { STATES } = require('./state-machine');
10
+
11
+ function header(state) {
12
+ return [
13
+ '# Autopilot Session Report',
14
+ '',
15
+ `**Current story:** ${state.current_story || '(none)'}`,
16
+ `**Current phase:** ${state.current_bmad_step || '(none)'}`,
17
+ `**Sprint complete:** ${!!state.sprint_is_complete}`,
18
+ `**Last updated:** ${state.last_updated || '(unknown)'}`,
19
+ ].join('\n');
20
+ }
21
+
22
+ function ledgerSummary(entries) {
23
+ const counts = Object.create(null);
24
+ for (const e of entries) counts[e.kind] = (counts[e.kind] || 0) + 1;
25
+ const lines = ['', '## Ledger summary', ''];
26
+ for (const k of Object.keys(counts).sort()) {
27
+ lines.push(`- ${k}: ${counts[k]}`);
28
+ }
29
+ return lines.join('\n');
30
+ }
31
+
32
+ function recentActions(entries, limit = 10) {
33
+ const actionEntries = entries.filter((e) => e.kind === 'action_emitted').slice(-limit);
34
+ const lines = ['', `## Last ${actionEntries.length} actions`, ''];
35
+ for (const e of actionEntries) {
36
+ const a = e.action || {};
37
+ const summary =
38
+ a.type === 'invoke_skill'
39
+ ? `invoke_skill ${a.skill}`
40
+ : a.type === 'run_script'
41
+ ? `run_script ${a.command ? a.command[0] : '?'}`
42
+ : a.type === 'git_op'
43
+ ? `git_op ${a.op}`
44
+ : a.type;
45
+ lines.push(`- [${e.ts}] ${e.phase} → ${summary}`);
46
+ }
47
+ return lines.join('\n');
48
+ }
49
+
50
+ function recentDecisions(entries, limit = 5) {
51
+ const dec = entries.filter((e) => e.kind === 'decisions_appended').slice(-limit);
52
+ if (dec.length === 0) return '';
53
+ const lines = ['', `## Recent decisions (${dec.length})`, ''];
54
+ for (const e of dec) {
55
+ lines.push(`- [${e.ts}] story=${e.story} phase=${e.phase} ids=${(e.ids || []).join(',')}`);
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+
60
+ function blockers(entries) {
61
+ const halts = entries.filter((e) => e.kind === 'halt').slice(-3);
62
+ if (halts.length === 0) return '';
63
+ const lines = ['', '## Recent halts', ''];
64
+ for (const e of halts) {
65
+ lines.push(`- [${e.ts}] phase=${e.phase} reason=${e.reason || '(none)'}`);
66
+ }
67
+ return lines.join('\n');
68
+ }
69
+
70
+ function nextActionHint(state, profile) {
71
+ const phase = state.current_bmad_step;
72
+ if (state.sprint_is_complete && phase !== STATES.SPRINT_FINALIZE_PENDING) {
73
+ return '\n## Next action\n\nSprint is complete. Next `autopilot start` will run finalize in a fresh context.';
74
+ }
75
+ if (phase === STATES.SPRINT_FINALIZE_PENDING) {
76
+ return '\n## Next action\n\nFinalize step pending. Run `/sprint-autopilot-on` in a fresh session to complete.';
77
+ }
78
+ return `\n## Next action\n\nRun \`autopilot next\` to emit the action for phase=${phase} on profile=${profile?.name ?? '?'}.`;
79
+ }
80
+
81
+ function render(state, entries, profile) {
82
+ const safeEntries = Array.isArray(entries) ? entries : [];
83
+ return [
84
+ header(state || {}),
85
+ ledgerSummary(safeEntries),
86
+ recentActions(safeEntries),
87
+ recentDecisions(safeEntries),
88
+ blockers(safeEntries),
89
+ nextActionHint(state || {}, profile || {}),
90
+ ]
91
+ .filter(Boolean)
92
+ .join('\n');
93
+ }
94
+
95
+ module.exports = { render, header, ledgerSummary, recentActions, recentDecisions, blockers, nextActionHint };