@event4u/agent-config 8.4.0 → 8.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 (126) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  4. package/dist/discovery/deprecation-report.md +1 -1
  5. package/dist/discovery/discovery-manifest.json +1 -1
  6. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  7. package/dist/discovery/discovery-manifest.summary.md +1 -1
  8. package/dist/discovery/orphan-report.md +1 -1
  9. package/dist/discovery/packs.json +1 -1
  10. package/dist/discovery/trust-report.md +1 -1
  11. package/dist/discovery/workspaces.json +1 -1
  12. package/dist/mcp/registry-manifest.json +1 -1
  13. package/package.json +4 -1
  14. package/src/agent-src/README.md +64 -0
  15. package/src/agent-src/scripts/archive_completed_roadmaps.ts +497 -0
  16. package/src/agent-src/scripts/scan-seeder-violations.php +145 -0
  17. package/src/agent-src/scripts/update_roadmap_progress.ts +1087 -0
  18. package/src/agent-src/templates/AGENTS.md +34 -0
  19. package/src/agent-src/templates/scripts/README.md +195 -0
  20. package/src/agent-src/templates/scripts/check_memory.ts +662 -0
  21. package/src/agent-src/templates/scripts/check_memory_proposal.ts +370 -0
  22. package/src/agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
  23. package/src/agent-src/templates/scripts/implement_ticket/__main__.ts +27 -0
  24. package/src/agent-src/templates/scripts/memory_hash.ts +355 -0
  25. package/src/agent-src/templates/scripts/memory_lookup.ts +1089 -0
  26. package/src/agent-src/templates/scripts/memory_report.ts +868 -0
  27. package/src/agent-src/templates/scripts/memory_signal.ts +444 -0
  28. package/src/agent-src/templates/scripts/memory_status.ts +215 -0
  29. package/src/agent-src/templates/scripts/ownership-map.example.yml +87 -0
  30. package/src/agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
  31. package/src/agent-src/templates/scripts/pr_review_routing.ts +544 -0
  32. package/src/agent-src/templates/scripts/pr_risk_review.ts +0 -0
  33. package/src/agent-src/templates/scripts/telemetry/aggregator.ts +0 -0
  34. package/src/agent-src/templates/scripts/telemetry/boundary.ts +164 -0
  35. package/src/agent-src/templates/scripts/telemetry/engagement.ts +479 -0
  36. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +394 -0
  37. package/src/agent-src/templates/scripts/telemetry/settings.ts +210 -0
  38. package/src/agent-src/templates/scripts/telemetry_record.ts +276 -0
  39. package/src/agent-src/templates/scripts/telemetry_report.ts +211 -0
  40. package/src/agent-src/templates/scripts/telemetry_status.ts +333 -0
  41. package/src/agent-src/templates/scripts/tier_usage_report.ts +619 -0
  42. package/src/agent-src/templates/scripts/work_engine/__main__.ts +14 -0
  43. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +1261 -0
  44. package/src/agent-src/templates/scripts/work_engine/_lib/user_global_paths.ts +329 -0
  45. package/src/agent-src/templates/scripts/work_engine/cli.ts +206 -0
  46. package/src/agent-src/templates/scripts/work_engine/cli_args.ts +249 -0
  47. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +225 -0
  48. package/src/agent-src/templates/scripts/work_engine/directives/backend/analyze.ts +125 -0
  49. package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +189 -0
  50. package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +94 -0
  51. package/src/agent-src/templates/scripts/work_engine/directives/backend/memory.ts +193 -0
  52. package/src/agent-src/templates/scripts/work_engine/directives/backend/plan.ts +267 -0
  53. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +518 -0
  54. package/src/agent-src/templates/scripts/work_engine/directives/backend/report.ts +379 -0
  55. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +268 -0
  56. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +258 -0
  57. package/src/agent-src/templates/scripts/work_engine/directives/index.ts +32 -0
  58. package/src/agent-src/templates/scripts/work_engine/directives/mixed/contract.ts +243 -0
  59. package/src/agent-src/templates/scripts/work_engine/directives/mixed/index.ts +108 -0
  60. package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +259 -0
  61. package/src/agent-src/templates/scripts/work_engine/directives/mixed/ui.ts +216 -0
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/_passthrough.ts +40 -0
  63. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +241 -0
  64. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +216 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +506 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +325 -0
  67. package/src/agent-src/templates/scripts/work_engine/directives/ui/index.ts +102 -0
  68. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +462 -0
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +474 -0
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +352 -0
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.ts +33 -0
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.ts +213 -0
  73. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/index.ts +111 -0
  74. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.ts +126 -0
  75. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/report.ts +112 -0
  76. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/test.ts +164 -0
  77. package/src/agent-src/templates/scripts/work_engine/dispatcher.ts +515 -0
  78. package/src/agent-src/templates/scripts/work_engine/emitters.ts +119 -0
  79. package/src/agent-src/templates/scripts/work_engine/errors.ts +24 -0
  80. package/src/agent-src/templates/scripts/work_engine/hook_bootstrap.ts +104 -0
  81. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.ts +189 -0
  82. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.ts +41 -0
  83. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.ts +89 -0
  84. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +193 -0
  85. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.ts +304 -0
  86. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.ts +110 -0
  87. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.ts +118 -0
  88. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/index.ts +17 -0
  89. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.ts +161 -0
  90. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.ts +45 -0
  91. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/trace.ts +134 -0
  92. package/src/agent-src/templates/scripts/work_engine/hooks/context.ts +94 -0
  93. package/src/agent-src/templates/scripts/work_engine/hooks/events.ts +58 -0
  94. package/src/agent-src/templates/scripts/work_engine/hooks/exceptions.ts +85 -0
  95. package/src/agent-src/templates/scripts/work_engine/hooks/index.ts +27 -0
  96. package/src/agent-src/templates/scripts/work_engine/hooks/registry.ts +66 -0
  97. package/src/agent-src/templates/scripts/work_engine/hooks/runner.ts +90 -0
  98. package/src/agent-src/templates/scripts/work_engine/hooks/settings.ts +260 -0
  99. package/src/agent-src/templates/scripts/work_engine/input_builders.ts +260 -0
  100. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +466 -0
  101. package/src/agent-src/templates/scripts/work_engine/migration/v0_to_v1.ts +531 -0
  102. package/src/agent-src/templates/scripts/work_engine/orchestration.ts +366 -0
  103. package/src/agent-src/templates/scripts/work_engine/persona_policy.ts +97 -0
  104. package/src/agent-src/templates/scripts/work_engine/resolvers/diff.ts +135 -0
  105. package/src/agent-src/templates/scripts/work_engine/resolvers/file.ts +175 -0
  106. package/src/agent-src/templates/scripts/work_engine/resolvers/prompt.ts +115 -0
  107. package/src/agent-src/templates/scripts/work_engine/scoring/confidence.ts +415 -0
  108. package/src/agent-src/templates/scripts/work_engine/scoring/decision_engine.ts +466 -0
  109. package/src/agent-src/templates/scripts/work_engine/scoring/decision_trace.ts +298 -0
  110. package/src/agent-src/templates/scripts/work_engine/scoring/memory_visibility.ts +444 -0
  111. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +252 -0
  112. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +745 -0
  113. package/src/agent-src/templates/scripts/work_engine/state.ts +1151 -0
  114. package/src/agent-src/templates/scripts/work_engine/state_io.ts +413 -0
  115. package/src/install/atomic.ts +111 -0
  116. package/src/install/conflict.ts +226 -0
  117. package/src/install/detect.ts +280 -0
  118. package/src/install/paths.ts +90 -0
  119. package/src/install/plan.ts +205 -0
  120. package/src/install/preflight.ts +210 -0
  121. package/src/install/selectedTools.ts +52 -0
  122. package/src/install/toolDetection.ts +120 -0
  123. package/src/install/txlog.ts +161 -0
  124. package/src/install/types.ts +175 -0
  125. package/src/install/wizard-plan.ts +216 -0
  126. package/src/scripts/prepack-check.mjs +108 -5
@@ -0,0 +1,745 @@
1
+ /**
2
+ * Toolchain resolution — pick the right test/quality runner per stack.
3
+ *
4
+ * TypeScript twin of `work_engine/stack/runner.py` (ADR-200 py2ts). Leaf
5
+ * module — stdlib only, NO intra-`work_engine` imports — so the public API
6
+ * names stay snake_case to mirror the Python module 1:1 (per ADR-200: Python
7
+ * style is part of the contract).
8
+ *
9
+ * Sibling of {@link "./detect"} (which labels the *frontend* stack). This
10
+ * module answers a different question: *given a project root, which test
11
+ * runner and quality tools does this stack actually use, and what is the
12
+ * exact command to invoke them?* It is the engine behind 6.1.0 Step 6 — the
13
+ * toolchain resolver that lets one set of commands (`/tests execute`,
14
+ * `/tests create`, `/quality-fix`, `/review-changes`, `/work`) adapt to
15
+ * phpunit / pest / vitest / jest / playwright / pytest / go / cargo instead
16
+ * of exploding into per-stack command variants.
17
+ *
18
+ * Detection is filesystem-cheap and **never crashes**: a malformed manifest,
19
+ * a missing file, or an unknown stack degrades to a LOW-confidence empty
20
+ * result rather than raising — a wrong toolchain label is recoverable (the
21
+ * agent can ask), a crash mid-run is not. This mirrors the recoverable-error
22
+ * contract in {@link "./detect"}.
23
+ *
24
+ * Three opt-in flags shape the *selected* command set (the monorepo guard):
25
+ *
26
+ * - `include_e2e` — by default e2e suites (playwright / cypress) are
27
+ * excluded; fast unit tests run first. Pass to add them.
28
+ * - `include_slow` — by default a script tagged `test:slow` /
29
+ * `test:integration` is excluded. Pass to add it.
30
+ * - `php_only` — keep only the PHP ecosystem (the `--php` narrowing the
31
+ * roadmap calls for; "only genuine PHP-space commands stay PHP-locked").
32
+ *
33
+ * The full per-stack inventory is always returned (`runners`); the
34
+ * `selected` tuple is what a command should actually run after applying
35
+ * the flags + the fast-by-default monorepo guard.
36
+ */
37
+ import * as fs from 'node:fs';
38
+ import * as path from 'node:path';
39
+
40
+ /**
41
+ * Every test-runner label the resolver can emit.
42
+ *
43
+ * Single source of truth so the state schema, fixtures, and tests validate
44
+ * against one set without re-deriving it. Mirrors Python's `frozenset`.
45
+ */
46
+ export const KNOWN_RUNNERS: ReadonlySet<string> = new Set([
47
+ 'pest',
48
+ 'phpunit',
49
+ 'vitest',
50
+ 'jest',
51
+ 'playwright',
52
+ 'cypress',
53
+ 'pytest',
54
+ 'go-test',
55
+ 'cargo-test',
56
+ ]);
57
+
58
+ // Speed buckets. `fast` runs by default; `slow` needs `include_slow`;
59
+ // `e2e` needs `include_e2e`. The monorepo guard reads this.
60
+ export const SPEED_FAST = 'fast';
61
+ export const SPEED_SLOW = 'slow';
62
+ export const SPEED_E2E = 'e2e';
63
+
64
+ // Confidence tiers — declarative, mirrors the non-interactive-contract
65
+ // tiers (HIGH = deterministic dependency/marker; MEDIUM = heuristic;
66
+ // LOW = nothing matched).
67
+ export const HIGH = 'HIGH';
68
+ export const MEDIUM = 'MEDIUM';
69
+ export const LOW = 'LOW';
70
+
71
+ const _MANIFESTS = [
72
+ 'composer.json',
73
+ 'package.json',
74
+ 'pyproject.toml',
75
+ 'go.mod',
76
+ 'Cargo.toml',
77
+ 'Makefile',
78
+ 'Taskfile.yml',
79
+ 'Taskfile.yaml',
80
+ ];
81
+
82
+ /** A heterogeneous JSON object, mirroring a Python `dict[str, object]`. */
83
+ type Manifest = { [key: string]: unknown };
84
+
85
+ /** Role → wrapper-command map, mirroring Python's `dict[str, str]`. */
86
+ type Wrappers = { [role: string]: string };
87
+
88
+ /**
89
+ * One detected test runner for one ecosystem.
90
+ *
91
+ * `command` is the exact invocation to run the suite (a task-runner
92
+ * wrapper like `make test` when one exists, otherwise the direct tool).
93
+ * `speed` is one of {@link SPEED_FAST} / {@link SPEED_SLOW} /
94
+ * {@link SPEED_E2E}; the monorepo guard filters on it. `basis` names the
95
+ * concrete signal that matched (dependency, binary, marker file) so the
96
+ * routing decision is auditable, exactly like the non-interactive-contract's
97
+ * `basis` column.
98
+ *
99
+ * Mirrors the Python `@dataclass(frozen=True)` with positional construction
100
+ * and the documented field defaults.
101
+ */
102
+ export class RunnerResult {
103
+ readonly ecosystem: string;
104
+ readonly runner: string;
105
+ readonly command: string;
106
+ readonly speed: string;
107
+ readonly confidence: string;
108
+ readonly basis: string;
109
+
110
+ constructor(
111
+ ecosystem: string,
112
+ runner: string,
113
+ command: string,
114
+ speed: string = SPEED_FAST,
115
+ confidence: string = HIGH,
116
+ basis = '',
117
+ ) {
118
+ this.ecosystem = ecosystem;
119
+ this.runner = runner;
120
+ this.command = command;
121
+ this.speed = speed;
122
+ this.confidence = confidence;
123
+ this.basis = basis;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Outcome of one toolchain-resolution pass over a project root.
129
+ *
130
+ * `runners` is the full inventory (every ecosystem detected, every speed
131
+ * bucket). `selected` is what a command should actually run after the flags
132
+ * + fast-by-default guard. `quality` is the ordered list of quality/lint
133
+ * commands per detected ecosystem. `confidence` is the overall tier (HIGH
134
+ * when ≥1 runner matched deterministically and no cross-ecosystem conflict;
135
+ * LOW when nothing matched). `mtime` is the latest manifest mtime, used for
136
+ * cache invalidation just like {@link "./detect".StackResult}.
137
+ */
138
+ export class ToolchainResult {
139
+ readonly ecosystems: readonly string[];
140
+ readonly runners: readonly RunnerResult[];
141
+ readonly selected: readonly RunnerResult[];
142
+ readonly quality: readonly string[];
143
+ readonly confidence: string;
144
+ readonly mtime: number;
145
+
146
+ constructor(args: {
147
+ ecosystems: readonly string[];
148
+ runners: readonly RunnerResult[];
149
+ selected: readonly RunnerResult[];
150
+ quality: readonly string[];
151
+ confidence: string;
152
+ mtime: number;
153
+ }) {
154
+ this.ecosystems = args.ecosystems;
155
+ this.runners = args.runners;
156
+ this.selected = args.selected;
157
+ this.quality = args.quality;
158
+ this.confidence = args.confidence;
159
+ this.mtime = args.mtime;
160
+ }
161
+
162
+ /**
163
+ * Serialise to the auto-generated project-config shape.
164
+ *
165
+ * Written to `agents/runtime/state/toolchain.json` by
166
+ * {@link write_config} so the per-stack commands are captured once and
167
+ * re-read cheaply (keyed on `mtime`) instead of re-detected every turn.
168
+ */
169
+ to_config(): Record<string, unknown> {
170
+ return {
171
+ ecosystems: [...this.ecosystems],
172
+ confidence: this.confidence,
173
+ // `mtime` is a Python float; wrap so `write_config`'s serialiser
174
+ // renders an integral value as `N.0` (Python float repr).
175
+ mtime: pyFloat(this.mtime),
176
+ runners: this.runners.map((r) => ({
177
+ ecosystem: r.ecosystem,
178
+ runner: r.runner,
179
+ command: r.command,
180
+ speed: r.speed,
181
+ confidence: r.confidence,
182
+ basis: r.basis,
183
+ })),
184
+ selected: this.selected.map((r) => r.command),
185
+ quality: [...this.quality],
186
+ };
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Inspect `project_root` and resolve its test/quality toolchain.
192
+ *
193
+ * @param project_root
194
+ * Directory carrying the manifests (`composer.json` / `package.json` /
195
+ * `pyproject.toml` / `go.mod` / `Cargo.toml`). The resolver does not walk
196
+ * upwards — the caller picks the scope, matching `detect.detect_stack`.
197
+ * @param opts.include_slow @param opts.include_e2e
198
+ * Monorepo guard. Off by default → `selected` carries only fast unit
199
+ * suites. Turn on to add the slow / e2e buckets.
200
+ * @param opts.php_only
201
+ * The `--php` narrowing — keep only the PHP ecosystem in `selected` (the
202
+ * full inventory is still returned in `runners`).
203
+ * @returns
204
+ * A {@link ToolchainResult}. Never raises. No manifest / unknown stack →
205
+ * an empty result with `confidence == LOW` so the caller can fall back to
206
+ * asking.
207
+ */
208
+ export function resolve_toolchain(
209
+ project_root: string,
210
+ opts: { include_slow?: boolean; include_e2e?: boolean; php_only?: boolean } = {},
211
+ ): ToolchainResult {
212
+ const include_slow = opts.include_slow ?? false;
213
+ const include_e2e = opts.include_e2e ?? false;
214
+ const php_only = opts.php_only ?? false;
215
+
216
+ const runners: RunnerResult[] = [];
217
+ const quality: string[] = [];
218
+
219
+ const composer = _read_json(path.join(project_root, 'composer.json'));
220
+ const pkg = _read_json(path.join(project_root, 'package.json'));
221
+ const has_composer = _is_file(path.join(project_root, 'composer.json'));
222
+ const has_package = _is_file(path.join(project_root, 'package.json'));
223
+ const pyproject_text = _read_text(path.join(project_root, 'pyproject.toml'));
224
+ const has_python =
225
+ Boolean(pyproject_text) ||
226
+ _is_file(path.join(project_root, 'requirements.txt')) ||
227
+ _is_file(path.join(project_root, 'setup.cfg')) ||
228
+ _is_file(path.join(project_root, 'pytest.ini'));
229
+ const has_go = _is_file(path.join(project_root, 'go.mod'));
230
+ const has_cargo = _is_file(path.join(project_root, 'Cargo.toml'));
231
+
232
+ const wrappers = _task_runner_wrappers(project_root, pkg);
233
+
234
+ if (has_composer) {
235
+ runners.push(..._php_runners(project_root, composer, wrappers));
236
+ quality.push(..._php_quality(project_root, composer, wrappers));
237
+ }
238
+ if (has_package) {
239
+ runners.push(..._js_runners(pkg, wrappers));
240
+ quality.push(..._js_quality(pkg, wrappers));
241
+ }
242
+ if (has_python) {
243
+ runners.push(..._python_runners(pyproject_text));
244
+ quality.push(..._python_quality(pyproject_text));
245
+ }
246
+ if (has_go) {
247
+ runners.push(
248
+ new RunnerResult('go', 'go-test', 'go test ./...', SPEED_FAST, HIGH, 'go.mod present'),
249
+ );
250
+ quality.push('go vet ./...');
251
+ }
252
+ if (has_cargo) {
253
+ runners.push(
254
+ new RunnerResult('rust', 'cargo-test', 'cargo test', SPEED_FAST, HIGH, 'Cargo.toml present'),
255
+ );
256
+ quality.push('cargo clippy');
257
+ }
258
+
259
+ const ecosystems = _dictFromKeys(runners.map((r) => r.ecosystem));
260
+ const selected = _apply_guard(runners, { include_slow, include_e2e, php_only });
261
+ const confidence = _overall_confidence(runners);
262
+
263
+ return new ToolchainResult({
264
+ ecosystems,
265
+ runners: [...runners],
266
+ selected: [...selected],
267
+ quality: _dictFromKeys(quality),
268
+ confidence,
269
+ mtime: latest_manifest_mtime(project_root),
270
+ });
271
+ }
272
+
273
+ /**
274
+ * Persist `result` to `agents/runtime/state/toolchain.json`.
275
+ *
276
+ * The auto-generated per-stack config the roadmap calls for. Best-effort:
277
+ * returns the path written; never raises on a read-only or missing parent
278
+ * (the resolver is a routing aid, not a hard dependency).
279
+ */
280
+ export function write_config(project_root: string, result: ToolchainResult): string {
281
+ const target = path.join(project_root, 'agents', 'runtime', 'state', 'toolchain.json');
282
+ try {
283
+ fs.mkdirSync(path.dirname(target), { recursive: true });
284
+ fs.writeFileSync(
285
+ target,
286
+ jsonDumps(result.to_config(), { indent: 2, sort_keys: true }) + '\n',
287
+ { encoding: 'utf-8' },
288
+ );
289
+ } catch {
290
+ // Mirrors Python's `except OSError: pass` — best-effort write.
291
+ }
292
+ return target;
293
+ }
294
+
295
+ /**
296
+ * Latest mtime across every manifest the resolver consults.
297
+ *
298
+ * Cache-invalidation hook: when the persisted value no longer matches the
299
+ * live value the cached toolchain is stale and resolution re-runs. `0.0`
300
+ * when no manifest exists (greenfield) — a stable sentinel, not a
301
+ * missing-file error.
302
+ */
303
+ export function latest_manifest_mtime(project_root: string): number {
304
+ const mtimes: number[] = [];
305
+ for (const name of _MANIFESTS) {
306
+ const p = path.join(project_root, name);
307
+ if (_is_file(p)) {
308
+ mtimes.push(_stat_mtime(p));
309
+ }
310
+ }
311
+ return mtimes.length > 0 ? Math.max(...mtimes) : 0.0;
312
+ }
313
+
314
+ // --------------------------------------------------------------------------
315
+ // Ecosystem resolvers
316
+ // --------------------------------------------------------------------------
317
+
318
+ function _php_runners(root: string, composer: Manifest, wrappers: Wrappers): RunnerResult[] {
319
+ const deps = _all_dependencies(composer, 'require', 'require-dev');
320
+ if ('pestphp/pest' in deps || _is_file(path.join(root, 'vendor', 'bin', 'pest'))) {
321
+ const cmd = wrappers['php-test'] || 'vendor/bin/pest';
322
+ const basis =
323
+ 'pestphp/pest' in deps
324
+ ? 'pestphp/pest in composer require'
325
+ : 'vendor/bin/pest present';
326
+ return [new RunnerResult('php', 'pest', cmd, SPEED_FAST, HIGH, basis)];
327
+ }
328
+ if (_is_file(path.join(root, 'artisan'))) {
329
+ const cmd = wrappers['php-test'] || 'php artisan test';
330
+ return [new RunnerResult('php', 'phpunit', cmd, SPEED_FAST, HIGH, 'artisan present (Laravel)')];
331
+ }
332
+ if ('phpunit/phpunit' in deps || _is_file(path.join(root, 'vendor', 'bin', 'phpunit'))) {
333
+ const cmd = wrappers['php-test'] || 'vendor/bin/phpunit';
334
+ return [new RunnerResult('php', 'phpunit', cmd, SPEED_FAST, HIGH, 'phpunit/phpunit detected')];
335
+ }
336
+ // composer.json with no test dependency — phpunit is the safe PHP default.
337
+ const cmd = wrappers['php-test'] || 'vendor/bin/phpunit';
338
+ return [
339
+ new RunnerResult(
340
+ 'php',
341
+ 'phpunit',
342
+ cmd,
343
+ SPEED_FAST,
344
+ MEDIUM,
345
+ 'composer.json present, no explicit runner',
346
+ ),
347
+ ];
348
+ }
349
+
350
+ function _js_runners(pkg: Manifest, wrappers: Wrappers): RunnerResult[] {
351
+ const deps = _all_dependencies(
352
+ pkg,
353
+ 'dependencies',
354
+ 'devDependencies',
355
+ 'peerDependencies',
356
+ 'optionalDependencies',
357
+ );
358
+ const scripts: Manifest = _isDict(pkg.scripts) ? pkg.scripts : {};
359
+ const pm_test = wrappers['js-test'];
360
+ const out: RunnerResult[] = [];
361
+
362
+ // Fast unit runner — vitest beats jest when both present (vitest is the
363
+ // modern default), but only one fast runner is selected.
364
+ if ('vitest' in deps) {
365
+ const cmd =
366
+ pm_test && _script_uses(scripts, 'test', 'vitest') ? pm_test : 'npx vitest run';
367
+ out.push(new RunnerResult('js', 'vitest', cmd, SPEED_FAST, HIGH, 'vitest in package deps'));
368
+ } else if ('jest' in deps) {
369
+ const cmd = pm_test && _script_uses(scripts, 'test', 'jest') ? pm_test : 'npx jest';
370
+ out.push(new RunnerResult('js', 'jest', cmd, SPEED_FAST, HIGH, 'jest in package deps'));
371
+ } else if (pm_test) {
372
+ out.push(
373
+ new RunnerResult(
374
+ 'js',
375
+ 'jest',
376
+ pm_test,
377
+ SPEED_FAST,
378
+ MEDIUM,
379
+ 'package.json test script, runner unclear',
380
+ ),
381
+ );
382
+ }
383
+
384
+ // e2e runner — separate bucket, excluded unless --include-e2e.
385
+ if ('@playwright/test' in deps || 'playwright' in deps) {
386
+ const cmd = _script_command(scripts, ['test:e2e', 'e2e', 'playwright']) || 'npx playwright test';
387
+ out.push(
388
+ new RunnerResult('js', 'playwright', cmd, SPEED_E2E, HIGH, '@playwright/test in package deps'),
389
+ );
390
+ } else if ('cypress' in deps) {
391
+ const cmd = _script_command(scripts, ['test:e2e', 'e2e', 'cypress']) || 'npx cypress run';
392
+ out.push(new RunnerResult('js', 'cypress', cmd, SPEED_E2E, HIGH, 'cypress in package deps'));
393
+ }
394
+
395
+ // slow bucket — an explicit slow/integration script.
396
+ const slow_cmd = _script_command(scripts, ['test:slow', 'test:integration']);
397
+ if (slow_cmd) {
398
+ out.push(
399
+ new RunnerResult(
400
+ 'js',
401
+ 'vitest' in deps ? 'vitest' : 'jest',
402
+ slow_cmd,
403
+ SPEED_SLOW,
404
+ MEDIUM,
405
+ 'test:slow/integration script',
406
+ ),
407
+ );
408
+ }
409
+ return out;
410
+ }
411
+
412
+ function _python_runners(pyproject_text: string): RunnerResult[] {
413
+ if (pyproject_text.includes('pytest') || pyproject_text === '') {
414
+ const conf = pyproject_text.includes('pytest') ? HIGH : MEDIUM;
415
+ const basis = pyproject_text.includes('pytest')
416
+ ? 'pytest in pyproject'
417
+ : 'python project, no explicit runner';
418
+ return [new RunnerResult('python', 'pytest', 'pytest', SPEED_FAST, conf, basis)];
419
+ }
420
+ return [
421
+ new RunnerResult('python', 'pytest', 'pytest', SPEED_FAST, MEDIUM, 'python project, no explicit runner'),
422
+ ];
423
+ }
424
+
425
+ function _php_quality(root: string, composer: Manifest, _wrappers: Wrappers): string[] {
426
+ const deps = _all_dependencies(composer, 'require', 'require-dev');
427
+ const out: string[] = [];
428
+ if ('phpstan/phpstan' in deps || _is_file(path.join(root, 'vendor', 'bin', 'phpstan'))) {
429
+ out.push('vendor/bin/phpstan analyse');
430
+ }
431
+ if ('laravel/pint' in deps || _is_file(path.join(root, 'vendor', 'bin', 'pint'))) {
432
+ out.push('vendor/bin/pint');
433
+ }
434
+ return out;
435
+ }
436
+
437
+ function _js_quality(pkg: Manifest, _wrappers: Wrappers): string[] {
438
+ const deps = _all_dependencies(
439
+ pkg,
440
+ 'dependencies',
441
+ 'devDependencies',
442
+ 'peerDependencies',
443
+ 'optionalDependencies',
444
+ );
445
+ const out: string[] = [];
446
+ if ('typescript' in deps) {
447
+ out.push('npx tsc --noEmit');
448
+ }
449
+ if ('eslint' in deps) {
450
+ out.push('npx eslint .');
451
+ }
452
+ return out;
453
+ }
454
+
455
+ function _python_quality(pyproject_text: string): string[] {
456
+ const out: string[] = [];
457
+ if (pyproject_text.includes('ruff')) {
458
+ out.push('ruff check');
459
+ }
460
+ if (pyproject_text.includes('mypy')) {
461
+ out.push('mypy .');
462
+ }
463
+ return out;
464
+ }
465
+
466
+ // --------------------------------------------------------------------------
467
+ // Task-runner wrappers (Makefile / Taskfile / package scripts)
468
+ // --------------------------------------------------------------------------
469
+
470
+ /**
471
+ * Map logical roles to a wrapper command when one exists.
472
+ *
473
+ * Wrappers win over direct tool invocation (they handle container access,
474
+ * env, parallelism) — the architecture rule's "Build / Task Runner
475
+ * Detection". Returns role → command; absent roles fall through to the
476
+ * direct tool.
477
+ */
478
+ function _task_runner_wrappers(root: string, pkg: Manifest): Wrappers {
479
+ const out: Wrappers = {};
480
+ const makefile = _read_text(path.join(root, 'Makefile'));
481
+ const taskfile =
482
+ _read_text(path.join(root, 'Taskfile.yml')) || _read_text(path.join(root, 'Taskfile.yaml'));
483
+ if (makefile && _reSearch(/^test\s*:/m, makefile)) {
484
+ out['php-test'] = 'make test';
485
+ } else if (taskfile && _reSearch(/^\s*test\s*:/m, taskfile)) {
486
+ out['php-test'] = 'task test';
487
+ }
488
+ const scripts: Manifest = _isDict(pkg.scripts) ? pkg.scripts : {};
489
+ if (_isDict(scripts) && 'test' in scripts) {
490
+ out['js-test'] = `${_package_manager(root)} test`;
491
+ }
492
+ return out;
493
+ }
494
+
495
+ function _package_manager(root: string): string {
496
+ if (_is_file(path.join(root, 'pnpm-lock.yaml'))) {
497
+ return 'pnpm';
498
+ }
499
+ if (_is_file(path.join(root, 'yarn.lock'))) {
500
+ return 'yarn';
501
+ }
502
+ return 'npm';
503
+ }
504
+
505
+ // --------------------------------------------------------------------------
506
+ // Monorepo guard + confidence
507
+ // --------------------------------------------------------------------------
508
+
509
+ function _apply_guard(
510
+ runners: RunnerResult[],
511
+ opts: { include_slow: boolean; include_e2e: boolean; php_only: boolean },
512
+ ): RunnerResult[] {
513
+ const out: RunnerResult[] = [];
514
+ for (const r of runners) {
515
+ if (opts.php_only && r.ecosystem !== 'php') {
516
+ continue;
517
+ }
518
+ if (r.speed === SPEED_E2E && !opts.include_e2e) {
519
+ continue;
520
+ }
521
+ if (r.speed === SPEED_SLOW && !opts.include_slow) {
522
+ continue;
523
+ }
524
+ out.push(r);
525
+ }
526
+ return out;
527
+ }
528
+
529
+ function _overall_confidence(runners: RunnerResult[]): string {
530
+ if (runners.length === 0) {
531
+ return LOW;
532
+ }
533
+ if (runners.some((r) => r.confidence === HIGH)) {
534
+ return HIGH;
535
+ }
536
+ return MEDIUM;
537
+ }
538
+
539
+ // --------------------------------------------------------------------------
540
+ // Shared readers (mirror detect.py's recoverable-error contract)
541
+ // --------------------------------------------------------------------------
542
+
543
+ function _read_json(p: string): Manifest {
544
+ if (!_is_file(p)) {
545
+ return {};
546
+ }
547
+ let payload: unknown;
548
+ try {
549
+ payload = JSON.parse(fs.readFileSync(p, { encoding: 'utf-8' }));
550
+ } catch {
551
+ return {};
552
+ }
553
+ return _isDict(payload) ? payload : {};
554
+ }
555
+
556
+ function _read_text(p: string): string {
557
+ if (!_is_file(p)) {
558
+ return '';
559
+ }
560
+ try {
561
+ return fs.readFileSync(p, { encoding: 'utf-8' });
562
+ } catch {
563
+ return '';
564
+ }
565
+ }
566
+
567
+ function _all_dependencies(manifest: Manifest, ...keys: string[]): Manifest {
568
+ const merged: Manifest = {};
569
+ for (const key of keys) {
570
+ const section = manifest[key];
571
+ if (_isDict(section)) {
572
+ Object.assign(merged, section);
573
+ }
574
+ }
575
+ return merged;
576
+ }
577
+
578
+ function _script_uses(scripts: Manifest, name: string, tool: string): boolean {
579
+ const value = scripts[name];
580
+ return typeof value === 'string' && value.includes(tool);
581
+ }
582
+
583
+ function _script_command(scripts: Manifest, names: string[]): string {
584
+ for (const name of names) {
585
+ if (typeof scripts[name] === 'string') {
586
+ return `npm run ${name}`;
587
+ }
588
+ }
589
+ return '';
590
+ }
591
+
592
+ // --------------------------------------------------------------------------
593
+ // stdlib parity helpers
594
+ // --------------------------------------------------------------------------
595
+
596
+ /** Python `Path.is_file()` — true only for a regular file (follows symlinks). */
597
+ function _is_file(p: string): boolean {
598
+ try {
599
+ return fs.statSync(p).isFile();
600
+ } catch {
601
+ return false;
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Python `Path.stat().st_mtime` in POSIX seconds — see the matching note in
607
+ * `detect.ts`. Only `to_config` / `write_config` serialize this value, and
608
+ * the parity tests treat the serialized `mtime` as non-deterministic.
609
+ */
610
+ function _stat_mtime(p: string): number {
611
+ const st = fs.statSync(p, { bigint: true });
612
+ return Number(st.mtimeNs) / 1e9;
613
+ }
614
+
615
+ /** Python `isinstance(x, dict)` — a plain (non-array, non-null) object. */
616
+ function _isDict(v: unknown): v is Manifest {
617
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
618
+ }
619
+
620
+ /**
621
+ * Python `re.search(pattern, text)` truthiness. The patterns used here carry
622
+ * the multiline flag (`(?m)` in the source → `/m` here), so `^`/`$` match at
623
+ * line boundaries exactly like CPython's `re`.
624
+ */
625
+ function _reSearch(pattern: RegExp, text: string): boolean {
626
+ return pattern.test(text);
627
+ }
628
+
629
+ /**
630
+ * Python `tuple(dict.fromkeys(seq))` — dedupe preserving first-seen insertion
631
+ * order. `dict.fromkeys` keeps the first occurrence's position; a JS `Set`
632
+ * built by iteration does the same, so `[...new Set(seq)]` is faithful.
633
+ */
634
+ function _dictFromKeys(seq: string[]): string[] {
635
+ return [...new Set(seq)];
636
+ }
637
+
638
+ // --------------------------------------------------------------------------
639
+ // JSON serialisation — byte-parity with `json.dumps(..., indent=2,
640
+ // sort_keys=True)` for `write_config`.
641
+ // --------------------------------------------------------------------------
642
+
643
+ /**
644
+ * Mirror Python `json.dumps(obj, indent=2, sort_keys=True)` byte-for-byte.
645
+ *
646
+ * Two CPython behaviours that `JSON.stringify` does NOT reproduce on its own
647
+ * and are reproduced here:
648
+ *
649
+ * - `sort_keys=True` — object keys emitted in code-point ascending order.
650
+ * `JSON.stringify` preserves insertion order; we pre-sort recursively.
651
+ * - integer-valued floats render as `N.0` (e.g. `1767222000.0`). JSON has no
652
+ * float/int tag, so a JS `number` that is integral renders without `.0`.
653
+ * In this module the only float is `mtime`; the parity tests normalize it
654
+ * (its exact byte-repr is not reproducible across CPython/V8), but the
655
+ * serialiser still applies the `N.0` rule via a tagged float wrapper so the
656
+ * *shape* matches. `mtime` is the sole value passed through that wrapper.
657
+ *
658
+ * 2-space indent, `": "` key separator, `","` item separator with the
659
+ * indent-driven newline, `{}` / `[]` for empties, non-ASCII verbatim
660
+ * (`ensure_ascii` defaults to `True` in CPython, but `to_config` carries no
661
+ * non-ASCII, so the distinction never surfaces).
662
+ */
663
+ function jsonDumps(obj: unknown, opts: { indent: number; sort_keys: boolean }): string {
664
+ return _encode(obj, opts, 0);
665
+ }
666
+
667
+ const _INDENT_UNIT = ' ';
668
+
669
+ function _encode(value: unknown, opts: { indent: number; sort_keys: boolean }, depth: number): string {
670
+ if (value === null) {
671
+ return 'null';
672
+ }
673
+ if (typeof value === 'boolean') {
674
+ return value ? 'true' : 'false';
675
+ }
676
+ if (typeof value === 'number') {
677
+ return _encodeNumber(value, false);
678
+ }
679
+ if (value instanceof _PyFloat) {
680
+ return _encodeNumber(value.value, true);
681
+ }
682
+ if (typeof value === 'string') {
683
+ return JSON.stringify(value);
684
+ }
685
+ if (Array.isArray(value)) {
686
+ if (value.length === 0) {
687
+ return '[]';
688
+ }
689
+ const inner = _INDENT_UNIT.repeat(opts.indent * (depth + 1));
690
+ const items = value.map((v) => inner + _encode(v, opts, depth + 1));
691
+ const close = _INDENT_UNIT.repeat(opts.indent * depth);
692
+ return '[\n' + items.join(',\n') + '\n' + close + ']';
693
+ }
694
+ if (_isDict(value)) {
695
+ let keys = Object.keys(value);
696
+ if (keys.length === 0) {
697
+ return '{}';
698
+ }
699
+ if (opts.sort_keys) {
700
+ keys = keys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
701
+ }
702
+ const inner = _INDENT_UNIT.repeat(opts.indent * (depth + 1));
703
+ const items = keys.map(
704
+ (k) => inner + JSON.stringify(k) + ': ' + _encode(value[k], opts, depth + 1),
705
+ );
706
+ const close = _INDENT_UNIT.repeat(opts.indent * depth);
707
+ return '{\n' + items.join(',\n') + '\n' + close + '}';
708
+ }
709
+ // Unreachable for `to_config` output; mirror `json.dumps` raising on an
710
+ // unserialisable type would require a TypeError — return the JS default.
711
+ return JSON.stringify(value);
712
+ }
713
+
714
+ /**
715
+ * Render a number like CPython's `float.__repr__` / `int.__repr__` inside
716
+ * `json.dumps`. When `isFloat` is set and the value is integral, append
717
+ * `.0` (Python's float repr). Non-integral floats use JS's shortest
718
+ * round-trip repr — which matches CPython's `repr(float)` for the values
719
+ * that arise here. (The `mtime` field is normalized by the parity tests, so
720
+ * any residual last-digit divergence on exotic sub-second timestamps is not
721
+ * load-bearing.)
722
+ */
723
+ function _encodeNumber(n: number, isFloat: boolean): string {
724
+ if (isFloat && Number.isInteger(n)) {
725
+ return `${n}.0`;
726
+ }
727
+ return String(n);
728
+ }
729
+
730
+ /** Tag wrapper marking a JS number that must serialise with Python float repr. */
731
+ class _PyFloat {
732
+ readonly value: number;
733
+ constructor(value: number) {
734
+ this.value = value;
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Wrap a numeric value so {@link jsonDumps} renders it as a Python float
740
+ * (integer-valued → `N.0`). Exported for the parity tests / callers that
741
+ * round-trip a `ToolchainResult` through `to_config`.
742
+ */
743
+ export function pyFloat(value: number): _PyFloat {
744
+ return new _PyFloat(value);
745
+ }