@galda/cli 0.10.41 → 0.10.42

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/app/index.html CHANGED
@@ -1218,6 +1218,14 @@
1218
1218
  .gearpop .srow.dis{opacity:.38;pointer-events:none}
1219
1219
  .gearpop .srow .sl{flex:1;min-width:0;font-size:12.5px;color:var(--ink)}
1220
1220
  .gearpop .srow .sd{display:block;font-size:10.5px;color:var(--ink3);margin-top:2px}
1221
+ /* Project rules read-only view (§1.5a): a quiet framed panel under its row —
1222
+ mono filename label + the file body, scrollable. Reuses --canvas/--hair so it
1223
+ matches the MCP box; no new surface invented. */
1224
+ .gearpop .rulesview{margin:2px 0 9px;display:flex;flex-direction:column;gap:8px}
1225
+ .gearpop .rulesfile{background:var(--canvas);border-radius:8px;overflow:hidden}
1226
+ .gearpop .rulesname{font:600 9px/1 var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--ink3);padding:8px 10px 0}
1227
+ .gearpop .rulestext{margin:6px 0 0;padding:0 10px 10px;max-height:220px;overflow:auto;font:400 11px/1.6 var(--mono);color:var(--ink2);white-space:pre-wrap;word-break:break-word}
1228
+ .gearpop .rulesempty{color:var(--ink3);font-style:italic}
1221
1229
  /* MCP tab: live status dot + connect command + quiet explanation */
1222
1230
  .gearpop .mcpstat{display:flex;align-items:center;gap:8px;font:600 11px/1 var(--ui);color:var(--ink);padding:2px 0 10px}
1223
1231
  .gearpop .mcpstat i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 6px rgba(var(--green-rgb),.5)}
@@ -3276,7 +3284,7 @@ const savedTheme = (() => {
3276
3284
  if (v !== s) localStorage.setItem('theme', v);
3277
3285
  return v;
3278
3286
  })();
3279
- const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null,
3287
+ const state = { repos: null, repoHome: '', /* GET /api/repos — the folders a project can point at (Context Bar) */ projects: [], goals: [], tasks: [], act: {}, actAt: {}, todos: {}, chatTurns: {}, active: 'default', editing: null, attach: [], connectCommand: '', models: ['sonnet'], agentModels: null, agentEfforts: null, agents: ['claude-code', 'codex'], expanded: new Set(), openThreads: new Set(), queueOrder: {}, externalActivity: [], selectedGoal: null, workflowColumns: {}, wfDraft: null, reviewDefinitions: {}, rdDraft: null, connectedApp: localStorage.getItem('sel:agent') || 'claude-code', reviewOpenGoal: null, reviewApprovals: {}, goalDetailOpen: null, qreplyOpen: new Set(), logCollapsed: localStorage.getItem('logCollapsed') !== '0', logEntries: [], layout: localStorage.getItem('layout') || 'flagship', theme: savedTheme, boardskin: localStorage.getItem('boardskin') || 'auto', summaryPattern: Number(localStorage.getItem('summaryPattern')) || 3, peraSpot: 0, skill: null, skillsCache: null, projectRules: {}, rulesOpen: false,
3280
3288
  // per-message send overrides set via slash-commands (reset after each send,
3281
3289
  // except model which is sticky in localStorage). palette = open command list.
3282
3290
  msg: { mode: null, model: null, effort: null, skill: null, later: false, review: false },
@@ -10526,8 +10534,44 @@ function gearSetHTML(){
10526
10534
  <button class="optchip${lang === 'en' ? ' on' : ''}" data-gslang="en">EN</button>
10527
10535
  <button class="optchip${lang === 'ja' ? ' on' : ''}" data-gslang="ja">JA</button></div>
10528
10536
  ${billingRow}
10537
+ ${gearRulesHTML()}
10529
10538
  <div class="srow dis"><div class="sl">Data folder<span class="sd">Runs are stored in the engine's chat-runs folder on the host</span></div></div>`;
10530
10539
  }
10540
+ // ONE-CONVERSATION §1.5a: the project's working rules — the file(s) the connected
10541
+ // AI actually reads (Claude Code → CLAUDE.md, Codex → AGENTS.md). Read-only: Galda
10542
+ // does NOT own these rules, it only surfaces them (editing is done in an editor or
10543
+ // by asking the AI in chat). §3 段階開示 — when the project has no such file the row
10544
+ // is absent entirely (never an empty container; the "ask once" flow is §1.5b).
10545
+ function gearRulesHTML(){
10546
+ const r = state.projectRules[state.active];
10547
+ if (!r || !r.files || !r.files.length) return '';
10548
+ const names = r.files.map((f) => f.name).join(' · ');
10549
+ const view = state.rulesOpen
10550
+ ? `<div class="rulesview">${r.files.map((f) =>
10551
+ `<div class="rulesfile"><div class="rulesname">${esc(f.name)}</div>`
10552
+ + `<pre class="rulestext">${esc(f.content) || '<span class="rulesempty">(empty file)</span>'}</pre></div>`).join('')}</div>`
10553
+ : '';
10554
+ return `
10555
+ <div class="srow"><div class="sl">Project rules<span class="sd">What this project tells the AI — read-only · ${esc(names)}</span></div>
10556
+ <button class="optchip${state.rulesOpen ? ' on' : ''}" data-grules="1">${state.rulesOpen ? 'Hide' : 'View'}</button></div>
10557
+ ${view}`;
10558
+ }
10559
+ // Fetch the active project's rules files once (cached per project), then rebuild
10560
+ // the settings panel so the row appears. The browser can't read the host FS, so
10561
+ // this is the only way in — GET /api/projects/:id/rules (engine, §1.5a).
10562
+ let rulesFetching = null;
10563
+ async function loadProjectRules(){
10564
+ const pid = state.active;
10565
+ if (state.projectRules[pid] || rulesFetching === pid) return;
10566
+ rulesFetching = pid;
10567
+ try {
10568
+ const r = await fetch(withKey(`/api/projects/${pid}/rules`));
10569
+ state.projectRules[pid] = r.ok ? await r.json() : { files: [] };
10570
+ } catch { state.projectRules[pid] = { files: [] }; }
10571
+ finally { rulesFetching = null; }
10572
+ // Only rebuild if the settings tab is still the one on screen.
10573
+ if ($('gearPop') && custTab === 'settings') buildGearPop();
10574
+ }
10531
10575
  // MCP (v46): connection status dot + connectCommand + COPY + quiet explanation.
10532
10576
  // The dot is the app's own live engine link (SSE up/down) — the only real signal here.
10533
10577
  function gearMcpHTML(){
@@ -10578,6 +10622,9 @@ function buildGearPop(){
10578
10622
  try { await fetch(withKey('/api/signout'), { method: 'POST' }); recentlySignedOut = true; }
10579
10623
  finally { await refresh(); if ($('gearPop')) buildGearPop(); }
10580
10624
  };
10625
+ const rulesBtn = pop.querySelector('[data-grules]');
10626
+ if (rulesBtn) rulesBtn.onclick = () => { state.rulesOpen = !state.rulesOpen; buildGearPop(); };
10627
+ loadProjectRules();
10581
10628
  return;
10582
10629
  }
10583
10630
  if (custTab === 'mcp') {
@@ -10711,6 +10758,7 @@ function custPlace(){ // flagship: top-anchored band beside the rail (v45 §2);
10711
10758
  }
10712
10759
  function custOpen(tab){
10713
10760
  custTab = tab || 'theme'; // v46 deep-link (flagship __custOpen(tab)): menu entries land on their tab
10761
+ state.rulesOpen = false; // rules panel starts collapsed each time the gear opens
10714
10762
  custSnap = { cust: { ...cust }, theme: state.theme, band: fsUI.band };
10715
10763
  buildGearPop(); custPlace();
10716
10764
  $('gearPop').classList.add('open');
package/engine/lib.mjs CHANGED
@@ -1135,6 +1135,26 @@ export function classifyTestGate({ current = [], baseline = [] } = {}) {
1135
1135
  return { newFailures, preExisting, blocked: newFailures.length > 0 };
1136
1136
  }
1137
1137
 
1138
+ // A manager test-run that was KILLED before it could finish is inconclusive, not
1139
+ // a red. When the machine is saturated (prod + several worker goals all standing
1140
+ // up real servers + headless Chrome), `npm test` gets killed at its execFile
1141
+ // timeout BEFORE node --test can print its "ℹ pass N / ℹ fail N" summary — so the
1142
+ // parse sees 0 passed and the killed process is counted as a single phantom
1143
+ // failure. That is the machine being too busy to run the suite, not the goal's
1144
+ // code breaking, and blocking a finished goal (with a PR) on it strands good work
1145
+ // (dogfood #602/#604, 2026-07-23: passed:0, "1 failing", every listed failure a
1146
+ // 30s timeout, no summary line).
1147
+ //
1148
+ // The signal is `timedOut` (execFile killed the process — killed/SIGTERM/ETIMEDOUT),
1149
+ // NOT the mere absence of a summary: a small suite that printed `not ok 1` and
1150
+ // exited 1 on its own is a REAL failure and must still block. We also require
1151
+ // passed===0 (a killed run that had already logged passes is unusual, but if any
1152
+ // test passed the suite clearly ran) and fail CLOSED on a real AssertionError that
1153
+ // shares the killed run.
1154
+ export function isInconclusiveTestRun({ ran = false, timedOut = false, passed = 0, realAssertion = false } = {}) {
1155
+ return Boolean(ran) && Boolean(timedOut) && !realAssertion && passed === 0;
1156
+ }
1157
+
1138
1158
  // The verification-gate decision (Masa 2026-07-19: "検証/proof は Failed を作らない").
1139
1159
  // PROOF IS ADVISORY: a UI goal whose screenshot could not be captured
1140
1160
  // (proofMissing) or whose pixel heuristic could not auto-confirm the target
@@ -1979,6 +1999,28 @@ export function hasGitChanges(porcelainStatus) {
1979
1999
  return Boolean(String(porcelainStatus ?? '').trim());
1980
2000
  }
1981
2001
 
2002
+ // ---- project rules files (ONE-CONVERSATION §1.5a) --------------------------
2003
+ // The files that hold a project's working rules — the instructions the connected
2004
+ // AI actually reads (Claude Code reads CLAUDE.md from cwd; Codex reads AGENTS.md).
2005
+ // Galda does NOT own these rules: it only surfaces whichever files exist,
2006
+ // read-only. Order is fixed so the settings row is stable, and both are shown
2007
+ // when both exist (never show one and let the other read as "missing").
2008
+ export const PROJECT_RULES_FILENAMES = ['CLAUDE.md', 'AGENTS.md'];
2009
+
2010
+ // Given a project dir and a reader (dir,name)->string|null, return the rules
2011
+ // files that exist, in PROJECT_RULES_FILENAMES order. A file the reader can't
2012
+ // return (absent → null, or throws) is omitted, so the UI shows only what is
2013
+ // really there rather than an empty rules pane.
2014
+ export function collectProjectRules(dir, readFile) {
2015
+ const files = [];
2016
+ for (const name of PROJECT_RULES_FILENAMES) {
2017
+ let content = null;
2018
+ try { content = readFile(dir, name); } catch { content = null; }
2019
+ if (typeof content === 'string') files.push({ name, content });
2020
+ }
2021
+ return files;
2022
+ }
2023
+
1982
2024
  // ---- merge-time conflict detection -----------------------------------------
1983
2025
  // Parallel goals never corrupt each other's WORKING files (each runs in its
1984
2026
  // own worktree) — but two goals that edited the same repo path will still
package/engine/server.mjs CHANGED
@@ -21,8 +21,9 @@ import { homedir } from 'node:os';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { pathToFileURL } from 'node:url';
23
23
  import { needsProjectFolder, folderLabel, buildFolderQuestion, resolveFolderAnswer, classifyFolderTarget, buildFolderInitConfirm, isFolderInitConfirmed, needsReviewPreference, buildReviewPreferenceQuestion, resolveReviewPreferenceAnswer, routeQuestionAnswer, notUnderstoodNote, parseRelocalizedQuestion, classifyAuthProbe, authBannerText, makeSigninChallenge, parseClaimResponse } from './lib.mjs';
24
- import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
24
+ import { parseStreamEvents, parseCodexEvents, buildCodexArgs, parsePlan, refinePlanTasks, canEditGoal, canDeleteGoal, shouldAskClarification, workerExitReason, isForcedStop, taskStatusAfterVerify, workerResultText, taskCountsAsComplete, resolveWantsPR, resolveGoalSource, buildGoalSummary, buildGoalProofMd, buildGoalPrBody, buildReviewSummaryPrompt, parseReviewSummary, buildReviewRuleSection, nextGoalStatus, isPrMerged, isPrApproved, reviewToDoneStatus, advanceReviewGoal, revertReviewGoal, approveGoal, dismissGoal, rejectGoal, reopenGoal, permissionModeFor, isPlanReview, nextAfterPlanApprove, GOAL_MODES, parseSkillFrontmatter, collectSkills, retestGoal, cancelRetestGoal, computeRetestOutcome, revertGoal, planRevertActions, archiveGoal, unarchiveGoal, undismissGoal, parseNumstat, truncateDiffText, sumUsage, resolveEntryUrl, parseGitLog, diffNewCommits, replayQueueLog, reconcileOrphanGoals, reorderQueue, sortQueueByPriority, TASK_PRIORITIES, validateWorkflowColumns, DEFAULT_WORKFLOW_COLUMNS, validateReviewDefinition, DEFAULT_REVIEW_DEFINITION, buildEphemeralSeedLog, buildEphemeralSeedProjects, trimTaskActivityForState, buildAskContext, WORKER_TOOLS, workerPrompt, verifyCfAccessJwt, resolveIdentity, goalVisibleTo, clampParallelLimit, canStartMore, nextRunnableTasks, goalsConflict, detectConflicts, pickFoldTarget, hasTestRelevantChanges, parseFailingTestNames, classifyTestGate, isInconclusiveTestRun, classifyVerifyGate, buildExecutionPlan, buildContextHandoffSummary, buildFailurePostmortem, appendFailureMemory, latestFailurePolicy, classifyChangeRisk, checkRunBudget, usageBudgetTokens, isRateLimited, nextResumeDelay, checkFreeTierLimit, resolveEntitlement, resolveCachedEntitlement, FREE_TIER_LIMITS, QUEUED_GOAL_STATUSES, verifyLicenseToken, shouldEmitSetupCompleted, pickAnalyticsUid, shouldEmitFreeExhausted, detectRequestLanguage, isNothingVerifiable, classifyComposerIntentHeuristic, detectPauseIntent, buildIntentPrompt, parseIntentResponse, buildBoardSnapshot, validateBoardSnapshot, boardIsEmpty, decideBoardPull, resolveConnectedAgents, pickAvailableAgent, pickUtilityAgent, utilityModel, liveTakeoverDecision, shouldOpenBrowserOnBoot, goalTaskTitle, buildGoalTask, RUN_DECL_FILE, parseRunDeclaration, SHOT_DIR, collectShotNames, parseGoalAddress, REPLY_OUTCOMES, REPLY_INTENTS, buildReplyIntentPrompt, parseReplyIntent, buildGoalMessage, serializeGoalMessage, parseGoalMessages } from './lib.mjs';
25
25
  import { createSerialQueue } from './lib.mjs';
26
+ import { collectProjectRules } from './lib.mjs';
26
27
  import { openPR } from './pr.mjs';
27
28
  import { runVerification, exerciseUi } from './verify.mjs';
28
29
 
@@ -2465,8 +2466,11 @@ const testRunQueue = createSerialQueue();
2465
2466
  // project's own `npm test` in the goal's worktree and parse node --test's
2466
2467
  // "ℹ pass N / ℹ fail N" (or TAP "# pass/# fail"). Returns {ran,passed,failed,ok}.
2467
2468
  function runTestsOnce(dir) {
2469
+ // The timeout is env-overridable so tests can force the killed-run path fast;
2470
+ // production keeps the 4-minute default.
2471
+ const runTimeout = Number(process.env.MANAGER_TEST_TIMEOUT_MS) || 240000;
2468
2472
  return testRunQueue.run(() => new Promise((res) => {
2469
- execFile('npm', ['test'], { cwd: dir, timeout: 240000, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
2473
+ execFile('npm', ['test'], { cwd: dir, timeout: runTimeout, env: { ...process.env }, maxBuffer: 8 * 1024 * 1024 }, (e, out = '', err = '') => {
2470
2474
  const text = `${out}\n${err}`;
2471
2475
  const n = (re) => { const m = text.match(re); return m ? Number(m[1]) : null; };
2472
2476
  const passed = n(/ℹ pass (\d+)/) ?? n(/# pass (\d+)/) ?? n(/(\d+) passing/) ?? 0;
@@ -2480,9 +2484,15 @@ function runTestsOnce(dir) {
2480
2484
  const timeoutFlakes = (text.match(/did not report ready in time/g) || []).length;
2481
2485
  const realAssertion = /AssertionError|Expected values to be|expected .+ (?:to |but )/i.test(text);
2482
2486
  const failed = realAssertion ? failedTotal : Math.max(0, failedTotal - timeoutFlakes);
2487
+ // Was the run KILLED at the execFile timeout (machine too saturated to
2488
+ // finish), rather than exiting on its own? execFile sets killed=true and
2489
+ // signal='SIGTERM' on timeout. That is the inconclusive signal — a normal
2490
+ // non-zero exit (a small suite that printed `not ok` and exited 1) is NOT
2491
+ // killed and must still be treated as a real result (isInconclusiveTestRun).
2492
+ const timedOut = Boolean(e && (e.killed || e.signal === 'SIGTERM' || e.code === 'ETIMEDOUT'));
2483
2493
  // Capture the failing test NAMES too (not just the count) so the gate can
2484
2494
  // diff them against the base's failures and block only on NEW ones.
2485
- res({ passed, failed, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
2495
+ res({ passed, failed, timedOut, realAssertion, detail: testOutputExcerpt(text), failingNames: parseFailingTestNames(text) });
2486
2496
  });
2487
2497
  }));
2488
2498
  }
@@ -2501,11 +2511,16 @@ async function runProjectTests(dir) {
2501
2511
  r = {
2502
2512
  passed: Math.max(r.passed, r2.passed),
2503
2513
  failed: Math.min(r.failed, r2.failed),
2514
+ // Only an ALL-killed pair is inconclusive: if EITHER attempt exited on its
2515
+ // own (timedOut=false) the result is conclusive. realAssertion is OR-ed so
2516
+ // a real red in either run still fails closed.
2517
+ timedOut: Boolean(r.timedOut && r2.timedOut),
2518
+ realAssertion: r.realAssertion || r2.realAssertion,
2504
2519
  detail: kept.detail || r.detail || r2.detail || '',
2505
2520
  failingNames: kept.failingNames ?? r.failingNames ?? [],
2506
2521
  };
2507
2522
  }
2508
- return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
2523
+ return { ran: true, passed: r.passed, failed: r.failed, ok: r.failed === 0, timedOut: !!r.timedOut, realAssertion: !!r.realAssertion, detail: r.detail ?? '', failingNames: r.failingNames ?? [] };
2509
2524
  }
2510
2525
 
2511
2526
  // Failing tests on the BASE (pre-change) checkout — the set the review gate
@@ -2946,6 +2961,16 @@ async function verifyGate(goal, prProject, siblings, baseProject, activityTask =
2946
2961
  // Manager-shaped repos.
2947
2962
  const proofFailing = siblings.some((t) => t.proof && t.proof.pass === false);
2948
2963
  let testsFailing = goal.testResult.ran && goal.testResult.failed > 0;
2964
+ // Inconclusive run (2026-07-23): the suite was killed before it could finish
2965
+ // (0 passed, no summary, no real assertion) — the machine was too saturated to
2966
+ // run the tests, not the code failing. Blocking a finished goal (with a PR) on
2967
+ // that stranded #602/#604. Treat it like the infra-flake discount above: don't
2968
+ // block, note it, and let a human re-run when the machine is idle.
2969
+ if (testsFailing && isInconclusiveTestRun(goal.testResult)) {
2970
+ testsFailing = false;
2971
+ goal.testResult.inconclusive = true;
2972
+ goalAct('Project tests could not complete — the suite was killed before finishing (0 passed, no real assertion), which means the machine was too saturated to run them, not a code failure. Treating as inconclusive, not blocking. Re-run the tests when the machine is idle to confirm.');
2973
+ }
2949
2974
  // Baseline diff (Masa 2026-07-19): block ONLY on failures THIS change
2950
2975
  // introduced. A test already red on the base — e.g. persist-failure on clean
2951
2976
  // origin/main — is pre-existing noise and must not knock a proof-verified
@@ -3410,6 +3435,22 @@ const server = createServer(async (req, res) => {
3410
3435
  return json(res, 200, { repos: detectRepos(HOME), home: HOME });
3411
3436
  }
3412
3437
 
3438
+ // The project's working rules (ONE-CONVERSATION §1.5a): the settings panel
3439
+ // shows a read-only view of whichever of CLAUDE.md / AGENTS.md exist in the
3440
+ // project dir — the instructions the connected AI actually reads. The browser
3441
+ // can't touch the filesystem, so it reads them here. Filenames are fixed
3442
+ // (collectProjectRules), so `project.dir` is the only path input and there is
3443
+ // no traversal surface.
3444
+ const rulesMatch = url.pathname.match(/^\/api\/projects\/([\w-]+)\/rules$/);
3445
+ if (rulesMatch && req.method === 'GET') {
3446
+ const project = projects.find((p) => p.id === rulesMatch[1]);
3447
+ if (!project) return json(res, 404, { error: 'project not found' });
3448
+ const files = collectProjectRules(project.dir, (dir, name) => {
3449
+ try { return readFileSync(join(dir, name), 'utf8'); } catch { return null; }
3450
+ });
3451
+ return json(res, 200, { dir: project.dir, files });
3452
+ }
3453
+
3413
3454
  if (url.pathname === '/api/projects' && req.method === 'POST') {
3414
3455
  let body = '';
3415
3456
  req.on('data', (d) => { body += d; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@galda/cli",
3
- "version": "0.10.41",
3
+ "version": "0.10.42",
4
4
  "type": "module",
5
5
  "description": "Galda - hand off work to Claude Code or Codex, get proof back. Runs on your existing subscription, no extra API cost.",
6
6
  "scripts": {