@ikunin/sprintpilot 1.0.5 → 2.0.4
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/_Sprintpilot/Sprintpilot.md +14 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
- package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
- package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/modules/ma/config.yaml +42 -0
- package/_Sprintpilot/scripts/agent-adapter.js +247 -0
- package/_Sprintpilot/scripts/cached-read.js +238 -0
- package/_Sprintpilot/scripts/check-prereqs.js +139 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
- package/_Sprintpilot/scripts/git-portable.js +219 -0
- package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
- package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
- package/_Sprintpilot/scripts/log-timing.js +360 -0
- package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
- package/_Sprintpilot/scripts/merge-shards.js +339 -0
- package/_Sprintpilot/scripts/preflight-merge.js +235 -0
- package/_Sprintpilot/scripts/resolve-dag.js +559 -0
- package/_Sprintpilot/scripts/resolve-profile.js +355 -0
- package/_Sprintpilot/scripts/state-shard.js +602 -0
- package/_Sprintpilot/scripts/submodule-lock.js +130 -0
- package/_Sprintpilot/scripts/summarize-timings.js +362 -0
- package/_Sprintpilot/scripts/sync-status.js +13 -0
- package/_Sprintpilot/scripts/with-retry.js +145 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +572 -42
- package/bin/sprintpilot.js +4 -0
- package/lib/commands/install.js +157 -1
- package/package.json +1 -1
|
@@ -31,8 +31,9 @@ Edit `_Sprintpilot/modules/autopilot/config.yaml`:
|
|
|
31
31
|
|
|
32
32
|
| Setting | Default | Values | Purpose |
|
|
33
33
|
|---------|---------|--------|---------|
|
|
34
|
-
| `autopilot.session_story_limit` | `3` | integer ≥ 0 | Stories fully implemented per autopilot run before checkpoint. `0` = unlimited. |
|
|
34
|
+
| `autopilot.session_story_limit` | `3` (nano: `5`) | integer ≥ 0 | Stories fully implemented per autopilot run before checkpoint. `0` = unlimited. Retuned in 2.0.1 after context-rot exposure on longer sessions; nano is cheaper per story so fits a higher cap. |
|
|
35
35
|
| `autopilot.retrospective_mode` | `auto` | `auto` / `stop` / `skip` | How epic-end retrospectives are handled (see below). |
|
|
36
|
+
| `autopilot.auto_infer_dependencies` | `true` (nano + legacy: `false`) | bool | 2.0.2 — autopilot session infers inter-story DAG once after `bmad-sprint-planning` and writes `_Sprintpilot/sprints/dependencies.yaml`. Hand-authored sidecars (no `# AUTO-INFERRED` marker) are detected and respected. See "Dependency Inference" below. |
|
|
36
37
|
|
|
37
38
|
`retrospective_mode` options:
|
|
38
39
|
- **`auto`** *(default)* — autopilot writes a deterministic retrospective artifact from `sprint-status.yaml` + `decision-log.yaml`, then continues. Single pass, no external skill call, safe under every CLI.
|
|
@@ -41,6 +42,18 @@ Edit `_Sprintpilot/modules/autopilot/config.yaml`:
|
|
|
41
42
|
|
|
42
43
|
Both settings are prompted during `sprintpilot install` (interactive mode) with existing values as defaults, so reinstalls preserve your choices.
|
|
43
44
|
|
|
45
|
+
### Dependency Inference
|
|
46
|
+
|
|
47
|
+
After `bmad-sprint-planning` completes, the autopilot session reads `epics.md`, `architecture.md`, and `sprint-status.yaml` and emits a JSON dependency envelope. `_Sprintpilot/scripts/infer-dependencies.js` validates it (schema, unknown keys, self-deps, cross-epic edges, missing rationales, cycles) and writes `_Sprintpilot/sprints/dependencies.yaml` with an `# AUTO-INFERRED` marker header. The script never calls an LLM — the autopilot session is the inference caller.
|
|
48
|
+
|
|
49
|
+
This unblocks parallel story dispatch (`parallel_stories: true` + `dispatch-layer.js`) without requiring users to discover and hand-author the sidecar. Hand-authored files (no marker) are respected silently. Failure modes (invalid JSON, validation errors) log and continue — `resolve-dag.js` falls back to its safe linear `ordering` strategy on dispatch.
|
|
50
|
+
|
|
51
|
+
### Mandatory fresh-context finalize
|
|
52
|
+
|
|
53
|
+
Independent of `session_story_limit`, the autopilot forces an extra session at end-of-sprint. When step 2 detects all stories are done, it writes `current_bmad_step = sprint-finalize-pending` to the state file and halts — it does **not** run step 10 (cleanup) in that session. The next `/sprint-autopilot-on` invocation reads the marker in step 1 and jumps directly to step 10 with a clean context window, where seven CRITICAL deterministic script calls run the cleanup (checkbox marking, worktree removal, lock release, artifact commit, sprint-complete state, verification, state-file delete).
|
|
54
|
+
|
|
55
|
+
This behavior is not configurable: it's a mitigation for late-session instruction decay that was reliably dropping cleanup actions in long single-session runs. The extra session is short (typically ~60-100 turns, under $2). See `_Sprintpilot/skills/sprint-autopilot-on/workflow.md` step 2 and step 10 for the exact protocol.
|
|
56
|
+
|
|
44
57
|
---
|
|
45
58
|
|
|
46
59
|
## Full skill reference by lifecycle phase
|
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
# Controls /sprint-autopilot-on session behavior.
|
|
3
3
|
|
|
4
4
|
autopilot:
|
|
5
|
+
# Adaptive Process Scaling profile.
|
|
6
|
+
#
|
|
7
|
+
# nano — toy / tutorial / learning project, solo, small codebase.
|
|
8
|
+
# Routes through bmad-quick-dev one-shot (PR 4+). Fastest.
|
|
9
|
+
# small — MVP / internal tool / prototype, solo or 1–2 devs.
|
|
10
|
+
# medium — team product with real users. Default, recommended.
|
|
11
|
+
# large — production system, compliance / uptime stakes. Full
|
|
12
|
+
# reconciliation, interactive retrospectives, tighter
|
|
13
|
+
# checkpoints, parallelism on by default.
|
|
14
|
+
# legacy — rollback to v1.0.5 behavior. No v2 optimizations active.
|
|
15
|
+
# Pinned by version_pinned in profiles/legacy.yaml so future
|
|
16
|
+
# changes cannot silently affect legacy users.
|
|
17
|
+
#
|
|
18
|
+
# Profile defaults live in _Sprintpilot/modules/autopilot/profiles/*.yaml.
|
|
19
|
+
# Override specific knobs here or in git/ma config.yaml — user overrides
|
|
20
|
+
# always win over profile defaults. See docs/adaptive-process-scaling.md.
|
|
21
|
+
#
|
|
22
|
+
# Missing key defaults to 'medium' (matches v1.0.5 behavior byte-for-byte).
|
|
23
|
+
# Upgrading v1 installs don't need to set this; new installs get it from
|
|
24
|
+
# the installer prompt or `sprintpilot install --profile <name>`.
|
|
25
|
+
complexity_profile: medium
|
|
26
|
+
|
|
5
27
|
# Maximum stories to FULLY IMPLEMENT in a single autopilot session.
|
|
6
28
|
#
|
|
7
29
|
# "Fully implement" means the complete per-story cycle:
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Adaptive Process Scaling — base defaults for all non-legacy profiles.
|
|
2
|
+
# Per-profile YAML files override only the keys they want to change.
|
|
3
|
+
# See docs/adaptive-process-scaling.md and docs/implementation-plan.md.
|
|
4
|
+
|
|
5
|
+
name: _base
|
|
6
|
+
version_pinned: null
|
|
7
|
+
|
|
8
|
+
autopilot:
|
|
9
|
+
implementation_flow: full # full | quick
|
|
10
|
+
# Proactive handoff threshold. Lowered from 5 → 3 because context rot
|
|
11
|
+
# (late-session instruction decay) caused the LLM to skip late-step
|
|
12
|
+
# actions in longer single-session runs. Keeping sessions short enough
|
|
13
|
+
# that every step 10 CRITICAL action fits comfortably inside the prompt
|
|
14
|
+
# budget is cheaper than paying for skipped cleanup actions.
|
|
15
|
+
session_story_limit: 3
|
|
16
|
+
retrospective_mode: auto
|
|
17
|
+
phase_timings: true # M0, PR 2 — enabled by default (legacy overrides to false)
|
|
18
|
+
coalesce_state_writes: true # M3, PR 6 — batch non-critical writes, flush at story boundary
|
|
19
|
+
conditional_boot_work: true # M4, PR 7 — skip health-check + branch reconciliation on a clean repo
|
|
20
|
+
cache_shared_reads: true # M5, PR 8 — memoize sprint-status / git-status / decision-log reads per loop iteration
|
|
21
|
+
# 2.0.2 — autopilot session infers inter-story DAG once after bmad-sprint-planning
|
|
22
|
+
# and writes _Sprintpilot/sprints/dependencies.yaml. Hand-authored sidecars
|
|
23
|
+
# (no AUTO-INFERRED marker) are detected and respected.
|
|
24
|
+
auto_infer_dependencies: true
|
|
25
|
+
|
|
26
|
+
git:
|
|
27
|
+
granularity: story # story | epic
|
|
28
|
+
worktree:
|
|
29
|
+
enabled: true
|
|
30
|
+
squash_on_merge: false
|
|
31
|
+
|
|
32
|
+
ma:
|
|
33
|
+
parallel_stories: false
|
|
34
|
+
max_parallel_stories: 2
|
|
35
|
+
# PR 11 intra-epic parallelism safety rails. Honored by dispatch-layer.js
|
|
36
|
+
# when a host declares parallel support (see agent-adapter.js).
|
|
37
|
+
min_epic_duration_for_parallel_sec: 300
|
|
38
|
+
baseline_story_duration_sec: 180
|
|
39
|
+
max_consecutive_conflicts: 2
|
|
40
|
+
effective_parallel_floor: 1
|
|
41
|
+
parallel_epics: false
|
|
42
|
+
# PR 3: state_sharding semantics — auto = shard when parallelism is active,
|
|
43
|
+
# always = shard unconditionally (for forcing the code path in tests),
|
|
44
|
+
# never = write authoritative files directly (pre-PR behavior, rollback).
|
|
45
|
+
state_sharding: auto
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Large profile — production system, compliance / uptime stakes.
|
|
2
|
+
# Parallelism on by default; full reconciliation every boot; tighter
|
|
3
|
+
# session checkpointing; interactive retrospectives.
|
|
4
|
+
|
|
5
|
+
name: large
|
|
6
|
+
|
|
7
|
+
autopilot:
|
|
8
|
+
session_story_limit: 3
|
|
9
|
+
retrospective_mode: stop
|
|
10
|
+
conditional_boot_work: false # always run full reconciliation
|
|
11
|
+
|
|
12
|
+
ma:
|
|
13
|
+
parallel_stories: true
|
|
14
|
+
max_parallel_stories: 3
|
|
15
|
+
# PR 11 safety rails (inherited defaults via _base, overridden here).
|
|
16
|
+
max_consecutive_conflicts: 2
|
|
17
|
+
effective_parallel_floor: 1
|
|
18
|
+
# PR 12 cross-epic parallelism stays OFF even on large — experimental.
|
|
19
|
+
# Explicitly pinned (not inherited) so future _base changes can't silently
|
|
20
|
+
# flip it on for compliance-conscious users.
|
|
21
|
+
parallel_epics: false
|
|
22
|
+
state_sharding: always
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Legacy profile — rollback to pre-v2 (v1.0.5) behavior.
|
|
2
|
+
#
|
|
3
|
+
# Explicitly duplicates every setting that was default in v1.0.5 so future
|
|
4
|
+
# changes to _base.yaml cannot silently affect legacy behavior. This is
|
|
5
|
+
# the forward-compatibility guarantee: legacy produces a file-and-schema
|
|
6
|
+
# superset match against the v1.0.5 snapshot.
|
|
7
|
+
#
|
|
8
|
+
# Unlike the other profile YAMLs, legacy does NOT extend _base. The
|
|
9
|
+
# resolver detects version_pinned and skips the base overlay.
|
|
10
|
+
|
|
11
|
+
name: legacy
|
|
12
|
+
version_pinned: "v1.0.5"
|
|
13
|
+
|
|
14
|
+
autopilot:
|
|
15
|
+
implementation_flow: full
|
|
16
|
+
session_story_limit: 3
|
|
17
|
+
retrospective_mode: auto
|
|
18
|
+
phase_timings: false
|
|
19
|
+
coalesce_state_writes: false
|
|
20
|
+
conditional_boot_work: false
|
|
21
|
+
cache_shared_reads: false
|
|
22
|
+
# Legacy is byte-for-byte v1.0.5; no v2 LLM calls.
|
|
23
|
+
auto_infer_dependencies: false
|
|
24
|
+
|
|
25
|
+
git:
|
|
26
|
+
granularity: story
|
|
27
|
+
worktree:
|
|
28
|
+
enabled: true
|
|
29
|
+
squash_on_merge: false
|
|
30
|
+
|
|
31
|
+
ma:
|
|
32
|
+
parallel_stories: false
|
|
33
|
+
max_parallel_stories: 2
|
|
34
|
+
parallel_epics: false
|
|
35
|
+
state_sharding: never
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Nano profile — toy / tutorial / learning projects, solo.
|
|
2
|
+
# Routes each story through bmad-quick-dev (one-shot Implement → Review
|
|
3
|
+
# → Classify → Commit). Drops worktrees, per-story PRs, retrospectives,
|
|
4
|
+
# and session checkpoints. Quality gates preserved via quick-dev's
|
|
5
|
+
# internal review step.
|
|
6
|
+
#
|
|
7
|
+
# Behavior is gated on PR 4 (nano routing) landing. Until then this
|
|
8
|
+
# profile is configurable but has no runtime effect.
|
|
9
|
+
|
|
10
|
+
name: nano
|
|
11
|
+
|
|
12
|
+
autopilot:
|
|
13
|
+
implementation_flow: quick
|
|
14
|
+
# Nano's quick-dev one-shot is cheaper per story, so nano can run longer
|
|
15
|
+
# sessions — but unlimited (0) runs into the same context-rot failure
|
|
16
|
+
# mode as the full flow. 5 stories per session keeps step 10's CRITICAL
|
|
17
|
+
# actions reliably in-context without paying for excessive session boots.
|
|
18
|
+
session_story_limit: 5
|
|
19
|
+
# Nano runs at epic granularity with parallelism off — auto-inferring a
|
|
20
|
+
# story DAG would just burn one LLM call per sprint and never get used.
|
|
21
|
+
auto_infer_dependencies: false
|
|
22
|
+
retrospective_mode: skip
|
|
23
|
+
# Nano safety net — if a story fails under quick-dev, escalate to the
|
|
24
|
+
# small profile for the remainder of the session (scope: session only,
|
|
25
|
+
# never written back to config.yaml).
|
|
26
|
+
nano:
|
|
27
|
+
fallback_on_tests_fail: true
|
|
28
|
+
fallback_on_quick_dev_high_severity: true
|
|
29
|
+
fallback_target: small
|
|
30
|
+
|
|
31
|
+
git:
|
|
32
|
+
granularity: epic
|
|
33
|
+
worktree:
|
|
34
|
+
enabled: false
|
|
35
|
+
squash_on_merge: true
|
|
@@ -6,6 +6,14 @@ git:
|
|
|
6
6
|
base_branch: main
|
|
7
7
|
# Git status is tracked in git-status.yaml (Sprintpilot-owned), NOT sprint-status.yaml (BMad Method-owned)
|
|
8
8
|
|
|
9
|
+
# Branch + PR granularity.
|
|
10
|
+
# story — one branch + one PR per story (default).
|
|
11
|
+
# epic — one branch + one PR per epic; stories committed on it.
|
|
12
|
+
# Epic branch name: {branch_prefix}epic-{epic_id}.
|
|
13
|
+
# Epic PR merges with --squash; the PR body lists each
|
|
14
|
+
# story commit. Set on nano profile by default.
|
|
15
|
+
granularity: story
|
|
16
|
+
|
|
9
17
|
# Branch naming
|
|
10
18
|
branch_prefix: "story/" # prefix for story branches (e.g., story/1-2-user-auth)
|
|
11
19
|
max_branch_length: 60 # truncate + 6-char hash if longer
|
|
@@ -7,3 +7,45 @@ multi_agent:
|
|
|
7
7
|
max_parallel_research: 3 # Max concurrent research agents per batch
|
|
8
8
|
max_parallel_analysis: 5 # Max concurrent codebase analysis agents
|
|
9
9
|
# session_story_limit NOT duplicated here — uses autopilot's single authoritative limit
|
|
10
|
+
|
|
11
|
+
# PR 11: intra-epic parallel story execution.
|
|
12
|
+
# parallel_stories: true enables dispatch-layer.js on Claude Code;
|
|
13
|
+
# other hosts silently fall back to sequential (per agent-adapter.js
|
|
14
|
+
# detection — confidence high + supports_parallel true).
|
|
15
|
+
# max_parallel_stories: cap on concurrent sub-agents per layer.
|
|
16
|
+
# min_epic_duration_for_parallel_sec: skip parallelism for epics
|
|
17
|
+
# whose estimated wall-clock is below this threshold (saves
|
|
18
|
+
# dispatch overhead).
|
|
19
|
+
# max_consecutive_conflicts: disable parallelism for the rest of the
|
|
20
|
+
# session once N consecutive merge conflicts occur.
|
|
21
|
+
# effective_parallel_floor: never drop below this mid-session even
|
|
22
|
+
# after failure-driven concurrency reduction.
|
|
23
|
+
parallel_stories: false
|
|
24
|
+
max_parallel_stories: 2
|
|
25
|
+
min_epic_duration_for_parallel_sec: 300
|
|
26
|
+
baseline_story_duration_sec: 180
|
|
27
|
+
max_consecutive_conflicts: 2
|
|
28
|
+
effective_parallel_floor: 1
|
|
29
|
+
|
|
30
|
+
# EXPERIMENTAL: enable parallel_stories dispatch on Gemini CLI.
|
|
31
|
+
#
|
|
32
|
+
# Gemini CLI has a subagent primitive (invoke_subagent) but its
|
|
33
|
+
# worktree-scoped variant is not yet shipped upstream (tracker:
|
|
34
|
+
# github.com/google-gemini/gemini-cli#22967) and real-world parallelism
|
|
35
|
+
# reports serialization + quota throttling. Sprintpilot detects Gemini
|
|
36
|
+
# CLI via GEMINI_CLI=1 (env, HIGH confidence) or parent process `gemini`
|
|
37
|
+
# (MEDIUM), but supports_parallel stays false by default.
|
|
38
|
+
#
|
|
39
|
+
# Flip to true PER PROJECT once upstream worktree support lands (or to
|
|
40
|
+
# experiment at your own risk). Workflow logs an "experimental parallel"
|
|
41
|
+
# warning once per session when this is true AND host=gemini-cli.
|
|
42
|
+
experimental_parallel_on_gemini: false
|
|
43
|
+
|
|
44
|
+
# PR 12 — cross-epic parallelism (EXPERIMENTAL).
|
|
45
|
+
# Off by default on ALL profiles including large. Enabling requires:
|
|
46
|
+
# 1. Both epics carry `independent: true` in dependencies.yaml.
|
|
47
|
+
# 2. preflight-merge.js reports no conflicts between them.
|
|
48
|
+
# 3. max_parallel_epics is hardcoded at 2 — no tuning knob.
|
|
49
|
+
# A single cross-epic merge conflict in a session disables parallel_epics
|
|
50
|
+
# for the rest of the session.
|
|
51
|
+
parallel_epics: false
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// agent-adapter.js — detect the host coding agent currently running the
|
|
4
|
+
// Sprintpilot workflow. Output informs whether parallel sub-agent
|
|
5
|
+
// dispatch (PR 11) is safe.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// agent-adapter.js detect [--project-root <path>]
|
|
9
|
+
//
|
|
10
|
+
// Output (stdout, JSON):
|
|
11
|
+
// {
|
|
12
|
+
// "host": "claude-code" | "cursor" | "windsurf" | "aider" | "cline"
|
|
13
|
+
// | "roo" | "trae" | "kiro" | "copilot" | "unknown",
|
|
14
|
+
// "supports_parallel": boolean,
|
|
15
|
+
// "detection_reason": string,
|
|
16
|
+
// "confidence": "high" | "medium" | "low"
|
|
17
|
+
// }
|
|
18
|
+
//
|
|
19
|
+
// Detection priority (first match wins):
|
|
20
|
+
// 1. Env vars set by the running host → HIGH confidence
|
|
21
|
+
// 2. Parent process name → MEDIUM confidence
|
|
22
|
+
// 3. Filesystem install markers → LOW confidence
|
|
23
|
+
//
|
|
24
|
+
// Tautology guard (concept §M13): filesystem markers prove the INSTALL
|
|
25
|
+
// target, not the CURRENT host. confidence=low forces supports_parallel
|
|
26
|
+
// = false regardless of which host the markers suggest.
|
|
27
|
+
|
|
28
|
+
const fs = require('node:fs');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
const { spawnSync } = require('node:child_process');
|
|
31
|
+
|
|
32
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
33
|
+
const log = require('../lib/runtime/log');
|
|
34
|
+
|
|
35
|
+
// Host capability table. supports_parallel is true only for hosts with
|
|
36
|
+
// a first-class multi-agent API that Sprintpilot's dispatch-layer.js
|
|
37
|
+
// can reliably drive — which today means worktree-scoped sub-agents
|
|
38
|
+
// with parallel fan-out. Claude Code is the only host that meets both
|
|
39
|
+
// bars at the time of writing. Gemini CLI has a subagent primitive
|
|
40
|
+
// (invoke_subagent) but its worktree-scoped variant is still open
|
|
41
|
+
// upstream (github.com/google-gemini/gemini-cli#22967) and real-world
|
|
42
|
+
// parallelism reports serialization + quota throttling (#25534); hence
|
|
43
|
+
// supports_parallel=false by default, with an experimental opt-in via
|
|
44
|
+
// `ma.experimental_parallel_on_gemini: true` handled at workflow level.
|
|
45
|
+
const HOSTS = {
|
|
46
|
+
'claude-code': { supports_parallel: true },
|
|
47
|
+
'gemini-cli': { supports_parallel: false, subagents: 'experimental' },
|
|
48
|
+
cursor: { supports_parallel: false },
|
|
49
|
+
windsurf: { supports_parallel: false },
|
|
50
|
+
aider: { supports_parallel: false },
|
|
51
|
+
cline: { supports_parallel: false },
|
|
52
|
+
roo: { supports_parallel: false },
|
|
53
|
+
trae: { supports_parallel: false },
|
|
54
|
+
kiro: { supports_parallel: false },
|
|
55
|
+
copilot: { supports_parallel: false },
|
|
56
|
+
unknown: { supports_parallel: false },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ENV_DETECTORS = [
|
|
60
|
+
{ host: 'claude-code', match: (env) => env.CLAUDECODE === '1' || !!env.CLAUDE_CODE_SESSION_ID },
|
|
61
|
+
// Gemini CLI sets GEMINI_CLI=1 for every subprocess it spawns
|
|
62
|
+
// (docs/tools/shell.md + docs/reference/commands.md as of v0.33.x).
|
|
63
|
+
{ host: 'gemini-cli', match: (env) => env.GEMINI_CLI === '1' || !!env.GEMINI_CLI_SURFACE },
|
|
64
|
+
{ host: 'cursor', match: (env) => !!env.CURSOR_SESSION_ID || !!env.CURSOR_TRACE_ID },
|
|
65
|
+
{ host: 'windsurf', match: (env) => !!env.WINDSURF_SESSION },
|
|
66
|
+
{ host: 'aider', match: (env) => !!env.AIDER_SESSION || !!env.AIDER_HISTORY_FILE },
|
|
67
|
+
{ host: 'cline', match: (env) => !!env.CLINE_SESSION || !!env.CLINE_CONFIG },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const PARENT_DETECTORS = [
|
|
71
|
+
{ host: 'claude-code', parent: 'claude' },
|
|
72
|
+
{ host: 'gemini-cli', parent: 'gemini' },
|
|
73
|
+
{ host: 'cursor', parent: 'cursor-agent' },
|
|
74
|
+
{ host: 'aider', parent: 'aider' },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
function help() {
|
|
78
|
+
log.out('Usage: agent-adapter.js detect [--project-root <path>]');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function detectFromEnv(env) {
|
|
82
|
+
for (const d of ENV_DETECTORS) {
|
|
83
|
+
if (d.match(env)) {
|
|
84
|
+
return { host: d.host, confidence: 'high', detection_reason: `env var set (${d.host})` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Parsers extracted as pure functions so the platform branches stay
|
|
91
|
+
// unit-testable on any OS. Each takes the raw stdout from the platform
|
|
92
|
+
// command and returns the basename or null. Negative test cases
|
|
93
|
+
// (empty input, "INFO: No tasks…", malformed lines) covered by tests.
|
|
94
|
+
|
|
95
|
+
/** Parse `ps -p <pid> -o comm=` output (POSIX). */
|
|
96
|
+
function parsePsOutput(raw) {
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
const trimmed = raw.trim();
|
|
99
|
+
if (!trimmed) return null;
|
|
100
|
+
return path.basename(trimmed.split(/\s+/)[0]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse `tasklist /FO CSV /NH` output (Windows). Strips `.exe` so the
|
|
105
|
+
* basename matches the POSIX path (so PARENT_DETECTORS only needs the
|
|
106
|
+
* non-extension name once).
|
|
107
|
+
* Sample row: "claude.exe","12345","Console","1","123,456 K"
|
|
108
|
+
*/
|
|
109
|
+
function parseTasklistOutput(raw) {
|
|
110
|
+
if (!raw) return null;
|
|
111
|
+
const trimmed = raw.trim();
|
|
112
|
+
if (!trimmed || /^INFO:/i.test(trimmed)) return null; // "INFO: No tasks…"
|
|
113
|
+
const m = trimmed.match(/^"([^"]+)"/);
|
|
114
|
+
if (!m) return null;
|
|
115
|
+
return path.basename(m[1]).replace(/\.exe$/i, '');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function parentProcessName() {
|
|
119
|
+
try {
|
|
120
|
+
const pid = process.ppid;
|
|
121
|
+
if (process.platform === 'win32') {
|
|
122
|
+
const res = spawnSync(
|
|
123
|
+
'tasklist',
|
|
124
|
+
['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
|
|
125
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
126
|
+
);
|
|
127
|
+
if (res.status !== 0) return null;
|
|
128
|
+
return parseTasklistOutput(res.stdout || '');
|
|
129
|
+
}
|
|
130
|
+
// POSIX: macOS and Linux both support `ps -p <pid> -o comm=`.
|
|
131
|
+
const res = spawnSync('ps', ['-p', String(pid), '-o', 'comm='], {
|
|
132
|
+
encoding: 'utf8',
|
|
133
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
134
|
+
});
|
|
135
|
+
if (res.status !== 0) return null;
|
|
136
|
+
return parsePsOutput(res.stdout || '');
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function detectFromParent() {
|
|
143
|
+
const parent = parentProcessName();
|
|
144
|
+
if (!parent) return null;
|
|
145
|
+
for (const d of PARENT_DETECTORS) {
|
|
146
|
+
if (parent === d.parent) {
|
|
147
|
+
return {
|
|
148
|
+
host: d.host,
|
|
149
|
+
confidence: 'medium',
|
|
150
|
+
detection_reason: `parent process '${parent}'`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function detectFromFilesystem(projectRoot) {
|
|
158
|
+
const markers = [
|
|
159
|
+
{ path: path.join(projectRoot, '.claude', 'skills'), host: 'claude-code' },
|
|
160
|
+
{ path: path.join(projectRoot, '.claude-code'), host: 'claude-code' },
|
|
161
|
+
{ path: path.join(projectRoot, '.cursor'), host: 'cursor' },
|
|
162
|
+
{ path: path.join(projectRoot, '.windsurf'), host: 'windsurf' },
|
|
163
|
+
{ path: path.join(projectRoot, '.aider.conf.yml'), host: 'aider' },
|
|
164
|
+
{ path: path.join(projectRoot, '.cline'), host: 'cline' },
|
|
165
|
+
];
|
|
166
|
+
for (const m of markers) {
|
|
167
|
+
if (fs.existsSync(m.path)) {
|
|
168
|
+
return {
|
|
169
|
+
host: m.host,
|
|
170
|
+
confidence: 'low',
|
|
171
|
+
detection_reason: `filesystem marker '${path.relative(projectRoot, m.path)}' — NOT proof of current host`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function detect({ env = process.env, projectRoot = process.cwd() } = {}) {
|
|
179
|
+
const fromEnv = detectFromEnv(env);
|
|
180
|
+
if (fromEnv) {
|
|
181
|
+
const caps = HOSTS[fromEnv.host] || HOSTS.unknown;
|
|
182
|
+
return {
|
|
183
|
+
host: fromEnv.host,
|
|
184
|
+
supports_parallel: caps.supports_parallel,
|
|
185
|
+
detection_reason: fromEnv.detection_reason,
|
|
186
|
+
confidence: 'high',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const fromParent = detectFromParent();
|
|
190
|
+
if (fromParent) {
|
|
191
|
+
const caps = HOSTS[fromParent.host] || HOSTS.unknown;
|
|
192
|
+
return {
|
|
193
|
+
host: fromParent.host,
|
|
194
|
+
supports_parallel: caps.supports_parallel,
|
|
195
|
+
detection_reason: fromParent.detection_reason,
|
|
196
|
+
confidence: 'medium',
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const fromFs = detectFromFilesystem(projectRoot);
|
|
200
|
+
if (fromFs) {
|
|
201
|
+
// Tautology guard: filesystem markers never enable parallel.
|
|
202
|
+
return {
|
|
203
|
+
host: fromFs.host,
|
|
204
|
+
supports_parallel: false,
|
|
205
|
+
detection_reason: fromFs.detection_reason,
|
|
206
|
+
confidence: 'low',
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
host: 'unknown',
|
|
211
|
+
supports_parallel: false,
|
|
212
|
+
detection_reason: 'no env/parent/filesystem signal',
|
|
213
|
+
confidence: 'low',
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function main() {
|
|
218
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
219
|
+
if (opts.help || positional.length === 0) {
|
|
220
|
+
help();
|
|
221
|
+
process.exit(opts.help ? 0 : 1);
|
|
222
|
+
}
|
|
223
|
+
const action = positional[0];
|
|
224
|
+
if (action !== 'detect') {
|
|
225
|
+
log.error(`unknown action '${action}'. Valid: detect`);
|
|
226
|
+
process.exit(1);
|
|
227
|
+
}
|
|
228
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
229
|
+
const result = detect({ env: process.env, projectRoot });
|
|
230
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = {
|
|
234
|
+
HOSTS,
|
|
235
|
+
ENV_DETECTORS,
|
|
236
|
+
PARENT_DETECTORS,
|
|
237
|
+
detectFromEnv,
|
|
238
|
+
detectFromParent,
|
|
239
|
+
detectFromFilesystem,
|
|
240
|
+
detect,
|
|
241
|
+
parsePsOutput,
|
|
242
|
+
parseTasklistOutput,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
if (require.main === module) {
|
|
246
|
+
main();
|
|
247
|
+
}
|