@kontourai/flow-agents 3.3.0 → 3.4.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 (256) hide show
  1. package/.github/workflows/add-to-project.yml +15 -0
  2. package/.github/workflows/ci.yml +161 -0
  3. package/CHANGELOG.md +48 -0
  4. package/CONTEXT.md +5 -1
  5. package/README.md +19 -8
  6. package/build/src/builder-flow-run-adapter.d.ts +80 -0
  7. package/build/src/builder-flow-run-adapter.js +241 -0
  8. package/build/src/builder-flow-runtime.d.ts +16 -0
  9. package/build/src/builder-flow-runtime.js +290 -0
  10. package/build/src/cli/builder-run.d.ts +1 -0
  11. package/build/src/cli/builder-run.js +27 -0
  12. package/build/src/cli/effective-backlog-settings.js +70 -2
  13. package/build/src/cli/init.d.ts +34 -0
  14. package/build/src/cli/init.js +341 -61
  15. package/build/src/cli/kit.js +55 -12
  16. package/build/src/cli/pull-work-provider.js +346 -5
  17. package/build/src/cli/skill-drift-check.d.ts +1 -0
  18. package/build/src/cli/skill-drift-check.js +165 -0
  19. package/build/src/cli/telemetry-doctor.d.ts +37 -0
  20. package/build/src/cli/telemetry-doctor.js +53 -6
  21. package/build/src/cli/validate-hook-influence.js +37 -7
  22. package/build/src/cli/workflow-sidecar.d.ts +93 -8
  23. package/build/src/cli/workflow-sidecar.js +1175 -158
  24. package/build/src/cli.js +5 -0
  25. package/build/src/flow-kit/validate.d.ts +54 -34
  26. package/build/src/flow-kit/validate.js +237 -26
  27. package/build/src/index.d.ts +2 -0
  28. package/build/src/index.js +1 -0
  29. package/build/src/lib/console-connect-options.d.ts +97 -0
  30. package/build/src/lib/console-connect-options.js +199 -0
  31. package/build/src/lib/console-telemetry-validate.d.ts +49 -0
  32. package/build/src/lib/console-telemetry-validate.js +91 -0
  33. package/build/src/lib/flow-resolver.d.ts +56 -3
  34. package/build/src/lib/flow-resolver.js +151 -11
  35. package/build/src/lib/fs.d.ts +17 -0
  36. package/build/src/lib/fs.js +172 -0
  37. package/build/src/lib/local-artifact-root.d.ts +44 -1
  38. package/build/src/lib/local-artifact-root.js +131 -3
  39. package/build/src/runtime-adapters.d.ts +39 -3
  40. package/build/src/runtime-adapters.js +77 -31
  41. package/build/src/tools/build-universal-bundles.js +40 -2
  42. package/build/src/tools/codex-agent-routing.d.ts +2 -0
  43. package/build/src/tools/codex-agent-routing.js +49 -0
  44. package/build/src/tools/generate-context-map.js +1 -0
  45. package/build/src/tools/validate-source-tree.js +27 -1
  46. package/context/scripts/hooks/lib/kit-catalog.js +235 -0
  47. package/context/scripts/hooks/lib/runnable-command.js +177 -0
  48. package/context/scripts/hooks/stop-goal-fit.js +278 -48
  49. package/context/scripts/hooks/workflow-steering.js +121 -21
  50. package/context/scripts/package.json +3 -0
  51. package/context/scripts/telemetry/install-console-config.sh +25 -4
  52. package/context/scripts/telemetry/lib/config.sh +102 -12
  53. package/context/scripts/telemetry/lib/pricing.sh +50 -0
  54. package/context/scripts/telemetry/lib/session.sh +3 -0
  55. package/context/scripts/telemetry/lib/transport.sh +87 -0
  56. package/context/scripts/telemetry/lib/usage.sh +205 -4
  57. package/context/scripts/telemetry/telemetry.conf +6 -0
  58. package/context/scripts/telemetry/telemetry.sh +48 -0
  59. package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
  60. package/docs/agent-usage-feedback-loop.md +35 -0
  61. package/docs/architecture-engine-and-kits.md +110 -0
  62. package/docs/context-map.md +2 -0
  63. package/docs/decisions/embeddable-engine.md +152 -0
  64. package/docs/decisions/index.md +3 -1
  65. package/docs/decisions/trust-ledger-retention.md +88 -0
  66. package/docs/decisions/workflow-enforcement.md +31 -9
  67. package/docs/fixture-ownership.md +3 -0
  68. package/docs/implementing-trust-reconciliation.md +129 -0
  69. package/docs/index.md +19 -9
  70. package/docs/integrations/flow-agents-console.md +167 -0
  71. package/docs/kit-authoring-guide.md +52 -21
  72. package/docs/spec/builder-flow-runtime.md +80 -0
  73. package/docs/spec/runtime-hook-surface.md +45 -1
  74. package/docs/specs/economics-record-contract.md +270 -0
  75. package/docs/specs/harness-capability-matrix.md +74 -0
  76. package/docs/specs/learning-review-proposals-contract.md +340 -0
  77. package/docs/specs/routing-efficiency-review.md +59 -0
  78. package/docs/verifiable-trust.md +74 -25
  79. package/docs/workflow-usage-guide.md +10 -0
  80. package/evals/acceptance/prove-capture-teeth.sh +132 -0
  81. package/evals/ci/antigaming-suite.sh +1 -0
  82. package/evals/ci/run-baseline.sh +72 -4
  83. package/evals/fixtures/economics/acceptance.json +12 -0
  84. package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
  85. package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
  86. package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
  87. package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
  88. package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
  89. package/evals/fixtures/economics/critique.json +22 -0
  90. package/evals/fixtures/economics/expected-record.json +71 -0
  91. package/evals/fixtures/economics/session-usage-event.json +1 -0
  92. package/evals/fixtures/economics/state.json +11 -0
  93. package/evals/fixtures/economics/transcript.jsonl +3 -0
  94. package/evals/fixtures/hook-influence/cases.json +7 -7
  95. package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
  96. package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
  97. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
  98. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
  99. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
  100. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
  101. package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
  102. package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
  103. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
  104. package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
  105. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
  106. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
  107. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
  108. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
  109. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
  110. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
  111. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
  112. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
  113. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
  114. package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
  115. package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
  116. package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
  117. package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
  118. package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
  119. package/evals/integration/test_builder_entry_enforcement.sh +241 -0
  120. package/evals/integration/test_builder_step_producers.sh +18 -10
  121. package/evals/integration/test_bundle_install.sh +172 -0
  122. package/evals/integration/test_console_tenant_isolation.sh +167 -0
  123. package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
  124. package/evals/integration/test_dual_emit_flow_step.sh +10 -4
  125. package/evals/integration/test_economics_record.sh +674 -0
  126. package/evals/integration/test_effective_backlog_settings.sh +1 -1
  127. package/evals/integration/test_evidence_capture_hook.sh +17 -2
  128. package/evals/integration/test_exemption_usage_review.sh +198 -0
  129. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  130. package/evals/integration/test_flow_kit_install_git.sh +83 -0
  131. package/evals/integration/test_flowdef_session_activation.sh +0 -1
  132. package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
  133. package/evals/integration/test_gate_lockdown.sh +7 -0
  134. package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
  135. package/evals/integration/test_goal_fit_hook.sh +2031 -0
  136. package/evals/integration/test_hook_category_behaviors.sh +8 -1
  137. package/evals/integration/test_hook_influence_cases.sh +25 -1
  138. package/evals/integration/test_install_merge.sh +227 -2
  139. package/evals/integration/test_kit_conformance_levels.sh +6 -6
  140. package/evals/integration/test_learning_review_proposals.sh +329 -0
  141. package/evals/integration/test_liveness_conflict_injection.sh +26 -22
  142. package/evals/integration/test_liveness_console_relay.sh +166 -0
  143. package/evals/integration/test_liveness_heartbeat.sh +17 -17
  144. package/evals/integration/test_liveness_worktree_root.sh +575 -0
  145. package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
  146. package/evals/integration/test_publish_delivery.sh +331 -1
  147. package/evals/integration/test_pull_work_board.sh +200 -0
  148. package/evals/integration/test_pull_work_provider.sh +1 -1
  149. package/evals/integration/test_record_check.sh +378 -0
  150. package/evals/integration/test_routing_efficiency.sh +71 -0
  151. package/evals/integration/test_runtime_adapter_activation.sh +28 -0
  152. package/evals/integration/test_session_resume_roundtrip.sh +16 -19
  153. package/evals/integration/test_skill_drift_check.sh +870 -0
  154. package/evals/integration/test_telemetry.sh +445 -0
  155. package/evals/integration/test_telemetry_doctor.sh +66 -0
  156. package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
  157. package/evals/integration/test_trust_reconcile_negatives.sh +30 -13
  158. package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
  159. package/evals/integration/test_usage_cost.sh +61 -0
  160. package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
  161. package/evals/integration/test_workflow_steering_hook.sh +157 -16
  162. package/evals/integration/test_workspace_settings.sh +176 -0
  163. package/evals/lib/env.sh +26 -0
  164. package/evals/lib/node.sh +8 -0
  165. package/evals/run.sh +29 -0
  166. package/evals/static/test_ci_integration_coverage.sh +115 -0
  167. package/evals/static/test_declared_scope_forms_documented.sh +114 -0
  168. package/evals/static/test_universal_bundles.sh +34 -0
  169. package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
  170. package/evals/static/test_workflow_skills.sh +1 -1
  171. package/kits/builder/flows/build.flow.json +9 -18
  172. package/kits/builder/flows/publish-learn.flow.json +5 -1
  173. package/kits/builder/kit.json +120 -0
  174. package/kits/builder/skills/deliver/SKILL.md +42 -0
  175. package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
  176. package/kits/builder/skills/execute-plan/SKILL.md +9 -0
  177. package/kits/builder/skills/learning-review/SKILL.md +51 -0
  178. package/kits/builder/skills/plan-work/SKILL.md +17 -20
  179. package/kits/builder/skills/pull-work/SKILL.md +21 -0
  180. package/kits/builder/skills/release-readiness/SKILL.md +12 -0
  181. package/kits/knowledge/kit.json +9 -0
  182. package/kits/veritas-governance/docs/README.md +35 -7
  183. package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
  184. package/kits/veritas-governance/kit.json +14 -0
  185. package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
  186. package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
  187. package/package.json +2 -2
  188. package/packaging/manifest.json +29 -0
  189. package/schemas/backlog-provider-settings.schema.json +13 -0
  190. package/schemas/workflow-state.schema.json +44 -0
  191. package/scripts/README.md +4 -0
  192. package/scripts/check-content-boundary.cjs +8 -1
  193. package/scripts/ci/trust-reconcile.js +136 -0
  194. package/scripts/hooks/codex-hook-adapter.js +77 -2
  195. package/scripts/hooks/evidence-capture.js +38 -5
  196. package/scripts/hooks/lib/codex-exit-code.js +316 -0
  197. package/scripts/hooks/lib/kit-catalog.js +235 -0
  198. package/scripts/hooks/lib/liveness-write.js +28 -1
  199. package/scripts/hooks/lib/local-artifact-paths.js +97 -1
  200. package/scripts/hooks/lib/runnable-command.js +177 -0
  201. package/scripts/hooks/lib/skill-drift.js +350 -0
  202. package/scripts/hooks/stop-goal-fit.js +278 -48
  203. package/scripts/hooks/workflow-steering.js +121 -21
  204. package/scripts/install-codex-home.sh +97 -47
  205. package/scripts/install-merge.js +72 -14
  206. package/scripts/install-owned-files.js +178 -0
  207. package/scripts/liveness/relay.sh +84 -0
  208. package/scripts/telemetry/economics-record.schema.json +145 -0
  209. package/scripts/telemetry/economics-record.sh +331 -0
  210. package/scripts/telemetry/install-console-config.sh +25 -4
  211. package/scripts/telemetry/learning-review-decide.sh +124 -0
  212. package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
  213. package/scripts/telemetry/learning-review-proposals.sh +484 -0
  214. package/scripts/telemetry/lib/config.sh +102 -12
  215. package/scripts/telemetry/lib/pricing.sh +14 -6
  216. package/scripts/telemetry/lib/session.sh +3 -0
  217. package/scripts/telemetry/lib/transport.sh +133 -15
  218. package/scripts/telemetry/lib/usage.sh +121 -28
  219. package/scripts/telemetry/routing-efficiency.sh +0 -0
  220. package/scripts/telemetry/telemetry.conf +6 -0
  221. package/scripts/telemetry/telemetry.sh +48 -0
  222. package/src/builder-flow-run-adapter.ts +357 -0
  223. package/src/builder-flow-runtime.ts +348 -0
  224. package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
  225. package/src/cli/builder-flow-runtime.test.mjs +213 -0
  226. package/src/cli/builder-run.ts +28 -0
  227. package/src/cli/codex-agent-routing.test.mjs +44 -0
  228. package/src/cli/codex-exit-code.test.mjs +207 -0
  229. package/src/cli/console-connect-options.test.mjs +329 -0
  230. package/src/cli/console-telemetry-validate.test.mjs +157 -0
  231. package/src/cli/effective-backlog-settings.ts +68 -2
  232. package/src/cli/flow-resolver-composition.test.mjs +101 -0
  233. package/src/cli/init.test.mjs +161 -0
  234. package/src/cli/init.ts +407 -62
  235. package/src/cli/kit-metadata-security.test.mjs +443 -0
  236. package/src/cli/kit.ts +50 -12
  237. package/src/cli/pull-work-provider.ts +377 -3
  238. package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
  239. package/src/cli/skill-drift-check.ts +196 -0
  240. package/src/cli/telemetry-doctor.test.mjs +53 -0
  241. package/src/cli/telemetry-doctor.ts +50 -7
  242. package/src/cli/validate-hook-influence.ts +37 -6
  243. package/src/cli/workflow-sidecar.ts +1150 -151
  244. package/src/cli.ts +5 -0
  245. package/src/flow-kit/validate.ts +277 -38
  246. package/src/index.ts +19 -0
  247. package/src/lib/console-connect-options.ts +261 -0
  248. package/src/lib/console-telemetry-validate.ts +88 -0
  249. package/src/lib/flow-resolver.ts +153 -10
  250. package/src/lib/fs.ts +160 -0
  251. package/src/lib/local-artifact-root.ts +129 -3
  252. package/src/runtime-adapters.ts +113 -33
  253. package/src/tools/build-universal-bundles.ts +36 -2
  254. package/src/tools/codex-agent-routing.ts +48 -0
  255. package/src/tools/generate-context-map.ts +1 -0
  256. package/src/tools/validate-source-tree.ts +26 -1
@@ -0,0 +1,329 @@
1
+ // Unit tests for src/lib/console-connect-options.ts (install-flow-console-connect
2
+ // PR2, AC-G1/AC-G3/AC-REGRESSION): the pure decision logic behind the guided
3
+ // "Connect to Kontour Console?" wizard.
4
+ //
5
+ // Loaded from the built JS (mirrors src/cli/console-telemetry-validate.test.mjs's
6
+ // import-from-build convention, testing a src/lib/ module from src/cli/).
7
+ // Run: `npm run test:unit`, or directly after `npm run build`:
8
+ // node --test src/cli/console-connect-options.test.mjs
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+
12
+ import {
13
+ normalizeConsoleConnectChoice,
14
+ mapConsoleConnectChoiceToSinks,
15
+ needsConsoleUrlPrompt,
16
+ needsConsoleCredentialPrompts,
17
+ resolveSelfHostedUrlOrFallback,
18
+ runConsoleConnectWizard,
19
+ describeConsoleStatus,
20
+ buildPostInstallSummaryLines,
21
+ } from "../../build/src/lib/console-connect-options.js";
22
+
23
+ // --- normalizeConsoleConnectChoice ---
24
+
25
+ test("normalizeConsoleConnectChoice: each accepted spelling for hosted", () => {
26
+ for (const answer of ["hosted", "Hosted", "HOSTED", "h", "H"]) {
27
+ assert.equal(normalizeConsoleConnectChoice(answer, "skip"), "hosted", `answer=${answer}`);
28
+ }
29
+ });
30
+
31
+ test("normalizeConsoleConnectChoice: each accepted spelling for local", () => {
32
+ for (const answer of ["local", "Local", "l", "L"]) {
33
+ assert.equal(normalizeConsoleConnectChoice(answer, "skip"), "local", `answer=${answer}`);
34
+ }
35
+ });
36
+
37
+ test("normalizeConsoleConnectChoice: each accepted spelling for self-hosted", () => {
38
+ for (const answer of ["self-hosted", "Self-Hosted", "selfhosted", "self", "s", "S"]) {
39
+ assert.equal(normalizeConsoleConnectChoice(answer, "skip"), "self-hosted", `answer=${answer}`);
40
+ }
41
+ });
42
+
43
+ test("normalizeConsoleConnectChoice: each accepted spelling for skip", () => {
44
+ for (const answer of ["skip", "Skip", "none", "n", "N"]) {
45
+ assert.equal(normalizeConsoleConnectChoice(answer, "hosted"), "skip", `answer=${answer}`);
46
+ }
47
+ });
48
+
49
+ test("normalizeConsoleConnectChoice: blank falls back to fallback", () => {
50
+ assert.equal(normalizeConsoleConnectChoice("", "hosted"), "hosted");
51
+ assert.equal(normalizeConsoleConnectChoice(" ", "local"), "local");
52
+ });
53
+
54
+ test("normalizeConsoleConnectChoice: garbage falls back to fallback", () => {
55
+ assert.equal(normalizeConsoleConnectChoice("banana", "hosted"), "hosted");
56
+ assert.equal(normalizeConsoleConnectChoice("yes please", "skip"), "skip");
57
+ });
58
+
59
+ // --- mapConsoleConnectChoiceToSinks ---
60
+
61
+ test("mapConsoleConnectChoiceToSinks: all four choices map to the specified sink arrays", () => {
62
+ assert.deepEqual(mapConsoleConnectChoiceToSinks("hosted"), ["kontour-hosted-console"]);
63
+ assert.deepEqual(mapConsoleConnectChoiceToSinks("local"), ["local-kontour-console"]);
64
+ assert.deepEqual(mapConsoleConnectChoiceToSinks("self-hosted"), ["user-hosted-console"]);
65
+ assert.deepEqual(mapConsoleConnectChoiceToSinks("skip"), ["local-files"]);
66
+ });
67
+
68
+ // --- needsConsoleUrlPrompt / needsConsoleCredentialPrompts ---
69
+
70
+ test("needsConsoleUrlPrompt: true only for self-hosted", () => {
71
+ assert.equal(needsConsoleUrlPrompt("self-hosted"), true);
72
+ assert.equal(needsConsoleUrlPrompt("hosted"), false);
73
+ assert.equal(needsConsoleUrlPrompt("local"), false);
74
+ assert.equal(needsConsoleUrlPrompt("skip"), false);
75
+ });
76
+
77
+ test("needsConsoleCredentialPrompts: true for hosted/local/self-hosted, false for skip", () => {
78
+ assert.equal(needsConsoleCredentialPrompts("hosted"), true);
79
+ assert.equal(needsConsoleCredentialPrompts("local"), true);
80
+ assert.equal(needsConsoleCredentialPrompts("self-hosted"), true);
81
+ assert.equal(needsConsoleCredentialPrompts("skip"), false);
82
+ });
83
+
84
+ // --- resolveSelfHostedUrlOrFallback ---
85
+
86
+ test("resolveSelfHostedUrlOrFallback: blank URL falls back to local-files with a non-empty warning", () => {
87
+ const result = resolveSelfHostedUrlOrFallback("");
88
+ assert.deepEqual(result.sinks, ["local-files"]);
89
+ assert.equal(result.url, "");
90
+ assert.ok(result.fallbackWarning && result.fallbackWarning.length > 0);
91
+ });
92
+
93
+ test("resolveSelfHostedUrlOrFallback: whitespace-only URL also falls back", () => {
94
+ const result = resolveSelfHostedUrlOrFallback(" ");
95
+ assert.deepEqual(result.sinks, ["local-files"]);
96
+ assert.ok(result.fallbackWarning);
97
+ });
98
+
99
+ test("resolveSelfHostedUrlOrFallback: non-blank (even malformed) URL passes through unchanged, no fallback", () => {
100
+ const result = resolveSelfHostedUrlOrFallback("not-a-url");
101
+ assert.deepEqual(result.sinks, ["user-hosted-console"]);
102
+ assert.equal(result.url, "not-a-url");
103
+ assert.equal(result.fallbackWarning, undefined);
104
+ });
105
+
106
+ test("resolveSelfHostedUrlOrFallback: well-formed https URL passes through unchanged", () => {
107
+ const result = resolveSelfHostedUrlOrFallback("https://console.example.test");
108
+ assert.deepEqual(result.sinks, ["user-hosted-console"]);
109
+ assert.equal(result.url, "https://console.example.test");
110
+ assert.equal(result.fallbackWarning, undefined);
111
+ });
112
+
113
+ // --- runConsoleConnectWizard ---
114
+
115
+ function scriptedIo(answers) {
116
+ const queue = [...answers];
117
+ const prompts = [];
118
+ const hiddenPrompts = [];
119
+ return {
120
+ io: {
121
+ ask: async (prompt) => {
122
+ prompts.push(prompt);
123
+ if (queue.length === 0) throw new Error("scriptedIo: ask() called more times than answers were scripted");
124
+ return queue.shift();
125
+ },
126
+ askHidden: async (prompt) => {
127
+ hiddenPrompts.push(prompt);
128
+ if (queue.length === 0) throw new Error("scriptedIo: askHidden() called more times than answers were scripted");
129
+ return queue.shift();
130
+ },
131
+ },
132
+ prompts,
133
+ hiddenPrompts,
134
+ };
135
+ }
136
+
137
+ const defaults = { hostedUrl: "https://console.kontourai.io", localUrl: "http://127.0.0.1:3737" };
138
+
139
+ test("runConsoleConnectWizard: hosted path prompts token+tenant, no URL prompt", async () => {
140
+ const { io, prompts, hiddenPrompts } = scriptedIo(["hosted", "tok-abc123", "tenant-a"]);
141
+ const result = await runConsoleConnectWizard(io, defaults);
142
+ assert.deepEqual(result.telemetrySinks, ["kontour-hosted-console"]);
143
+ assert.equal(result.consoleUrl, undefined);
144
+ assert.equal(result.consoleTokenValue, "tok-abc123");
145
+ assert.equal(result.consoleTenant, "tenant-a");
146
+ assert.deepEqual(result.warnings, []);
147
+ assert.equal(prompts.length, 2); // choice + tenant (no URL prompt for hosted)
148
+ assert.equal(hiddenPrompts.length, 1);
149
+ assert.ok(hiddenPrompts[0].includes("CONSOLE_AUTH_TOKENS_JSON"));
150
+ });
151
+
152
+ test("runConsoleConnectWizard: local path prompts token+tenant, no URL prompt", async () => {
153
+ const { io } = scriptedIo(["local", "", ""]);
154
+ const result = await runConsoleConnectWizard(io, defaults);
155
+ assert.deepEqual(result.telemetrySinks, ["local-kontour-console"]);
156
+ assert.equal(result.consoleUrl, undefined);
157
+ assert.equal(result.consoleTokenValue, undefined);
158
+ assert.equal(result.consoleTenant, undefined);
159
+ assert.deepEqual(result.warnings, []);
160
+ });
161
+
162
+ test("runConsoleConnectWizard: self-hosted path with a valid URL prompts URL+token+tenant", async () => {
163
+ const { io } = scriptedIo(["self-hosted", "https://console.example.test", "tok-xyz", "tenant-b"]);
164
+ const result = await runConsoleConnectWizard(io, defaults);
165
+ assert.deepEqual(result.telemetrySinks, ["user-hosted-console"]);
166
+ assert.equal(result.consoleUrl, "https://console.example.test");
167
+ assert.equal(result.consoleTokenValue, "tok-xyz");
168
+ assert.equal(result.consoleTenant, "tenant-b");
169
+ assert.deepEqual(result.warnings, []);
170
+ });
171
+
172
+ test("runConsoleConnectWizard: self-hosted path with a blank URL falls back to local-files and skips credential prompts", async () => {
173
+ const { io, prompts, hiddenPrompts } = scriptedIo(["self-hosted", ""]);
174
+ const result = await runConsoleConnectWizard(io, defaults);
175
+ assert.deepEqual(result.telemetrySinks, ["local-files"]);
176
+ assert.equal(result.consoleUrl, undefined);
177
+ assert.equal(result.consoleTokenValue, undefined);
178
+ assert.equal(result.consoleTenant, undefined);
179
+ assert.equal(result.warnings.length, 1);
180
+ assert.ok(result.warnings[0].includes("requires a URL"));
181
+ assert.equal(prompts.length, 2); // choice + URL only -- no credential prompts fired
182
+ assert.equal(hiddenPrompts.length, 0);
183
+ });
184
+
185
+ test("runConsoleConnectWizard: skip path prompts nothing beyond the initial choice", async () => {
186
+ const { io, prompts, hiddenPrompts } = scriptedIo(["skip"]);
187
+ const result = await runConsoleConnectWizard(io, defaults);
188
+ assert.deepEqual(result.telemetrySinks, ["local-files"]);
189
+ assert.equal(result.consoleUrl, undefined);
190
+ assert.equal(result.consoleTokenValue, undefined);
191
+ assert.equal(result.consoleTenant, undefined);
192
+ assert.deepEqual(result.warnings, []);
193
+ assert.equal(prompts.length, 1);
194
+ assert.equal(hiddenPrompts.length, 0);
195
+ });
196
+
197
+ test("runConsoleConnectWizard: blank choice answer defaults to hosted", async () => {
198
+ const { io } = scriptedIo(["", "", ""]);
199
+ const result = await runConsoleConnectWizard(io, defaults);
200
+ assert.deepEqual(result.telemetrySinks, ["kontour-hosted-console"]);
201
+ });
202
+
203
+ test("runConsoleConnectWizard: invalid token format produces a warning but is NOT dropped from the returned value", async () => {
204
+ const { io } = scriptedIo(["hosted", "has a space and \t tab", "tenant-a"]);
205
+ const result = await runConsoleConnectWizard(io, defaults);
206
+ assert.equal(result.consoleTokenValue, "has a space and \t tab");
207
+ assert.ok(result.warnings.some((w) => w.includes("token format")));
208
+ });
209
+
210
+ test("runConsoleConnectWizard: invalid tenant format produces a warning but is NOT dropped from the returned value", async () => {
211
+ const { io } = scriptedIo(["hosted", "tok-ok", "tenant with spaces"]);
212
+ const result = await runConsoleConnectWizard(io, defaults);
213
+ assert.equal(result.consoleTenant, "tenant with spaces");
214
+ assert.ok(result.warnings.some((w) => w.includes("tenant format")));
215
+ });
216
+
217
+ test("runConsoleConnectWizard: invalid (malformed but non-blank) self-hosted URL produces a warning but does not fall back", async () => {
218
+ const { io } = scriptedIo(["self-hosted", "not-a-url", "", ""]);
219
+ const result = await runConsoleConnectWizard(io, defaults);
220
+ assert.deepEqual(result.telemetrySinks, ["user-hosted-console"]);
221
+ assert.equal(result.consoleUrl, "not-a-url");
222
+ assert.ok(result.warnings.some((w) => w.includes("does not look valid")));
223
+ });
224
+
225
+ // --- describeConsoleStatus ---
226
+
227
+ test("describeConsoleStatus: checked+ok=true -> connected-verified", () => {
228
+ const result = describeConsoleStatus({ console: { sink: "console", reachability: { checked: true, ok: true } } });
229
+ assert.equal(result.status, "connected-verified");
230
+ });
231
+
232
+ test("describeConsoleStatus: attempted-and-failed (checked=true, ok=false) -> connected-unverified, detail still surfaces the error", () => {
233
+ const result = describeConsoleStatus({
234
+ console: { sink: "console", reachability: { checked: true, ok: false, error: "timeout after 2000ms" } },
235
+ });
236
+ assert.equal(result.status, "connected-unverified");
237
+ assert.equal(result.detail, "timeout after 2000ms");
238
+ assert.ok(!result.detail.includes("allow-network"), "attempted-and-failed detail must not be replaced by the not-checked hint");
239
+ });
240
+
241
+ test("describeConsoleStatus: attempted-and-failed (checked=true, ok=false, no error) -> connected-unverified, detail still surfaces the statusCode", () => {
242
+ const result = describeConsoleStatus({
243
+ console: { sink: "console", reachability: { checked: true, ok: false, statusCode: 503 } },
244
+ });
245
+ assert.equal(result.status, "connected-unverified");
246
+ assert.ok(result.detail.includes("503"));
247
+ assert.ok(!result.detail.includes("allow-network"), "attempted-and-failed detail must not be replaced by the not-checked hint");
248
+ });
249
+
250
+ test("describeConsoleStatus: sink=local-only -> local-only regardless of reachability shape", () => {
251
+ const result1 = describeConsoleStatus({ console: { sink: "local-only", reachability: { checked: false, ok: null } } });
252
+ assert.equal(result1.status, "local-only");
253
+ const result2 = describeConsoleStatus({ console: { sink: "local-only", reachability: { checked: true, ok: true } } });
254
+ assert.equal(result2.status, "local-only");
255
+ });
256
+
257
+ test("describeConsoleStatus: sink=console but not checked -> connected-unverified (honestly not verified) with an actionable self-hosted/BYO hint", () => {
258
+ const result = describeConsoleStatus({ console: { sink: "console", reachability: { checked: false, ok: null } } });
259
+ assert.equal(result.status, "connected-unverified");
260
+ assert.ok(result.detail.startsWith("not checked"), "must not silently drop the honest 'not checked' verdict");
261
+ assert.ok(result.detail.includes("flow-agents telemetry-doctor --allow-network"), "must point at the flag that unblocks self-hosted/BYO reachability checks");
262
+ });
263
+
264
+ test("describeConsoleStatus: not-checked-via-not-allowed-guard (checked=false with an error set by endpointAllowed()) still gets the same actionable hint, not the bare error", () => {
265
+ const result = describeConsoleStatus({
266
+ console: { sink: "console", reachability: { checked: false, ok: null, error: "endpoint is not allowed" } },
267
+ });
268
+ assert.equal(result.status, "connected-unverified");
269
+ assert.ok(result.detail.includes("--allow-network"));
270
+ });
271
+
272
+ // --- buildPostInstallSummaryLines ---
273
+
274
+ test("buildPostInstallSummaryLines: never contains the literal token/secret value", () => {
275
+ const lines = buildPostInstallSummaryLines({
276
+ runtime: "base",
277
+ runtimeAutoDetected: false,
278
+ dest: "/tmp/some-dest",
279
+ telemetrySinks: ["kontour-hosted-console"],
280
+ consoleStatus: { status: "connected-verified" },
281
+ tokenConfigured: true,
282
+ tenantConfigured: true,
283
+ nextSteps: ["step one"],
284
+ });
285
+ const joined = lines.join("\n");
286
+ assert.ok(!joined.includes("secret-token-value"));
287
+ assert.ok(joined.includes("configured"));
288
+ });
289
+
290
+ test("buildPostInstallSummaryLines: runtime annotated (auto-detected) only when the flag is set", () => {
291
+ const detected = buildPostInstallSummaryLines({
292
+ runtime: "codex",
293
+ runtimeAutoDetected: true,
294
+ dest: "/tmp/dest",
295
+ telemetrySinks: ["local-files"],
296
+ consoleStatus: { status: "local-only" },
297
+ tokenConfigured: false,
298
+ tenantConfigured: false,
299
+ nextSteps: [],
300
+ });
301
+ assert.ok(detected.some((line) => line.includes("codex (auto-detected)")));
302
+
303
+ const notDetected = buildPostInstallSummaryLines({
304
+ runtime: "codex",
305
+ runtimeAutoDetected: false,
306
+ dest: "/tmp/dest",
307
+ telemetrySinks: ["local-files"],
308
+ consoleStatus: { status: "local-only" },
309
+ tokenConfigured: false,
310
+ tenantConfigured: false,
311
+ nextSteps: [],
312
+ });
313
+ assert.ok(notDetected.some((line) => line.includes("Runtime: codex") && !line.includes("auto-detected")));
314
+ });
315
+
316
+ test("buildPostInstallSummaryLines: local-only status includes 'local-only' marker and omits token/tenant lines", () => {
317
+ const lines = buildPostInstallSummaryLines({
318
+ runtime: "base",
319
+ runtimeAutoDetected: false,
320
+ dest: "/tmp/dest",
321
+ telemetrySinks: ["local-files"],
322
+ consoleStatus: { status: "local-only" },
323
+ tokenConfigured: false,
324
+ tenantConfigured: false,
325
+ nextSteps: [],
326
+ });
327
+ assert.ok(lines.some((line) => line.includes("Console:") && line.includes("local-only")));
328
+ assert.ok(!lines.some((line) => line.includes("Console token")));
329
+ });
@@ -0,0 +1,157 @@
1
+ // Unit tests for src/lib/console-telemetry-validate.ts (install-flow-foundations
2
+ // Thread D / AC4): pure predicates mirroring the bash Console telemetry
3
+ // validators (scripts/telemetry/install-console-config.sh, scripts/telemetry/lib/transport.sh).
4
+ //
5
+ // Loaded from the built JS (mirrors src/cli/sidecar-pure-helpers.test.mjs's
6
+ // import-from-build convention, and src/cli/codex-exit-code.test.mjs's precedent
7
+ // for a *.test.mjs under src/cli/ testing a module that lives elsewhere).
8
+ // Run: `npm run test:unit`, or directly after `npm run build`:
9
+ // node --test src/cli/console-telemetry-validate.test.mjs
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+
13
+ import {
14
+ hasControlChars,
15
+ isValidConsoleUrl,
16
+ isValidConsoleEndpointStrict,
17
+ isValidConsoleToken,
18
+ isValidConsoleTenant,
19
+ isSafeConsoleTenantForRuntime,
20
+ } from "../../build/src/lib/console-telemetry-validate.js";
21
+
22
+ // --- hasControlChars ---
23
+
24
+ test("hasControlChars: rejects newline, carriage return, tab", () => {
25
+ assert.equal(hasControlChars("a\nb"), true);
26
+ assert.equal(hasControlChars("a\rb"), true);
27
+ assert.equal(hasControlChars("a\tb"), true);
28
+ });
29
+
30
+ test("hasControlChars: plain value has none", () => {
31
+ assert.equal(hasControlChars("plain-value"), false);
32
+ });
33
+
34
+ // --- isValidConsoleUrl (mirrors install-console-config.sh's validate_url) ---
35
+
36
+ test("isValidConsoleUrl: blank is valid (optional field)", () => {
37
+ assert.equal(isValidConsoleUrl(""), true);
38
+ });
39
+
40
+ test("isValidConsoleUrl: https accepted", () => {
41
+ assert.equal(isValidConsoleUrl("https://console.example.com"), true);
42
+ });
43
+
44
+ test("isValidConsoleUrl: generic non-local http rejected", () => {
45
+ assert.equal(isValidConsoleUrl("http://example.com"), false);
46
+ });
47
+
48
+ test("isValidConsoleUrl: localhost http accepted (bare, with port, with path)", () => {
49
+ assert.equal(isValidConsoleUrl("http://localhost"), true);
50
+ assert.equal(isValidConsoleUrl("http://localhost:3000"), true);
51
+ assert.equal(isValidConsoleUrl("http://localhost/path"), true);
52
+ });
53
+
54
+ test("isValidConsoleUrl: 127.0.0.1 http accepted (bare, with port, with path)", () => {
55
+ assert.equal(isValidConsoleUrl("http://127.0.0.1"), true);
56
+ assert.equal(isValidConsoleUrl("http://127.0.0.1:9"), true);
57
+ assert.equal(isValidConsoleUrl("http://127.0.0.1/path"), true);
58
+ });
59
+
60
+ test("isValidConsoleUrl: control characters rejected", () => {
61
+ assert.equal(isValidConsoleUrl("https://example.com\n"), false);
62
+ assert.equal(isValidConsoleUrl("https://example.com\t"), false);
63
+ });
64
+
65
+ test("isValidConsoleUrl: a literal double-quote is NOT rejected (unlike the strict endpoint validator)", () => {
66
+ assert.equal(isValidConsoleUrl('https://example.com/"'), true);
67
+ });
68
+
69
+ // --- isValidConsoleEndpointStrict (mirrors transport.sh's console_telemetry_endpoint_allowed) ---
70
+
71
+ test("isValidConsoleEndpointStrict: blank is INVALID (non-empty required, unlike isValidConsoleUrl)", () => {
72
+ assert.equal(isValidConsoleEndpointStrict(""), false);
73
+ });
74
+
75
+ test("isValidConsoleEndpointStrict: https accepted", () => {
76
+ assert.equal(isValidConsoleEndpointStrict("https://console.example.com/api/telemetry/records"), true);
77
+ });
78
+
79
+ test("isValidConsoleEndpointStrict: localhost/127.0.0.1 http accepted", () => {
80
+ assert.equal(isValidConsoleEndpointStrict("http://localhost:3000"), true);
81
+ assert.equal(isValidConsoleEndpointStrict("http://127.0.0.1:9"), true);
82
+ });
83
+
84
+ test("isValidConsoleEndpointStrict: generic non-local http rejected", () => {
85
+ assert.equal(isValidConsoleEndpointStrict("http://example.com"), false);
86
+ });
87
+
88
+ test("isValidConsoleEndpointStrict: newline and carriage return rejected", () => {
89
+ assert.equal(isValidConsoleEndpointStrict("https://example.com\n"), false);
90
+ assert.equal(isValidConsoleEndpointStrict("https://example.com\r"), false);
91
+ });
92
+
93
+ test("isValidConsoleEndpointStrict: tab is ACCEPTED (bash console_telemetry_endpoint_allowed does not reject tab, unlike isValidConsoleUrl/validate_url)", () => {
94
+ assert.equal(isValidConsoleEndpointStrict("https://example.com\t/path"), true);
95
+ });
96
+
97
+ test("isValidConsoleEndpointStrict: a literal double-quote is rejected (unlike isValidConsoleUrl)", () => {
98
+ assert.equal(isValidConsoleEndpointStrict('https://example.com/"'), false);
99
+ });
100
+
101
+ // --- isValidConsoleToken (mirrors install-console-config.sh's validate_token) ---
102
+
103
+ test("isValidConsoleToken: blank is valid (optional field)", () => {
104
+ assert.equal(isValidConsoleToken(""), true);
105
+ });
106
+
107
+ test("isValidConsoleToken: length 4096 accepted, 4097 rejected", () => {
108
+ assert.equal(isValidConsoleToken("a".repeat(4096)), true);
109
+ assert.equal(isValidConsoleToken("a".repeat(4097)), false);
110
+ });
111
+
112
+ test("isValidConsoleToken: a value containing a space is rejected (charset)", () => {
113
+ assert.equal(isValidConsoleToken("has a space"), false);
114
+ });
115
+
116
+ test("isValidConsoleToken: control characters rejected", () => {
117
+ assert.equal(isValidConsoleToken("abc\ndef"), false);
118
+ });
119
+
120
+ test("isValidConsoleToken: charset accepts letters, digits, and . _ ~ + / = -", () => {
121
+ assert.equal(isValidConsoleToken("Az09._~+/=-"), true);
122
+ });
123
+
124
+ // --- isValidConsoleTenant (mirrors install-console-config.sh's validate_tenant) ---
125
+
126
+ test("isValidConsoleTenant: blank is valid (optional field, no length bound)", () => {
127
+ assert.equal(isValidConsoleTenant(""), true);
128
+ });
129
+
130
+ test("isValidConsoleTenant: charset accepts letters, digits, and . _ : -", () => {
131
+ assert.equal(isValidConsoleTenant("tenant-1.demo:prod_A"), true);
132
+ });
133
+
134
+ test("isValidConsoleTenant: a value with an unsupported character is rejected", () => {
135
+ assert.equal(isValidConsoleTenant("tenant/1"), false);
136
+ assert.equal(isValidConsoleTenant("tenant 1"), false);
137
+ });
138
+
139
+ test("isValidConsoleTenant: no length bound (129 chars of valid charset still accepted)", () => {
140
+ assert.equal(isValidConsoleTenant("a".repeat(129)), true);
141
+ });
142
+
143
+ // --- isSafeConsoleTenantForRuntime (mirrors transport.sh's console_telemetry_safe_tenant) ---
144
+
145
+ test("isSafeConsoleTenantForRuntime: blank is INVALID (non-empty required, unlike isValidConsoleTenant)", () => {
146
+ assert.equal(isSafeConsoleTenantForRuntime(""), false);
147
+ });
148
+
149
+ test("isSafeConsoleTenantForRuntime: length 128 accepted, 129 rejected", () => {
150
+ assert.equal(isSafeConsoleTenantForRuntime("a".repeat(128)), true);
151
+ assert.equal(isSafeConsoleTenantForRuntime("a".repeat(129)), false);
152
+ });
153
+
154
+ test("isSafeConsoleTenantForRuntime: same charset as isValidConsoleTenant", () => {
155
+ assert.equal(isSafeConsoleTenantForRuntime("tenant-1.demo:prod_A"), true);
156
+ assert.equal(isSafeConsoleTenantForRuntime("tenant/1"), false);
157
+ });
@@ -7,6 +7,7 @@ import { parseArgs, flagString, flagBool } from "../lib/args.js";
7
7
  import { readJson } from "../lib/fs.js";
8
8
 
9
9
  const PROJECT_SETTINGS_RELATIVE_PATH = path.join("context", "settings", "backlog-provider-settings.json");
10
+ const WORKSPACE_SETTINGS_RELATIVE_PATH = path.join(".kontourai", "settings.json");
10
11
 
11
12
  function loadSettings(file: string): Record<string, unknown> | null {
12
13
  if (!fs.existsSync(file)) return null;
@@ -36,6 +37,14 @@ function currentRepo(repoPath: string): { owner: string; name: string } | null {
36
37
  return null;
37
38
  }
38
39
 
40
+ function gitRoot(repoPath: string): string | null {
41
+ try {
42
+ return child_process.execFileSync("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
39
48
  function merge(base: unknown, override: unknown): Record<string, unknown> | null {
40
49
  if (!base && !override) return null;
41
50
  if (!base) return structuredClone(override) as Record<string, unknown>;
@@ -58,6 +67,43 @@ function findProject(settings: Record<string, unknown> | null, repo: { owner: st
58
67
  }) as Record<string, unknown> | undefined) ?? null;
59
68
  }
60
69
 
70
+ function workspaceRepos(settings: Record<string, unknown> | null): string[] {
71
+ const workspace = settings?.workspace;
72
+ if (typeof workspace !== "object" || workspace === null || Array.isArray(workspace)) return [];
73
+ const repos = (workspace as Record<string, unknown>).repos;
74
+ return Array.isArray(repos) ? repos.flatMap((repo) => typeof repo === "string" && repo.trim() ? [repo.trim()] : []) : [];
75
+ }
76
+
77
+ function hasWorkspaceSettings(repoPath: string): boolean {
78
+ return fs.existsSync(path.join(repoPath, WORKSPACE_SETTINGS_RELATIVE_PATH));
79
+ }
80
+
81
+ function isGitRepoRoot(candidate: string): boolean {
82
+ if (fs.existsSync(path.join(candidate, ".git"))) return true;
83
+ try {
84
+ const root = child_process.execFileSync("git", ["-C", candidate, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
85
+ return fs.realpathSync(root) === fs.realpathSync(candidate);
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function depthOneGitRepoCount(repoPath: string): number {
92
+ if (!fs.existsSync(repoPath)) return 0;
93
+ let count = 0;
94
+ for (const name of fs.readdirSync(repoPath)) {
95
+ const candidate = path.join(repoPath, name);
96
+ if (!fs.statSync(candidate).isDirectory()) continue;
97
+ if (isGitRepoRoot(candidate)) count += 1;
98
+ if (count >= 2) return count;
99
+ }
100
+ return count;
101
+ }
102
+
103
+ function isWorkspacePath(repoPath: string): boolean {
104
+ return hasWorkspaceSettings(repoPath) || depthOneGitRepoCount(repoPath) >= 2;
105
+ }
106
+
61
107
  function defaultProjectSettingsPath(): string {
62
108
  let cursor = path.dirname(fileURLToPath(import.meta.url));
63
109
  while (true) {
@@ -70,13 +116,33 @@ function defaultProjectSettingsPath(): string {
70
116
  }
71
117
 
72
118
  function effective(repoPath: string, projectSettings: string, globalSettings: string): [Record<string, unknown>, number] {
119
+ const globalDoc = loadSettings(globalSettings);
120
+ const root = gitRoot(repoPath);
121
+ if (!root && isWorkspacePath(repoPath)) {
122
+ const workspaceSettings = path.join(repoPath, WORKSPACE_SETTINGS_RELATIVE_PATH);
123
+ const workspaceDoc = loadSettings(workspaceSettings);
124
+ const effectiveSettings = merge(globalDoc?.defaults, workspaceDoc?.defaults);
125
+ const repos = workspaceRepos(workspaceDoc);
126
+ if (effectiveSettings) {
127
+ if (repos.length) (effectiveSettings as Record<string, unknown>).workspace = { repos };
128
+ return [{
129
+ status: "configured",
130
+ scope: "workspace",
131
+ workspace: { root: repoPath, settings_path: workspaceSettings, repos },
132
+ source: workspaceDoc?.defaults ? "workspace" : "global",
133
+ precedence: ["workspace.defaults", "global.defaults"],
134
+ settings: effectiveSettings,
135
+ }, 0];
136
+ }
137
+ return [{ status: "ask_user", scope: "workspace", reason: "no_backlog_provider_settings", message: "Ask the user which backlog WorkItemProvider and BoardProvider to use for this workspace.", workspace: { root: repoPath, settings_path: workspaceSettings, repos }, resolution: { workspace_settings_path: workspaceSettings, global_settings_path: globalSettings, checked: ["workspace", "global"] } }, 2];
138
+ }
139
+
73
140
  const repo = currentRepo(repoPath);
74
141
  const projectDoc = loadSettings(projectSettings);
75
- const globalDoc = loadSettings(globalSettings);
76
142
  if (!repo) return [{ status: "ask_user", reason: "could_not_identify_current_repo", message: "Ask the user which backlog WorkItemProvider and BoardProvider to use for this workspace.", resolution: { project_settings_path: projectSettings, global_settings_path: globalSettings } }, 2];
77
143
  const effectiveSettings = merge(merge(merge(globalDoc?.defaults, findProject(globalDoc, repo)), projectDoc?.defaults), findProject(projectDoc, repo));
78
144
  if (!effectiveSettings) return [{ status: "ask_user", reason: "no_backlog_provider_settings", message: "Ask the user which backlog WorkItemProvider and BoardProvider to use before selecting work.", current_repo: repo, resolution: { project_settings_path: projectSettings, global_settings_path: globalSettings, checked: ["project", "global"] } }, 2];
79
- return [{ status: "configured", current_repo: repo, source: findProject(projectDoc, repo) || projectDoc?.defaults ? "project" : "global", precedence: ["project.projects match", "project.defaults", "global.projects match", "global.defaults"], settings: effectiveSettings }, 0];
145
+ return [{ status: "configured", scope: "repo", current_repo: repo, source: findProject(projectDoc, repo) || projectDoc?.defaults ? "project" : "global", precedence: ["project.projects match", "project.defaults", "global.projects match", "global.defaults"], settings: effectiveSettings }, 0];
80
146
  }
81
147
 
82
148
  export function main(argv = process.argv.slice(2)): number {