project-autopilot 12.0.0 → 12.0.1

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 (40) hide show
  1. package/README.md +10 -10
  2. package/SKILL.md +1 -3
  3. package/adapters/agents.json +11 -1
  4. package/config.example.json +5 -3
  5. package/config.schema.json +28 -9
  6. package/docs/configuration.md +5 -3
  7. package/package.json +1 -1
  8. package/scripts/adapter-drivers.mjs +209 -22
  9. package/scripts/agent-contract.mjs +24 -2
  10. package/scripts/agent-invocation.mjs +32 -5
  11. package/scripts/autopilot.mjs +787 -222
  12. package/scripts/config-layout.mjs +6 -0
  13. package/scripts/config-migration.mjs +3 -3
  14. package/scripts/config-validation.mjs +12 -4
  15. package/scripts/e2e-detect.mjs +30 -0
  16. package/scripts/escalation-naming.mjs +19 -0
  17. package/scripts/failure-contract.mjs +25 -9
  18. package/scripts/gen-config-schema.mjs +7 -0
  19. package/scripts/idle-hang-watchdog.mjs +34 -7
  20. package/scripts/implementation-baseline.mjs +95 -4
  21. package/scripts/model-discovery.mjs +176 -38
  22. package/scripts/opencode-generation.mjs +116 -0
  23. package/scripts/per-task-budget.mjs +11 -7
  24. package/scripts/plan-review-rounds.mjs +164 -9
  25. package/scripts/planning-boundary.mjs +16 -6
  26. package/scripts/planning-continuation.mjs +43 -23
  27. package/scripts/planning-review-decision.mjs +3 -0
  28. package/scripts/planning-review-wiring.mjs +12 -8
  29. package/scripts/relay-guards.mjs +133 -11
  30. package/scripts/review-canary.mjs +5 -2
  31. package/scripts/role-fallback.mjs +23 -8
  32. package/scripts/run-history.mjs +32 -1
  33. package/scripts/run-observability.mjs +46 -2
  34. package/scripts/setup-discovery.mjs +2 -1
  35. package/scripts/setup.mjs +183 -81
  36. package/scripts/skill-copy.mjs +49 -0
  37. package/scripts/task-requirements.mjs +24 -0
  38. package/scripts/watch-report.mjs +146 -21
  39. package/scripts/watch-summary.mjs +26 -3
  40. package/scripts/plan-mode-model-note.mjs +0 -27
package/scripts/setup.mjs CHANGED
@@ -11,7 +11,8 @@ import {
11
11
  loadDocumentedCatalog,
12
12
  loadDocumentedEffort,
13
13
  discoverCapabilities,
14
- canonicalModelId
14
+ canonicalModelId,
15
+ modelDiscoveryTimeoutLabel
15
16
  } from "./model-discovery.mjs";
16
17
  import { discoverAgentContract } from "./agent-contract.mjs";
17
18
  import { buildDiscoveryCaches } from "./setup-discovery.mjs";
@@ -40,6 +41,7 @@ import {
40
41
  import { detect as detectSpecKit } from "../adapters/speckit.mjs";
41
42
  import { detect as detectGsd } from "../adapters/gsd.mjs";
42
43
  import { detectTestCommand, resolveGatesCommands } from "./detect-test-command.mjs";
44
+ import { detectUiProject, detectE2eTool } from "./e2e-detect.mjs";
43
45
  import { discoverPlaywrightRuntime } from "./playwright-runtime.mjs";
44
46
  import { formatPlaywrightStatusBlock } from "./playwright-status-block.mjs";
45
47
  import { PLATFORM_KINDS, detectPlatformCapabilities, detectPlatformKind, formatExecutionEnvironmentBlock } from "./platform-capabilities.mjs";
@@ -53,7 +55,6 @@ import {
53
55
  reviewerIndependenceWarning
54
56
  } from "./role-fallback-edit.mjs";
55
57
  import { normalizeEscalationModel, ESCALATION_ROLES } from "./escalation-model.mjs";
56
- import {isHaikuOnReadOnlyClaudeRole, planModeModelNote} from "./plan-mode-model-note.mjs";
57
58
  import { networkIsolationPrefix } from "./shell-guard.mjs";
58
59
  import { installAgentCommands } from "./agent-command-registry.mjs";
59
60
  import { fileURLToPath } from "node:url";
@@ -411,7 +412,16 @@ async function pickModelAndCapabilities(
411
412
  };
412
413
  }
413
414
 
414
- const discovery = contractCache || await discoverAgentContract(adapterId, meta);
415
+ const cachedContract = contractCache instanceof Map
416
+ ? contractCache.get(adapterId)
417
+ : contractCache;
418
+ let discovery = cachedContract;
419
+ if (!discovery) {
420
+ if (forceInteractive || process.stdin.isTTY) {
421
+ console.log(`Loading ${meta.binary} models…`);
422
+ }
423
+ discovery = await discoverAgentContract(adapterId, meta);
424
+ }
415
425
  const catalog = discovery.models.status === "reported" ? discovery.models.items : [];
416
426
 
417
427
  if (catalog.length) {
@@ -446,12 +456,12 @@ async function pickModelAndCapabilities(
446
456
  // an otherwise-live result, so it is never used for "Use CLI default"
447
457
  // (item.id is empty there — there is no selected model to look up).
448
458
  let reasoningOptions = item.reasoning || [];
449
- if (item.id && !reasoningOptions.length && meta.effort_flag === "--variant" && Array.isArray(item.variants) && item.variants.length) {
459
+ if (item.id && !reasoningOptions.length && (meta.effort_flag === "--variant" || meta.effort_mode === "model_suffix") && Array.isArray(item.variants) && item.variants.length) {
450
460
  // OpenCode has no separate reasoning field in its own model output —
451
- // its --help text names --variant itself as "provider-specific
452
- // reasoning effort" (e.g. high, max, minimal), so for the one adapter
453
- // that declares effort_flag "--variant", the live-discovered variants
454
- // ARE the live reasoning levels, not a distinct dimension.
461
+ // 1.x names --variant itself as "provider-specific reasoning effort"
462
+ // (e.g. high, max, minimal) and 2.x encodes the same level as a model
463
+ // suffix; either way the live-discovered variants ARE the live
464
+ // reasoning levels, not a distinct dimension.
455
465
  reasoningOptions = item.variants;
456
466
  } else if (item.id && !reasoningOptions.length) {
457
467
  const documentedEffort = loadDocumentedEffort(adapterId);
@@ -563,10 +573,22 @@ async function pickModelAndCapabilities(
563
573
  };
564
574
  }
565
575
 
566
- console.log(
567
- `\n${label}: ${meta.binary} does not expose a supported machine-readable model list; ` +
568
- `Autopilot will not invent a catalog.`
569
- );
576
+ if (discovery.models.status === "timeout") {
577
+ // The probe is cached and reused by every role, so the retry (and this
578
+ // fallback notice) is reported once per adapter, not once per role.
579
+ if (!discovery.models.timeoutReported) {
580
+ console.log(
581
+ `\n${label}: ${meta.binary} model list timed out after ${modelDiscoveryTimeoutLabel()} — ` +
582
+ `type a model name, or press Enter for the CLI default, or run ap config again later.`
583
+ );
584
+ discovery.models.timeoutReported = true;
585
+ }
586
+ } else {
587
+ console.log(
588
+ `\n${label}: ${meta.binary} does not expose a supported machine-readable model list; ` +
589
+ `Autopilot will not invent a catalog.`
590
+ );
591
+ }
570
592
 
571
593
  return {
572
594
  model: await valueOrDefault(
@@ -580,15 +602,45 @@ async function pickModelAndCapabilities(
580
602
  };
581
603
  }
582
604
 
605
+ function documentedLevelsForDefaultModel(adapterId) {
606
+ if (adapterId === "claude") {
607
+ const catalog = loadDocumentedCatalog("claude");
608
+ if (!catalog) return null;
609
+ const levels = catalog.models
610
+ .map(model => Array.isArray(model.reasoning) ? model.reasoning : [])
611
+ .find(list => list.length) || [];
612
+ if (!levels.length) return null;
613
+ return {levels, source: catalog.source, last_verified: catalog.last_verified};
614
+ }
615
+ const effort = loadDocumentedEffort(adapterId);
616
+ if (!effort) return null;
617
+ return {levels: effort.levels, source: effort.source, last_verified: effort.last_verified};
618
+ }
619
+
620
+ // Merge live-discovered capabilities over an adapter manifest. Falls back to
621
+ // the manifest's own effort_mode so a `config` transport (codex) survives and
622
+ // a 2.x OpenCode capability can replace the manifest's --variant flag with
623
+ // the model-suffix transport.
624
+ function effectiveMetaFor(meta, caps) {
625
+ return {
626
+ ...meta,
627
+ model_flag: caps.model ? caps.model_flag : null,
628
+ effort_flag: caps.effort ? caps.effort_flag : null,
629
+ effort_mode: caps.effort_mode || meta.effort_mode || null,
630
+ variant_flag: caps.variant ? caps.variant_flag : null,
631
+ variant_mode: meta.variant_mode || null
632
+ };
633
+ }
634
+
583
635
  // Ask for reasoning/effort and (when supported) variant for an already
584
636
  // picked model. Shared by the main-role flow and the per-role fallback
585
637
  // add flows so both use identical prompts and options. May swap
586
638
  // modelInfo.model when the model maps effort levels to exact ids.
587
- // Adapters without an effort flag are not asked and yield "".
588
- async function pickReasoningAndVariant(label, effectiveMeta, modelInfo, prior) {
639
+ // Adapters without an effort flag/mode are not asked and yield "".
640
+ async function pickReasoningAndVariant(label, effectiveMeta, modelInfo, prior, adapterId) {
589
641
  let reasoning = "";
590
642
 
591
- if (effectiveMeta.effort_flag) {
643
+ if (effectiveMeta.effort_flag || effectiveMeta.effort_mode === "model_suffix") {
592
644
  if (modelInfo.effortModelIds && modelInfo.reasoningOptions.length) {
593
645
  const opts = [...modelInfo.reasoningOptions];
594
646
  const priorReasoning = prior.reasoning || "";
@@ -623,10 +675,48 @@ async function pickReasoningAndVariant(label, effectiveMeta, modelInfo, prior) {
623
675
  ) {
624
676
  reasoning = "";
625
677
  }
678
+ } else if (!modelInfo.model) {
679
+ const documented = documentedLevelsForDefaultModel(adapterId);
680
+ if (documented) {
681
+ console.log(
682
+ `\n${label}: documented reasoning/effort levels (not live-verified for the CLI default model)`
683
+ );
684
+ console.log(
685
+ `Source: ${documented.source} | verified: ${documented.last_verified}`
686
+ );
687
+
688
+ const opts = [
689
+ "Use CLI default",
690
+ ...documented.levels
691
+ ];
692
+
693
+ const priorReasoning =
694
+ prior.reasoning || "";
695
+
696
+ const fallback = Math.max(
697
+ 0,
698
+ opts.indexOf(priorReasoning)
699
+ );
700
+
701
+ reasoning = await choose(
702
+ `${label} reasoning/effort (for selected model)`,
703
+ opts,
704
+ fallback
705
+ );
706
+
707
+ if (
708
+ reasoning === "Use CLI default"
709
+ ) {
710
+ reasoning = "";
711
+ }
712
+ } else {
713
+ console.log(
714
+ `\n${label}: the CLI default model has no selectable reasoning levels — the CLI decides.`
715
+ );
716
+ }
626
717
  } else {
627
- reasoning = await value(
628
- `${label} reasoning/effort`,
629
- prior.reasoning || ""
718
+ console.log(
719
+ `\n${label}: ${modelInfo.model} has no selectable reasoning levels — the CLI decides.`
630
720
  );
631
721
  }
632
722
  }
@@ -719,27 +809,12 @@ async function roleConfig( label,
719
809
  console.log(
720
810
  ` capabilities: ` +
721
811
  `model=${runtimeCaps.model ? runtimeCaps.model_flag : "no"}, ` +
722
- `effort=${runtimeCaps.effort ? runtimeCaps.effort_flag : "no"}, ` +
812
+ `effort=${runtimeCaps.effort ? (runtimeCaps.effort_flag || (runtimeCaps.effort_mode === "model_suffix" ? "model#level" : "yes")) : "no"}, ` +
723
813
  `variant=${runtimeCaps.variant ? runtimeCaps.variant_flag : (meta.variant_mode === "model_suffix" ? "model#variant" : "no")}, ` +
724
814
  `resume=${runtimeCaps.resume ? "yes" : "no"}`
725
815
  );
726
816
 
727
- const effectiveMeta = {
728
- ...meta,
729
- model_flag:
730
- runtimeCaps.model
731
- ? runtimeCaps.model_flag
732
- : null,
733
- effort_flag:
734
- runtimeCaps.effort
735
- ? runtimeCaps.effort_flag
736
- : null,
737
- variant_flag:
738
- runtimeCaps.variant
739
- ? runtimeCaps.variant_flag
740
- : null,
741
- variant_mode: meta.variant_mode || null
742
- };
817
+ const effectiveMeta = effectiveMetaFor(meta, runtimeCaps);
743
818
 
744
819
  const prior =
745
820
  adapter === current.adapter
@@ -756,14 +831,16 @@ async function roleConfig( label,
756
831
  label,
757
832
  effectiveMeta,
758
833
  prior,
759
- adapter
834
+ adapter,
835
+ contractCache
760
836
  );
761
837
 
762
838
  const { reasoning, variant } = await pickReasoningAndVariant(
763
839
  label,
764
840
  effectiveMeta,
765
841
  modelInfo,
766
- prior
842
+ prior,
843
+ adapter
767
844
  );
768
845
 
769
846
  // Resume is an internal orchestration decision, not a setup choice.
@@ -818,9 +895,11 @@ async function repairOneMissingModel({entry, roles, agents, roleLabels, contract
818
895
  const adapter = agents?.[adapterId];
819
896
  const binary = adapter?.binary || adapterId;
820
897
  let items = [];
898
+ let modelsStatus = null;
821
899
  try {
822
900
  const contract = contractCache?.get(adapterId) || await discoverAgentContract(adapterId, adapter);
823
901
  items = contract?.models?.status === "reported" ? contract.models.items : [];
902
+ modelsStatus = contract?.models?.status || null;
824
903
  } catch { items = []; }
825
904
  const ids = [];
826
905
  for (const item of items) {
@@ -835,7 +914,9 @@ async function repairOneMissingModel({entry, roles, agents, roleLabels, contract
835
914
  console.log(`\nModel no longer available: ${targetLabel} uses '${entry.model}', which ${binary} no longer lists.`);
836
915
  console.log(ids.length
837
916
  ? `${binary} currently lists: ${ids.join(", ")}`
838
- : `${binary} currently reports no machine-readable model list.`);
917
+ : modelsStatus === "timeout"
918
+ ? `${binary} model list timed out after ${modelDiscoveryTimeoutLabel()} — type a model id or drop this entry, or run ap config again later.`
919
+ : `${binary} currently reports no machine-readable model list.`);
839
920
 
840
921
  const dropLabel = entry.kind === "fallback"
841
922
  ? "Drop this fallback"
@@ -1042,6 +1123,27 @@ function detectPlanningFrameworks(repo) {
1042
1123
  // Platform the network question is decided for (single line so tests can pin it in a disposable copy).
1043
1124
  const NETWORK_PLATFORM = process.platform;
1044
1125
 
1126
+ // E2E question (shared by first run and `ap config`): asked only for a UI project with no E2E tool
1127
+ // and no stored tests.e2e. "no" is stored as "none" so it is never asked again. Installs nothing.
1128
+ const E2E_PROMPT = "Add Playwright? (recommended) [yes/no/cypress]: ";
1129
+
1130
+ function storedE2e(current) {
1131
+ return current?.capabilities?.verification?.e2e ?? current?.tests?.e2e ?? null;
1132
+ }
1133
+
1134
+ async function askE2eChoice(current, repo, interactive) {
1135
+ const stored = storedE2e(current);
1136
+ if (!interactive || stored != null) return stored;
1137
+ if (!detectUiProject(repo) || detectE2eTool(repo)) return null;
1138
+ for (;;) {
1139
+ const answer = (await ask(E2E_PROMPT)).trim().toLowerCase();
1140
+ if (answer === "" || answer === "y" || answer === "yes") return "playwright";
1141
+ if (answer === "n" || answer === "no") return "none";
1142
+ if (answer === "c" || answer === "cypress") return "cypress";
1143
+ console.log("Please answer yes, no or cypress.");
1144
+ }
1145
+ }
1146
+
1045
1147
  async function networkSetting(current, runtimeMode) {
1046
1148
  let networkIsolation =
1047
1149
  current?.gates?.network_isolation ??
@@ -1179,17 +1281,21 @@ async function printStartScreen(detectedFrameworks) {
1179
1281
  return;
1180
1282
  }
1181
1283
 
1182
- // Probe each available CLI once per setup invocation.
1183
- // The capability result is reused for every role, while model/variant
1184
- // selection is still presented independently each time the CLI is chosen.
1185
- const { capabilityCache, contractCache } =
1186
- await buildDiscoveryCaches(available, agents);
1187
-
1188
1284
  // Interactive once, reused by the role-fallback loop below and the gates
1189
1285
  // question further down: a non-interactive stdin keeps defaults.
1190
1286
  const interactive =
1191
1287
  forceInteractive || !!process.stdin.isTTY;
1192
1288
 
1289
+ // Probe each available CLI once per setup invocation.
1290
+ // The capability result is reused for every role, while model/variant
1291
+ // selection is still presented independently each time the CLI is chosen.
1292
+ const { capabilityCache, contractCache } =
1293
+ await buildDiscoveryCaches(available, agents, undefined, {
1294
+ onStart: (_id, meta) => {
1295
+ if (interactive) console.log(`Loading ${meta.binary} models…`);
1296
+ }
1297
+ });
1298
+
1193
1299
  let detectedFrameworks = [];
1194
1300
  let frameworkChoices = [];
1195
1301
 
@@ -1386,9 +1492,6 @@ async function printStartScreen(detectedFrameworks) {
1386
1492
  contractCache,
1387
1493
  r !== "implementer"
1388
1494
  );
1389
- if (isHaikuOnReadOnlyClaudeRole(r, roles[r]?.adapter, roles[r]?.model)) {
1390
- console.log(`\n${planModeModelNote(r)}`);
1391
- }
1392
1495
 
1393
1496
  // Pending #22: roleConfig() rebuilds the role, so carry a configured
1394
1497
  // escalation_model forward; the optional question below may change it.
@@ -1437,24 +1540,20 @@ async function printStartScreen(detectedFrameworks) {
1437
1540
  const fallbackCaps =
1438
1541
  capabilityCache.get(fallbackAdapter) ||
1439
1542
  await discoverCapabilities(fallbackMeta);
1440
- const fallbackEffectiveMeta = {
1441
- ...fallbackMeta,
1442
- model_flag: fallbackCaps.model ? fallbackCaps.model_flag : null,
1443
- effort_flag: fallbackCaps.effort ? fallbackCaps.effort_flag : null,
1444
- variant_flag: fallbackCaps.variant ? fallbackCaps.variant_flag : null,
1445
- variant_mode: fallbackMeta.variant_mode || null
1446
- };
1543
+ const fallbackEffectiveMeta = effectiveMetaFor(fallbackMeta, fallbackCaps);
1447
1544
  const fallbackModelInfo = await pickModelAndCapabilities(
1448
1545
  `${roleLabels[r]} fallback`,
1449
1546
  fallbackEffectiveMeta,
1450
1547
  null,
1451
- fallbackAdapter
1548
+ fallbackAdapter,
1549
+ contractCache
1452
1550
  );
1453
1551
  const fallbackExtras = await pickReasoningAndVariant(
1454
1552
  `${roleLabels[r]} fallback`,
1455
1553
  fallbackEffectiveMeta,
1456
1554
  fallbackModelInfo,
1457
- { reasoning: roles[r]?.reasoning || "", variant: "" }
1555
+ { reasoning: roles[r]?.reasoning || "", variant: "" },
1556
+ fallbackAdapter
1458
1557
  );
1459
1558
  const candidate = {
1460
1559
  adapter: fallbackAdapter,
@@ -1462,9 +1561,6 @@ async function printStartScreen(detectedFrameworks) {
1462
1561
  reasoning: fallbackExtras.reasoning,
1463
1562
  variant: fallbackExtras.variant
1464
1563
  };
1465
- if (isHaikuOnReadOnlyClaudeRole(r, candidate.adapter, candidate.model)) {
1466
- console.log(`\n${planModeModelNote(r)}`);
1467
- }
1468
1564
  if (!probe(fallbackAdapter)) {
1469
1565
  console.log(
1470
1566
  `\nWarning: fallback CLI ${fallbackAdapter} probe failed; saving anyway.`
@@ -1568,24 +1664,20 @@ async function printStartScreen(detectedFrameworks) {
1568
1664
  const fallbackCaps =
1569
1665
  capabilityCache.get(fallbackAdapter) ||
1570
1666
  await discoverCapabilities(fallbackMeta);
1571
- const fallbackEffectiveMeta = {
1572
- ...fallbackMeta,
1573
- model_flag: fallbackCaps.model ? fallbackCaps.model_flag : null,
1574
- effort_flag: fallbackCaps.effort ? fallbackCaps.effort_flag : null,
1575
- variant_flag: fallbackCaps.variant ? fallbackCaps.variant_flag : null,
1576
- variant_mode: fallbackMeta.variant_mode || null
1577
- };
1667
+ const fallbackEffectiveMeta = effectiveMetaFor(fallbackMeta, fallbackCaps);
1578
1668
  const fallbackModelInfo = await pickModelAndCapabilities(
1579
1669
  `${roleLabels[r]} fallback`,
1580
1670
  fallbackEffectiveMeta,
1581
1671
  null,
1582
- fallbackAdapter
1672
+ fallbackAdapter,
1673
+ contractCache
1583
1674
  );
1584
1675
  const fallbackExtras = await pickReasoningAndVariant(
1585
1676
  `${roleLabels[r]} fallback`,
1586
1677
  fallbackEffectiveMeta,
1587
1678
  fallbackModelInfo,
1588
- { reasoning: roles[r]?.reasoning || "", variant: "" }
1679
+ { reasoning: roles[r]?.reasoning || "", variant: "" },
1680
+ fallbackAdapter
1589
1681
  );
1590
1682
  const candidate = {
1591
1683
  adapter: fallbackAdapter,
@@ -1593,9 +1685,6 @@ async function printStartScreen(detectedFrameworks) {
1593
1685
  reasoning: fallbackExtras.reasoning,
1594
1686
  variant: fallbackExtras.variant
1595
1687
  };
1596
- if (isHaikuOnReadOnlyClaudeRole(r, candidate.adapter, candidate.model)) {
1597
- console.log(`\n${planModeModelNote(r)}`);
1598
- }
1599
1688
 
1600
1689
  // Warn-never-block per-pick checks: CLI probe plus model
1601
1690
  // membership against the cached/live contract catalog.
@@ -1699,24 +1788,20 @@ async function printStartScreen(detectedFrameworks) {
1699
1788
  const escAdapter = await choose(`${escLabel} CLI`, available, 0);
1700
1789
  const escMeta = agents[escAdapter];
1701
1790
  const escCaps = capabilityCache.get(escAdapter) || await discoverCapabilities(escMeta);
1702
- const escEffectiveMeta = {
1703
- ...escMeta,
1704
- model_flag: escCaps.model ? escCaps.model_flag : null,
1705
- effort_flag: escCaps.effort ? escCaps.effort_flag : null,
1706
- variant_flag: escCaps.variant ? escCaps.variant_flag : null,
1707
- variant_mode: escMeta.variant_mode || null
1708
- };
1791
+ const escEffectiveMeta = effectiveMetaFor(escMeta, escCaps);
1709
1792
  const escModelInfo = await pickModelAndCapabilities(
1710
1793
  escLabel,
1711
1794
  escEffectiveMeta,
1712
1795
  null,
1713
- escAdapter
1796
+ escAdapter,
1797
+ contractCache
1714
1798
  );
1715
1799
  const escExtras = await pickReasoningAndVariant(
1716
1800
  escLabel,
1717
1801
  escEffectiveMeta,
1718
1802
  escModelInfo,
1719
- { reasoning: roles[escRoles[0]]?.reasoning || "", variant: "" }
1803
+ { reasoning: roles[escRoles[0]]?.reasoning || "", variant: "" },
1804
+ escAdapter
1720
1805
  );
1721
1806
  const candidate = normalizeEscalationModel({
1722
1807
  adapter: escAdapter,
@@ -1960,6 +2045,10 @@ async function printStartScreen(detectedFrameworks) {
1960
2045
  true
1961
2046
  );
1962
2047
 
2048
+ // E2E question: same shared helper/prompt for first run and `ap config` (asked only when unset).
2049
+ const e2eChoice = await askE2eChoice(current, repo, interactive);
2050
+ const e2ePending = storedE2e(current) == null && (e2eChoice === "playwright" || e2eChoice === "cypress");
2051
+
1963
2052
  const config = {
1964
2053
  ...(current || {}),
1965
2054
 
@@ -2069,7 +2158,20 @@ async function printStartScreen(detectedFrameworks) {
2069
2158
  browser: "native",
2070
2159
  require_browser: "when-task-requires"
2071
2160
  },
2072
- ...(current?.capabilities || {})
2161
+ ...(current?.capabilities || {}),
2162
+ // tests.e2e / tests.e2e_setup_pending in the config file (config-layout maps them here).
2163
+ ...(e2eChoice != null
2164
+ ? {
2165
+ verification: {
2166
+ ...(current?.capabilities?.verification || {
2167
+ browser: "native",
2168
+ require_browser: "when-task-requires"
2169
+ }),
2170
+ e2e: e2eChoice,
2171
+ ...(e2ePending ? { e2e_setup_pending: true } : {})
2172
+ }
2173
+ }
2174
+ : {})
2073
2175
  },
2074
2176
 
2075
2177
  safety: {
@@ -0,0 +1,49 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // The folder holding package.json/bin/scripts of the running ap. Derived from
6
+ // this module's own import.meta.url (the module ships inside that folder), so
7
+ // it never depends on the caller's cwd.
8
+ export function skillCopyRoot(scriptUrl = import.meta.url) {
9
+ return path.resolve(path.dirname(fileURLToPath(scriptUrl)), "..");
10
+ }
11
+
12
+ // How this copy was installed: inside a node_modules tree (npm-global), a Git
13
+ // checkout (.git next to package.json), or any other copied skill folder.
14
+ export function skillCopyMode(root = skillCopyRoot()) {
15
+ const resolved = path.resolve(root);
16
+ if (resolved.split(/[\\/]/).includes("node_modules")) return "npm-global";
17
+ try {
18
+ if (fs.statSync(path.join(resolved, ".git")).isDirectory()) return "dev-checkout";
19
+ } catch { /* no .git directory */ }
20
+ return "skill-folder";
21
+ }
22
+
23
+ // The identity the watch report prints: the copy's package version, install
24
+ // mode and root path. Never throws: a missing/unreadable package.json leaves
25
+ // the version null rather than inventing one.
26
+ export function skillCopyInfo(root = skillCopyRoot()) {
27
+ const resolved = path.resolve(root);
28
+ let version = null;
29
+ try {
30
+ const pkg = JSON.parse(fs.readFileSync(path.join(resolved, "package.json"), "utf8"));
31
+ if (typeof pkg?.version === "string" && pkg.version) version = pkg.version;
32
+ } catch { /* version stays null */ }
33
+ return { version, mode: skillCopyMode(resolved), path: resolved };
34
+ }
35
+
36
+ // Record the running copy on the mission state.json the watch report reads.
37
+ // Called at mission run start; mutates and returns the state object.
38
+ export function recordSkillCopy(state, info = skillCopyInfo()) {
39
+ if (!state || typeof state !== "object") return state;
40
+ state.skill = { version: info.version, mode: info.mode, path: info.path };
41
+ return state;
42
+ }
43
+
44
+ // Record the entry command (and the stage it stops after, null = full loop) on
45
+ // the mission state at run start; the watch report prints it.
46
+ export function recordCommand(state, name, stopAfter = null) {
47
+ state.command = { name, stopAfter };
48
+ return state;
49
+ }
@@ -21,3 +21,27 @@ export function mergeTaskFacts(tasks = []) {
21
21
  requiredCapabilities: [...new Set(facts.flatMap(fact => fact.requiredCapabilities || []))]
22
22
  };
23
23
  }
24
+
25
+ // Standalone and SpecKit task lines carry no structured `files`/`verify`
26
+ // fields, only one text line ("... — accept: ...; verify: ..."). Read the
27
+ // file paths named in the line and its `verify:` segment, then derive the
28
+ // same facts the GSD path derives from its plan XML.
29
+ export function deriveTaskFactsFromLine(task = {}) {
30
+ const text = String(task.text || "");
31
+ const verifyMatch = text.match(/;\s*verify:\s*(.+)$/i);
32
+ const files = text.split(/\s+/)
33
+ .map(token => token.replace(/^[`'"(\[]+|[`'"),;.:\]]+$/g, ""))
34
+ .filter(token => /[\\/]/.test(token) && /\.\w+$/.test(token));
35
+ return deriveTaskFacts({files, verify: verifyMatch ? verifyMatch[1] : "", requiredCapabilities: task.requiredCapabilities});
36
+ }
37
+
38
+ export function mergeTaskLineFacts(tasks = []) {
39
+ return mergeTaskFacts(tasks.map(task => ({taskFacts: deriveTaskFactsFromLine(task)})));
40
+ }
41
+
42
+ // Brief text for the verifier when the phase needs a browser. The orchestrator
43
+ // does not launch a dev server; the verifier starts it or reports the gap.
44
+ export function browserVerifierNote(facts = {}) {
45
+ if (facts.browser !== true && facts.playwright !== true) return "";
46
+ return "Browser verification required: this phase changes UI files or verifies with Playwright. Start the project's dev server yourself (use the matching script in package.json, inside your allowed commands and scratch directory), open the changed page in the browser, and check the acceptance criteria there. If the dev server or browser cannot be started, report that check as NOT POSSIBLE with the reason — never skip it silently.";
47
+ }