repo-harness 0.16.1 → 0.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.es.md CHANGED
@@ -467,8 +467,8 @@ repositorio adopte la misma política.
467
467
 
468
468
  ## Versión actual
469
469
 
470
- - Paquete npm: `repo-harness@0.16.1`
471
- - Sello de workflow generado: `repo-harness@0.16.1+template@0.16.1`
470
+ - Paquete npm: `repo-harness@0.16.2`
471
+ - Sello de workflow generado: `repo-harness@0.16.2+template@0.16.2`
472
472
  - Repositorio de GitHub: `Ancienttwo/repo-harness`
473
473
  - Notas de versión e historial: [`docs/CHANGELOG.md`](docs/CHANGELOG.md)
474
474
 
package/README.fr.md CHANGED
@@ -462,8 +462,8 @@ adopte la même policy.
462
462
 
463
463
  ## Release actuelle
464
464
 
465
- - Package npm : `repo-harness@0.16.1`
466
- - Generated workflow stamp : `repo-harness@0.16.1+template@0.16.1`
465
+ - Package npm : `repo-harness@0.16.2`
466
+ - Generated workflow stamp : `repo-harness@0.16.2+template@0.16.2`
467
467
  - Dépôt GitHub : `Ancienttwo/repo-harness`
468
468
  - Notes et historique de release : [`docs/CHANGELOG.md`](docs/CHANGELOG.md)
469
469
 
package/README.ja.md CHANGED
@@ -470,8 +470,8 @@ commit script や hooks に組み込まないでください。
470
470
 
471
471
  ## 現在の Release
472
472
 
473
- - npm package:`repo-harness@0.16.1`
474
- - Generated workflow stamp:`repo-harness@0.16.1+template@0.16.1`
473
+ - npm package:`repo-harness@0.16.2`
474
+ - Generated workflow stamp:`repo-harness@0.16.2+template@0.16.2`
475
475
  - GitHub repository:`Ancienttwo/repo-harness`
476
476
  - Release notes and history:[`docs/CHANGELOG.md`](docs/CHANGELOG.md)
477
477
 
package/README.md CHANGED
@@ -450,8 +450,8 @@ repo-harness commit scripts or hooks unless that repo adopts the same policy.
450
450
 
451
451
  ## Current Release
452
452
 
453
- - npm package: `repo-harness@0.16.1`
454
- - Generated workflow stamp: `repo-harness@0.16.1+template@0.16.1`
453
+ - npm package: `repo-harness@0.16.2`
454
+ - Generated workflow stamp: `repo-harness@0.16.2+template@0.16.2`
455
455
  - GitHub repository: `Ancienttwo/repo-harness`
456
456
  - Release notes and history: [`docs/CHANGELOG.md`](docs/CHANGELOG.md)
457
457
 
package/README.zh-CN.md CHANGED
@@ -457,8 +457,8 @@ policy。
457
457
 
458
458
  ## 当前 Release
459
459
 
460
- - npm package:`repo-harness@0.16.1`
461
- - Generated workflow stamp:`repo-harness@0.16.1+template@0.16.1`
460
+ - npm package:`repo-harness@0.16.2`
461
+ - Generated workflow stamp:`repo-harness@0.16.2+template@0.16.2`
462
462
  - GitHub repository:`Ancienttwo/repo-harness`
463
463
  - Release notes 和 history:[`docs/CHANGELOG.md`](docs/CHANGELOG.md)
464
464
 
@@ -24,6 +24,12 @@ BUG_FIX_FLOW:
24
24
  3. Re-run full verification
25
25
  ```
26
26
 
27
+ #### Artifact Hygiene
28
+
29
+ - Write comments, commit messages, and PR text from the final diff only, as if discarded intermediate attempts never existed.
30
+ - Comments state only the non-obvious reason at the owning boundary; never restate the operation, preserve intermediate attempts, or list speculative future work.
31
+ - PR text states final behavior plus only the material rationale or trade-off a reviewer cannot recover from the diff; never mention never-merged states or reverted work.
32
+
27
33
  Detailed playbooks:
28
34
  - `docs/reference-configs/harness-overview.md`
29
35
  - `docs/reference-configs/agentic-development-flow.md`
@@ -53,7 +53,6 @@ RULES:
53
53
  - Promote factors only after hypothesis and backtest summary artifacts exist
54
54
  - Run the configured factor-lab check before claiming factor-lab work is complete
55
55
  {{/IF}}
56
-
57
56
  ACTIVE_PLAN:
58
57
  - .ai/harness/active-plan selects the current active plan only for its owning worktree; .ai/harness/active-worktree records that owner
59
58
 
@@ -12,6 +12,7 @@
12
12
  - Do not silently expand scope beyond approved plan.
13
13
  - If unexpected repo changes appear, stop and ask.
14
14
  - Prefer modifying existing files over unnecessary file creation.
15
+ - Write comments, commit messages, and PR text from the final diff only: comments state only the non-obvious reason at the owning boundary; PR text states final behavior plus only rationale a reviewer cannot recover from the diff; never mention discarded attempts, reverted work, or never-merged states.
15
16
 
16
17
  ### Final Response Contract
17
18
  1. What changed — list modified files with one-line summary each
@@ -75,6 +75,14 @@ For architecture reviews, bug hunts, risky refactors, deployment issues, auth/pa
75
75
 
76
76
  Reports must be concise and grounded in files, commands, runtime behavior, observed code, or verified system state.
77
77
 
78
+ ## Artifact Hygiene
79
+
80
+ Write comments, commit messages, and PR text from the final diff only, as if discarded intermediate attempts never existed. Session history is not documentation material.
81
+
82
+ - Comments state only the non-obvious reason at the owning boundary; never restate the operation, preserve intermediate attempts, or list speculative future work.
83
+ - PR text states final behavior plus only the material rationale or trade-off a reviewer cannot recover from the diff; never mention never-merged states, reverted work, or why something is absent (no "(without X)" titles, no paragraphs justifying a removal the reviewer never saw).
84
+ - When the user rejects an addition, the correct artifact output is the version where that addition never existed — not an annotated explanation of its removal.
85
+
78
86
  ## Completion Summary Rule
79
87
 
80
88
  For non-trivial completed tasks, include a short `下一刀` section only when verified state shows a concrete next bottleneck, unresolved risk, failing check, deployment gap, review gap, or active-plan item that materially affects the user's stated goal.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.16.1",
3
- "templateVersion": "0.16.1",
2
+ "version": "0.16.2",
3
+ "templateVersion": "0.16.2",
4
4
  "skillName": "repo-harness",
5
5
  "contractId": "tasks-first-harness-v1",
6
6
  "compatibility": {
@@ -259,6 +259,10 @@
259
259
  {
260
260
  "version": "0.16.1",
261
261
  "description": "Replaces the boolean `overwrite` flag on the seven MCP workflow-artifact write tools with an optional `expected_sha256` revision precondition (absent = create-only with WOULD_OVERWRITE, supplied = guarded overwrite with REVISION_CONFLICT and no current-hash echo), routes those writes through a guarded synchronous writer with lstat symlink and regular-file guards plus temp+fsync+rename+parent-fsync, returns sha256/previousSha256 on success, and rejects undeclared parameters server-side with UNKNOWN_PARAMETER while `overwrite` specifically returns RETIRED_PARAMETER naming `expected_sha256`"
262
+ },
263
+ {
264
+ "version": "0.16.2",
265
+ "description": "Fixes MCP runtime issues from #204, binds MCP HTTP sessions to the startup profile with fail-closed config-flip handling, publishes the architecture queue lock owner record atomically via staged hard-link creation, requires and fail-closed-validates sandbox_mode in Codex custom-agent TOMLs with EXECUTION_BOUNDARY persona de-duplication, and adds artifact-hygiene rules (final-diff-only comments and PR text) to the global working rules and generated agent contracts"
262
266
  }
263
267
  ],
264
268
  "generatedProjectStamp": {
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from "crypto";
3
3
  import {
4
4
  closeSync,
5
5
  existsSync,
6
+ linkSync,
6
7
  lstatSync,
7
8
  mkdirSync,
8
9
  openSync,
@@ -520,18 +521,34 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
520
521
  assertSafeWriteTarget(`${requestsDir}/.write-probe`);
521
522
  const lockFile = ".ai/harness/architecture/.architecture-queue.lock";
522
523
  assertSafeWriteTarget(lockFile);
524
+ const candidateToken = createHash("sha256").update(ownerToken).digest("hex").slice(0, 16);
525
+ const candidateFile = `${lockFile}.${ownerPid}.${candidateToken}.tmp`;
526
+ assertSafeWriteTarget(candidateFile);
523
527
  const deadline = Date.now() + 10_000;
524
528
  while (true) {
525
529
  try {
526
- const fd = openSync(lockFile, "wx", 0o600);
530
+ const fd = openSync(candidateFile, "wx", 0o600);
527
531
  try {
528
532
  writeAllSync(fd, JSON.stringify({ pid: ownerPid, token: ownerToken, created_at: new Date().toISOString() }));
529
533
  } finally {
530
534
  closeSync(fd);
531
535
  }
532
- return lockFile;
536
+ const holdBeforePublishMs = Number(process.env.REPO_HARNESS_ARCHITECTURE_HOLD_BEFORE_LOCK_PUBLISH_MS || "0");
537
+ if (Number.isFinite(holdBeforePublishMs) && holdBeforePublishMs > 0) {
538
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, holdBeforePublishMs);
539
+ }
540
+ try {
541
+ linkSync(candidateFile, lockFile);
542
+ return lockFile;
543
+ } catch (error: any) {
544
+ if (error?.code !== "EEXIST") throw error;
545
+ }
533
546
  } catch (error: any) {
534
- if (error?.code !== "EEXIST" || !existsSync(lockFile)) throw error;
547
+ if (error?.code !== "EEXIST") throw error;
548
+ } finally {
549
+ rmSync(candidateFile, { force: true });
550
+ }
551
+ if (existsSync(lockFile)) {
535
552
  try {
536
553
  const owner = JSON.parse(readFileSync(lockFile, "utf8"));
537
554
  if (!processIsAlive(Number(owner.pid))) {
@@ -545,9 +562,9 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
545
562
  continue;
546
563
  }
547
564
  }
548
- if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
549
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
550
565
  }
566
+ if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
567
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
551
568
  }
552
569
  }
553
570
 
@@ -14556,7 +14556,7 @@ function resolveReviewBaseRef(repoRoot) {
14556
14556
  return "HEAD";
14557
14557
  }
14558
14558
  }
14559
- var BUNDLED_CLI_VERSION = "0.16.1".length > 0 ? "0.16.1" : null;
14559
+ var BUNDLED_CLI_VERSION = "0.16.2".length > 0 ? "0.16.2" : null;
14560
14560
  function providerCliVersion() {
14561
14561
  if (BUNDLED_CLI_VERSION !== null)
14562
14562
  return BUNDLED_CLI_VERSION;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "repo-harness",
3
- "version": "0.16.1",
3
+ "version": "0.16.2",
4
4
  "description": "Installs, migrates, audits, and repairs repo-local agentic development harnesses",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from "crypto";
3
3
  import {
4
4
  closeSync,
5
5
  existsSync,
6
+ linkSync,
6
7
  lstatSync,
7
8
  mkdirSync,
8
9
  openSync,
@@ -520,18 +521,34 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
520
521
  assertSafeWriteTarget(`${requestsDir}/.write-probe`);
521
522
  const lockFile = ".ai/harness/architecture/.architecture-queue.lock";
522
523
  assertSafeWriteTarget(lockFile);
524
+ const candidateToken = createHash("sha256").update(ownerToken).digest("hex").slice(0, 16);
525
+ const candidateFile = `${lockFile}.${ownerPid}.${candidateToken}.tmp`;
526
+ assertSafeWriteTarget(candidateFile);
523
527
  const deadline = Date.now() + 10_000;
524
528
  while (true) {
525
529
  try {
526
- const fd = openSync(lockFile, "wx", 0o600);
530
+ const fd = openSync(candidateFile, "wx", 0o600);
527
531
  try {
528
532
  writeAllSync(fd, JSON.stringify({ pid: ownerPid, token: ownerToken, created_at: new Date().toISOString() }));
529
533
  } finally {
530
534
  closeSync(fd);
531
535
  }
532
- return lockFile;
536
+ const holdBeforePublishMs = Number(process.env.REPO_HARNESS_ARCHITECTURE_HOLD_BEFORE_LOCK_PUBLISH_MS || "0");
537
+ if (Number.isFinite(holdBeforePublishMs) && holdBeforePublishMs > 0) {
538
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, holdBeforePublishMs);
539
+ }
540
+ try {
541
+ linkSync(candidateFile, lockFile);
542
+ return lockFile;
543
+ } catch (error: any) {
544
+ if (error?.code !== "EEXIST") throw error;
545
+ }
533
546
  } catch (error: any) {
534
- if (error?.code !== "EEXIST" || !existsSync(lockFile)) throw error;
547
+ if (error?.code !== "EEXIST") throw error;
548
+ } finally {
549
+ rmSync(candidateFile, { force: true });
550
+ }
551
+ if (existsSync(lockFile)) {
535
552
  try {
536
553
  const owner = JSON.parse(readFileSync(lockFile, "utf8"));
537
554
  if (!processIsAlive(Number(owner.pid))) {
@@ -545,9 +562,9 @@ function acquireQueueLock(requestsDir: string, ownerPid: number, ownerToken: str
545
562
  continue;
546
563
  }
547
564
  }
548
- if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
549
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
550
565
  }
566
+ if (Date.now() >= deadline) throw new Error(`architecture queue lock unavailable: ${lockFile}`);
567
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
551
568
  }
552
569
  }
553
570
 
@@ -229,7 +229,10 @@ export function buildMcpCommand(): Command {
229
229
  .action((rawOpts: { json?: boolean }) => {
230
230
  void runMcpAction(() => {
231
231
  const rows = listManagedCodingWorkspaces();
232
- console.log(rawOpts.json ? JSON.stringify({ workspaces: rows }, null, 2) : rows.map((row) => `${row.id}\t${row.branch}\t${row.dirty ? 'dirty' : 'clean'}\t${row.path_exists ? 'present' : 'missing'}`).join('\n'));
232
+ console.log(rawOpts.json ? JSON.stringify({ workspaces: rows }, null, 2) : rows.map((row) => {
233
+ const worktreeState = row.stale_reason !== null ? 'stale' : (row.dirty ? 'dirty' : 'clean');
234
+ return `${row.id}\t${row.branch}\t${worktreeState}\t${row.path_exists ? 'present' : 'missing'}`;
235
+ }).join('\n'));
233
236
  });
234
237
  });
235
238
  workspaces
@@ -611,23 +611,39 @@ export class CodingWorkspaceManager {
611
611
 
612
612
  export function listManagedCodingWorkspaces(env: NodeJS.ProcessEnv = process.env): Array<
613
613
  Omit<CodingWorkspace, 'root' | 'sourceRoot' | 'integrationTargetRef'>
614
- & { integrationTargetRef: string | null; dirty: boolean; path_exists: boolean }
614
+ & { integrationTargetRef: string | null; dirty: boolean | null; path_exists: boolean; stale_reason: string | null }
615
615
  > {
616
- return stateFile(env).workspaces.map((workspace) => ({
617
- id: workspace.id,
618
- repoId: workspace.repoId,
619
- displayName: workspace.displayName,
620
- mode: workspace.mode,
621
- branch: workspace.branch,
622
- baseRef: workspace.baseRef,
623
- baseSha: workspace.baseSha,
624
- integrationTargetRef: typeof workspace.integrationTargetRef === 'string' ? workspace.integrationTargetRef : null,
625
- dirtySource: workspace.dirtySource,
626
- openedAt: workspace.openedAt,
627
- managed: workspace.managed,
628
- path_exists: existsSync(workspace.root),
629
- dirty: existsSync(workspace.root) ? isDirty(workspace.root) : false,
630
- }));
616
+ return stateFile(env).workspaces.map((workspace) => {
617
+ const pathExists = existsSync(workspace.root);
618
+ // A recorded directory that still exists but is no longer a usable worktree
619
+ // is reported as one explicit stale row with an unknown dirty state. It is
620
+ // never skipped, never repaired, and never allowed to fail the whole list.
621
+ let dirty: boolean | null = pathExists ? null : false;
622
+ let staleReason: string | null = null;
623
+ if (pathExists) {
624
+ try {
625
+ dirty = isDirty(workspace.root);
626
+ } catch (error) {
627
+ staleReason = error instanceof Error ? error.message : String(error);
628
+ }
629
+ }
630
+ return {
631
+ id: workspace.id,
632
+ repoId: workspace.repoId,
633
+ displayName: workspace.displayName,
634
+ mode: workspace.mode,
635
+ branch: workspace.branch,
636
+ baseRef: workspace.baseRef,
637
+ baseSha: workspace.baseSha,
638
+ integrationTargetRef: typeof workspace.integrationTargetRef === 'string' ? workspace.integrationTargetRef : null,
639
+ dirtySource: workspace.dirtySource,
640
+ openedAt: workspace.openedAt,
641
+ managed: workspace.managed,
642
+ path_exists: pathExists,
643
+ dirty,
644
+ stale_reason: staleReason,
645
+ };
646
+ });
631
647
  }
632
648
 
633
649
  export function cleanupManagedCodingWorkspace(
@@ -427,12 +427,17 @@ function acquireSession(sessions: McpSessionStore<McpHttpTransport>, sessionId:
427
427
  return sessions.acquire(sessionId);
428
428
  }
429
429
 
430
+ export function resolveMcpHttpProfile(opts: McpServerOptions, config: ReturnType<typeof loadMcpLocalConfig>): string {
431
+ return opts.profile ?? config?.profile ?? 'planner';
432
+ }
433
+
430
434
  async function handleMcpPost(
431
435
  req: Request,
432
436
  res: Response,
433
437
  opts: McpHttpOptions,
434
438
  sessions: McpSessionStore<McpHttpTransport>,
435
439
  codingRuntimes: CodingAuthorizationRuntimeStore | null,
440
+ startupProfile: string,
436
441
  ): Promise<void> {
437
442
  let body: unknown;
438
443
  try {
@@ -443,6 +448,22 @@ async function handleMcpPost(
443
448
  }
444
449
  const sessionId = sessionIdFromRequest(req);
445
450
  if (!sessionId && isInitializeRequest(body)) {
451
+ // The whole HTTP enforcement layer (coding-grant middleware, OAuth
452
+ // requirement, host/origin pinning, per-authorization coding runtimes) is
453
+ // decided once from the startup profile. A local config flip after startup
454
+ // would otherwise let a new session resolve a different tool context behind
455
+ // that stale enforcement, so sessions are bound to the startup profile and
456
+ // a flip fails closed until the operator restarts the server.
457
+ const currentProfile = resolveMcpHttpProfile(opts, loadMcpLocalConfig());
458
+ if (currentProfile !== startupProfile) {
459
+ res.status(503).json({
460
+ error: {
461
+ code: 'PROFILE_CHANGED',
462
+ message: `The local MCP profile changed from "${startupProfile}" to "${currentProfile}" after startup; restart the MCP server to serve the new profile.`,
463
+ },
464
+ });
465
+ return;
466
+ }
446
467
  const authorizationId = codingRuntimes ? authorizationIdFromRequest(req) : undefined;
447
468
  if (codingRuntimes && !authorizationId) {
448
469
  sendOAuthUnauthorized(req, res, 'Coding authorization identity is missing');
@@ -469,24 +490,28 @@ async function handleMcpPost(
469
490
  return;
470
491
  }
471
492
  let sessionInitialized = false;
472
- const transport = new StreamableHTTPServerTransport({
473
- sessionIdGenerator: () => randomUUID(),
474
- onsessioninitialized: (newSessionId) => {
475
- reservation.commit(newSessionId, transport);
476
- sessionInitialized = true;
477
- },
478
- }) as McpHttpTransport;
479
- transport.authorizationId = authorizationId;
480
- transport.onclose = () => {
481
- if (transport.sessionId) sessions.delete(transport.sessionId);
482
- };
483
- const server = createRepoHarnessMcpServer({ ...opts, codingRuntime });
493
+ // Transport and server construction stay inside the try: a throw from either
494
+ // must still release the reservation, otherwise the slot leaks for the whole
495
+ // process lifetime and the store fails closed at capacity forever.
496
+ let transport: McpHttpTransport | undefined;
484
497
  try {
498
+ transport = new StreamableHTTPServerTransport({
499
+ sessionIdGenerator: () => randomUUID(),
500
+ onsessioninitialized: (newSessionId) => {
501
+ reservation.commit(newSessionId, transport as McpHttpTransport);
502
+ sessionInitialized = true;
503
+ },
504
+ }) as McpHttpTransport;
505
+ transport.authorizationId = authorizationId;
506
+ transport.onclose = () => {
507
+ if (transport?.sessionId) sessions.delete(transport.sessionId);
508
+ };
509
+ const server = createRepoHarnessMcpServer({ ...opts, profile: startupProfile, codingRuntime });
485
510
  await server.connect(transport);
486
511
  await transport.handleRequest(req, res, body);
487
512
  } finally {
488
513
  reservation.release();
489
- if (!sessionInitialized) await transport.close().catch(() => undefined);
514
+ if (!sessionInitialized) await transport?.close().catch(() => undefined);
490
515
  }
491
516
  return;
492
517
  }
@@ -557,7 +582,7 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
557
582
  const repoRoot = resolveMcpRepoRoot(opts.repo ?? '.');
558
583
  assertNoLegacyRepoScopeMcpConfig(repoRoot);
559
584
  const localConfig = loadMcpLocalConfig();
560
- const profile = opts.profile ?? localConfig?.profile ?? 'planner';
585
+ const profile = resolveMcpHttpProfile(opts, localConfig);
561
586
  const storedEndpoint = localConfig?.chatgpt?.endpoint;
562
587
  const storedPublicOrigin = storedEndpoint ? new URL(storedEndpoint).origin : undefined;
563
588
  const configuredPublicOrigin = process.env.REPO_HARNESS_MCP_PUBLIC_ORIGIN?.trim()
@@ -636,6 +661,16 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
636
661
  codingRuntimes?.cleanupExpired();
637
662
  }, Math.min(sessionTtlMs, 60_000));
638
663
  cleanupTimer.unref?.();
664
+ // The server description reported by /health is fixed for the process: this
665
+ // context is built once here instead of per request, where rebuilding it
666
+ // re-ran `git rev-parse --show-toplevel` synchronously and blocked the event
667
+ // loop for seconds on a cold disk. Live state stays live: the coding grant is
668
+ // still re-checked per request by the middleware above, and session counters
669
+ // are read from the store on each response.
670
+ const healthContext = createMcpToolContext({ ...opts, repo: repoRoot, codingRuntime: null });
671
+ const healthSchemaHash = createHash('sha256')
672
+ .update(JSON.stringify(buildMcpToolDefinitions(healthContext.policy, { enableChatgptBrowser: opts.enableChatgptBrowser === true })))
673
+ .digest('hex');
639
674
  const app = express();
640
675
 
641
676
  app.use((req, res, next) => {
@@ -678,16 +713,14 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
678
713
  app.get('/health', (req, res) => {
679
714
  sessions.cleanupExpired();
680
715
  codingRuntimes?.cleanupExpired();
681
- const ctx = createMcpToolContext({ ...opts, repo: repoRoot, codingRuntime: null });
682
- const tools = buildMcpToolDefinitions(ctx.policy, { enableChatgptBrowser: opts.enableChatgptBrowser === true });
683
716
  res.json({
684
717
  status: 'ok',
685
718
  server: 'repo-harness-mcp',
686
719
  package_version: repoHarnessPackageVersion(),
687
720
  mcp_protocol: 'streamable-http',
688
- profile: ctx.policy.profile,
689
- capabilities: ctx.policy.capabilities,
690
- allowed_root_count: ctx.policy.allowedRoots?.length ?? 0,
721
+ profile: healthContext.policy.profile,
722
+ capabilities: healthContext.policy.capabilities,
723
+ allowed_root_count: healthContext.policy.allowedRoots?.length ?? 0,
691
724
  auth: authMode === 'oauth' ? (oauthPassphrase ? 'oauth' : 'missing') : (authToken ? authMode : 'missing'),
692
725
  auth_mode: authMode,
693
726
  public_origin: getPublicOrigin(req, configuredPublicOrigin),
@@ -698,7 +731,7 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
698
731
  sessions_closed: sessions.metrics.closed,
699
732
  sessions_expired: sessions.metrics.expired,
700
733
  sessions_evicted: sessions.metrics.evicted,
701
- schema_hash: createHash('sha256').update(JSON.stringify(tools)).digest('hex'),
734
+ schema_hash: healthSchemaHash,
702
735
  });
703
736
  });
704
737
 
@@ -752,7 +785,7 @@ export async function startMcpHttp(opts: McpHttpOptions): Promise<void> {
752
785
  }
753
786
 
754
787
  app.post('/mcp', requireMcpHttpAuth(authMode, authToken, oauthProvider, configuredPublicOrigin), express.raw({ type: '*/*', limit: '1mb' }), (req, res) => {
755
- handleMcpPost(req, res, { ...opts, repo: repoRoot }, sessions, codingRuntimes).catch((error: unknown) => {
788
+ handleMcpPost(req, res, { ...opts, repo: repoRoot }, sessions, codingRuntimes, profile).catch((error: unknown) => {
756
789
  if (!res.headersSent) res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
757
790
  });
758
791
  });