@try-works/dsh-recursive-mode 0.4.3 → 0.4.4

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/lib/index.js CHANGED
@@ -1490,14 +1490,22 @@ function lockOrderRule(artifact, runDir) {
1490
1490
  /**
1491
1491
  * Locked-artifact write rule: a denial when the target carries
1492
1492
  * `Status: LOCKED`, `null` otherwise. Only a run-tree `*.md` is a candidate —
1493
- * the same admission test the pre-T16 branch used.
1493
+ * the same admission test the pre-T16 branch used, now asked of the RESOLVED
1494
+ * path as well (see the ADMISSION note below).
1495
+ *
1496
+ * A caller with no `worktreeRoot` still gets `null` for every target, absolute
1497
+ * ones included: that is the pre-existing behaviour and it is left alone here —
1498
+ * the guard always carries a root, so nothing that reaches it changes.
1494
1499
  */
1495
1500
  function lockedWriteRule(target, worktreeRoot) {
1496
1501
  if (!target || !worktreeRoot) return null;
1497
1502
  const normalized = target.replace(/\\/g, "/");
1498
- if (!normalized.endsWith(".md") || !normalized.includes("/.recursive/run/")) return null;
1503
+ if (!normalized.endsWith(".md")) return null;
1499
1504
  const abs = resolveFrom(worktreeRoot, normalized);
1500
- if (!abs || getLockStatus(abs) !== "LOCKED") return null;
1505
+ if (!abs) return null;
1506
+ const resolved = abs.replace(/\\/g, "/");
1507
+ if (!normalized.includes("/.recursive/run/") && !resolved.includes("/.recursive/run/")) return null;
1508
+ if (getLockStatus(abs) !== "LOCKED") return null;
1501
1509
  return {
1502
1510
  verdict: "deny",
1503
1511
  detail: normalized + " carries Status: LOCKED (reopen explicitly to edit)"
@@ -2123,7 +2131,7 @@ function resolveFrom(worktreeRoot, target) {
2123
2131
  const normalized = target.replace(/\\/g, "/").trim();
2124
2132
  if (!normalized) return null;
2125
2133
  if (/^[A-Za-z]:\//.test(normalized) || normalized.startsWith("/")) return resolve(normalized);
2126
- return resolve(join(worktreeRoot, normalized.replace(/^\.?\/?/, "")));
2134
+ return resolve(join(worktreeRoot, normalized.replace(/^\.\//, "")));
2127
2135
  }
2128
2136
  /** The tool-target path of a call (same key order enforcement.ts uses). */
2129
2137
  function policyTargetPath(args) {
@@ -5993,13 +6001,41 @@ function reviewBundleDir(root, runId) {
5993
6001
  * discipline the lock receipts, the closeout receipts and the selection output all follow.
5994
6002
  */
5995
6003
  /** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
5996
- const FEEDBACK_FILE = "memory/.feedback.json";
6004
+ const FEEDBACK_FILE = ".recursive/memory/.feedback.json";
6005
+ /**
6006
+ * WHERE THE COUNTERS LIVED BEFORE the constant above carried its `.recursive/` prefix.
6007
+ *
6008
+ * ⚠ READ, BUT DELIBERATELY NEITHER MOVED NOR DELETED. Read, because evidence a previous run recorded is
6009
+ * not the plugin's to discard, and without the fallback the first settle after the move would start the
6010
+ * new file from an empty book — a counter lost silently, which is the one outcome ruled out. NOT moved,
6011
+ * because a delete is the single action that could re-create the very defect the prefix fixes: a legacy
6012
+ * file that is TRACKED and COMMITTED is absent from the run's diff while it is clean, so removing it
6013
+ * mid-run puts `D memory/.feedback.json` into the diff of every diff-audited phase authored before the
6014
+ * delete, which is the same retro-invalidation. Left alone, an untracked legacy file is in the diff from
6015
+ * the run's first phase (and is therefore accounted for), while a committed one stays invisible.
6016
+ * `readFeedback` prefers {@link FEEDBACK_FILE}, so the legacy counters are folded forward by the next
6017
+ * settle and this file then only sits there; it is machine-owned and disposable, so delete it by hand.
6018
+ */
6019
+ const LEGACY_FEEDBACK_FILE = "memory/.feedback.json";
5997
6020
  /** Where a run records what it was shown. */
5998
6021
  const INJECTIONS_FILE = "memory-injections.json";
5999
- /** Read the counters. A missing or unreadable file is an empty book, never an error. */
6022
+ /**
6023
+ * Read the counters. A missing or unreadable file is an empty book, never an error.
6024
+ *
6025
+ * ⚠ THE LEGACY PATH IS A FALLBACK AND ONLY A FALLBACK: it is consulted when — and only when — there is
6026
+ * no usable file at {@link FEEDBACK_FILE} yet, which is exactly the first run after the path moved. The
6027
+ * two are never merged, because they are two SNAPSHOTS of one counter and adding them would count a run
6028
+ * twice. A file that exists at the current path but does not parse stays an empty book, which is what
6029
+ * this function has always promised: a corrupt sidecar is not an invitation to read a different file.
6030
+ */
6000
6031
  function readFeedback(root, readFile = defaultRead$1) {
6001
- const text = readFile(join(root, FEEDBACK_FILE));
6002
- if (text === null || text.trim() === "") return {};
6032
+ const current = readFile(join(root, FEEDBACK_FILE));
6033
+ if (current !== null && current.trim() !== "") return parseBook(current);
6034
+ const legacy = readFile(join(root, LEGACY_FEEDBACK_FILE));
6035
+ return legacy === null ? {} : parseBook(legacy);
6036
+ }
6037
+ /** One counters file as a book: anything unreadable or unshaped is `{}`, never an error. */
6038
+ function parseBook(text) {
6003
6039
  try {
6004
6040
  const parsed = JSON.parse(text);
6005
6041
  if (typeof parsed !== "object" || parsed === null) return {};
@@ -6075,7 +6111,9 @@ function settleInjections(root, runDir, lockedPhases, write = defaultWrite, read
6075
6111
  counter.applied += 1;
6076
6112
  book[record.source] = counter;
6077
6113
  }
6078
- write(join(root, FEEDBACK_FILE), JSON.stringify(sortBook(book), null, 2) + "\n");
6114
+ const feedbackPath = join(root, FEEDBACK_FILE);
6115
+ mkdirSync(dirname(feedbackPath), { recursive: true });
6116
+ write(feedbackPath, JSON.stringify(sortBook(book), null, 2) + "\n");
6079
6117
  return book;
6080
6118
  }
6081
6119
  /**
@@ -7234,7 +7272,20 @@ function currentPhaseArtifact(worktreeRoot, runId) {
7234
7272
  best = name;
7235
7273
  }
7236
7274
  }
7237
- return best;
7275
+ let inForce = "";
7276
+ let inForcePhase = Number.POSITIVE_INFINITY;
7277
+ for (const name of names) {
7278
+ if (!name.endsWith(".md")) continue;
7279
+ const phase = phaseNumberForArtifact(name);
7280
+ if (!phase) continue;
7281
+ const value = Number(phase);
7282
+ if (getLockStatus(join(runDir, name)) === "LOCKED") continue;
7283
+ if (value < inForcePhase) {
7284
+ inForcePhase = value;
7285
+ inForce = name;
7286
+ }
7287
+ }
7288
+ return inForce !== "" ? inForce : best;
7238
7289
  }
7239
7290
  function evaluateToolGuard(exec, worktreeRoot, activeRunId, mode = "advisory") {
7240
7291
  const name = exec.name;
@@ -7359,14 +7410,58 @@ function resolveTargetPath(target, worktreeRoot) {
7359
7410
  return abs;
7360
7411
  }
7361
7412
  /**
7413
+ * The ADMISSION test for the tamper path: the resolved absolute path when
7414
+ * `targetPath` names a run-tree `*.md` — a tamper CANDIDATE — and `null`
7415
+ * otherwise. Pure path arithmetic on every branch (no filesystem work), so a
7416
+ * caller may use it as a cheap shape check before paying for `existsSync`.
7417
+ *
7418
+ * ⚠ THE BLIND SPOT THIS FUNCTION EXISTS TO CLOSE. The admission test used to be a
7419
+ * substring test on the target STRING alone, looking for `/.recursive/run/`. A
7420
+ * repo-relative path has no separator before `.recursive`, so
7421
+ * `.recursive/run/<id>/00-requirements.md` — the spelling a model actually types,
7422
+ * and its backslash form — was rejected before ANYTHING was examined, and
7423
+ * tampering with a locked artifact through that spelling was invisible
7424
+ * (measured: `detectTamper` returned a record for the absolute path and `null`
7425
+ * for the relative one, on the same file). The identical defect, in the identical
7426
+ * spelling, was fixed one module over in `policy-globs.ts` `lockedWriteRule`; this
7427
+ * is that fix's shape, reused rather than reinvented.
7428
+ *
7429
+ * So the marker is looked for on the path the target RESOLVES to as well as on
7430
+ * the string as written. The `||` is load-bearing and the string test is KEPT
7431
+ * rather than replaced, because a resolved-only test would SHRINK the admitted
7432
+ * set: an absolute target that literally carries the marker but resolves away
7433
+ * from it (`…/.recursive/run/../…`) was caught before and must stay caught. The
7434
+ * net effect is a strict SUPERSET of the previous behaviour, so no tamper that
7435
+ * was visible before can become invisible.
7436
+ *
7437
+ * EXPORTED because `src/index.ts`'s `fs/observed` listener must apply the SAME
7438
+ * admission test before calling `detectTamper`. That listener used to carry a
7439
+ * hand-copied mirror of this test, and a mirror is exactly what leaves half the
7440
+ * defect behind: widening `detectTamper` alone changes nothing, because the
7441
+ * listener rejects the spelling first. One function cannot disagree with itself.
7442
+ */
7443
+ function tamperCandidatePath(targetPath, worktreeRoot) {
7444
+ const normalized = targetPath.replace(/\\/g, "/");
7445
+ if (!normalized.endsWith(".md")) return null;
7446
+ const abs = resolveTargetPath(normalized, worktreeRoot);
7447
+ if (!abs) return null;
7448
+ const resolved = abs.replace(/\\/g, "/");
7449
+ if (!normalized.includes("/.recursive/run/") && !resolved.includes("/.recursive/run/")) return null;
7450
+ return abs;
7451
+ }
7452
+ /**
7362
7453
  * Layer 8 - fs/observed lock-tamper detection.
7363
7454
  * A locked *.md whose observed version differs from the stored LockHash is
7364
7455
  * a tamper. Returns a tamper reason (or null when clean/not-applicable).
7456
+ *
7457
+ * The admission test lives in `tamperCandidatePath` (above), shared with the
7458
+ * `fs/observed` listener in `src/index.ts` — see the note there for why sharing
7459
+ * it is the point and not a tidiness preference. What this function reports is
7460
+ * unchanged: the same record shape, carrying the target AS WRITTEN.
7365
7461
  */
7366
7462
  function detectTamper(targetPath, worktreeRoot, activeRunId) {
7367
7463
  const normalized = targetPath.replace(/\\/g, "/");
7368
- if (!normalized.endsWith(".md") || !normalized.includes("/.recursive/run/")) return null;
7369
- const abs = resolveTargetPath(normalized, worktreeRoot);
7464
+ const abs = tamperCandidatePath(normalized, worktreeRoot);
7370
7465
  if (!abs || !existsSync(abs)) return null;
7371
7466
  if (getLockStatus(abs) === "STALE_LOCK") return {
7372
7467
  runId: activeRunId,
@@ -10443,6 +10538,8 @@ var RecursiveRuntime = class extends Service {
10443
10538
  }
10444
10539
  const inFlight = pendingWork(runDir);
10445
10540
  if (inFlight.length > 0) throw new Error(toolError("PENDING_WORK", inFlight.map((p) => p.detail).join("; ")));
10541
+ const lint = await this.lintArtifact(runId, artifact, agent);
10542
+ if (!lint.passed) throw new Error("Artifact " + artifact + " does not meet the phase standard, so it was not locked: " + lint.errors.join("; "));
10446
10543
  let content = readFileSync(artifactPath, "utf8");
10447
10544
  const lockedAt = (/* @__PURE__ */ new Date()).toISOString().replace(/\.\d{3}Z$/, "Z");
10448
10545
  content = setOrInsertField(content, "Status", "LOCKED", ["Phase"]);
@@ -12526,7 +12623,7 @@ function renderExplained(entries) {
12526
12623
  * present-but-empty value would stop the deferral and pin the choice to nothing, which is a different state
12527
12624
  * from "unconfigured" and not one a user can see. Absence is the honest representation of "I have no opinion".
12528
12625
  *
12529
- * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer uses. A policy file
12626
+ * 3. **WRITE ATOMICALLY.** A temp file and a rename, the same pattern the preset installer used, before it was retired. A policy file
12530
12627
  * half-written because a process died mid-write would make `loadRouterPolicy` fall back to the built-in
12531
12628
  * self-audit policy — and it does that SILENTLY, on purpose (it never throws). So a torn write here would look
12532
12629
  * exactly like "the user configured nothing", for every role, until someone read the file.
@@ -13614,9 +13711,17 @@ function apply(ctx, config) {
13614
13711
  ctx.tools.register(createRecursiveReviewTool(recursive, subagentsSeam)),
13615
13712
  ctx.tools.register(createRecursiveDelegateTool(recursive, subagentsSeam)),
13616
13713
  ctx.tools.register(createRecursiveAskTool(recursive)),
13617
- ctx.tools.register(createRecursivePreviewTool(recursive)),
13618
- ...agentTeams ? [ctx.tools.register(createRecursiveAuditTeamTool(agentTeams))] : []
13714
+ ctx.tools.register(createRecursivePreviewTool(recursive))
13619
13715
  ];
13716
+ let auditTeamRegistered = agentTeams !== void 0 && agentTeams !== null;
13717
+ if (auditTeamRegistered) disposers.push(ctx.tools.register(createRecursiveAuditTeamTool(agentTeams ?? null)));
13718
+ ctx.inject(["agentTeams"], (teamCtx) => {
13719
+ if (auditTeamRegistered) return;
13720
+ const late = teamCtx.get("agentTeams");
13721
+ if (late === void 0 || late === null) return;
13722
+ auditTeamRegistered = true;
13723
+ ctx.tools.register(createRecursiveAuditTeamTool(late));
13724
+ });
13620
13725
  const commands = ctx.get("commands");
13621
13726
  if (commands) disposers.push(registerRecursiveCommand({ commands }, recursive));
13622
13727
  const systemPrompt = ctx.get("systemPrompt");
@@ -13715,10 +13820,10 @@ function apply(ctx, config) {
13715
13820
  if (observation?.kind !== "present") return;
13716
13821
  const displayPath = target?.displayPath ?? "";
13717
13822
  if (!displayPath) return;
13718
- const normalized = displayPath.replace(/\\/g, "/");
13719
- if (!normalized.endsWith(".md") || !normalized.includes("/.recursive/run/")) return;
13720
13823
  const cwd = actor?.agent?.session?.header?.cwd ?? "";
13721
13824
  if (!cwd) return;
13825
+ const normalized = displayPath.replace(/\\/g, "/");
13826
+ if (!tamperCandidatePath(normalized, cwd)) return;
13722
13827
  const runId = resolveRunDir(cwd)?.runId ?? "";
13723
13828
  const tamper = recursive.detectTamper(normalized, cwd, runId);
13724
13829
  if (!tamper) return;
@@ -13823,4 +13928,4 @@ function apply(ctx, config) {
13823
13928
  });
13824
13929
  }
13825
13930
  //#endregion
13826
- export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
13931
+ export { Config, DEFAULT_BUDGETS, DEFAULT_ENFORCEMENT, OPTIONAL_PHASES, PHASES, PHASE_POSITIONS, PHASE_SEQUENCE, RECURSIVE_API_PREFIX, RUN_ARTIFACT_SEQUENCE, RUN_STATES, RecursiveRuntime, apply, auditToPass, buildDelegationPrompt, buildReviewBundle, buildWorkSlice, builtInToolPolicy, capabilityProbe, childScratchPath, coerceAskToDecision, contentSha256, contractDigest, coupleGateBlockToGoal, createChildBrief, createHandoff, createRecursiveCloseoutTool, createRecursiveInitTool, createRecursiveLintTool, createRecursiveLockTool, createRecursivePhaseTool, createRecursiveScratchTool, createRecursiveStatusTool, createRecursiveWorktreeTool, currentPhaseArtifact, defaultReviewToolFilter, delegate, delegateContinuable, delegationDecisionBasis, delegationError, detectTamper, discoverRuns, drainContinuableChildren, drainContinuableDescendants, escapeRegExp, evaluateDelegationResult, evaluateToolGuard, foldDiagnostics, foldRun, foldRunCard, getAllStaleReceipts, getArtifactState, getGateStatus, getLatestRunDirectory, getLockStatus, getMdFieldValue, getNextLegalPhase, getPrerequisiteBlockers, getPrerequisites, getStaleDownstreamPhases, getTodoStats, getWorkflowProfile, inject, interruptContinuable, invalidateReceipt, isCoreArtifact, isTaskClaimedBy, loadRouterPolicy, lockHashFromContent, makeRecursiveRoutes, mountRecursiveRoutesOnce, name, normalizeForLockHash, parseReplyVerdict, pendingWork, phaseIndex, phasePosition, probeCapabilities, readReceipt, readRepairFromReply, readRepairFromStructured, readVerdictFromReply, readVerdictFromStructured, receiptPath, referencesFromResult, registerRecursiveSkill, remainingDepthFor, renderPhaseTail, renderRecursivePolicy, renderStableContract, renderTaskHistory, replyPath, resetFoldCache, resolveEnforcementConfig, resolveRole, resolveRunDir, resolveToolPolicyForGuard, reviewBundleDir, reviewOutputSchema, routerPolicyPath, snapshotWorkspace, tamperCandidatePath, trimMdValue, validateChain, validateReferences, validateTransition, writeActionRecord, writeReceipt };
@@ -1,5 +1,20 @@
1
1
  /** Where the machine-owned counters live — inside the memory plane, but never a shard a person writes. */
2
- export declare const FEEDBACK_FILE = "memory/.feedback.json";
2
+ export declare const FEEDBACK_FILE = ".recursive/memory/.feedback.json";
3
+ /**
4
+ * WHERE THE COUNTERS LIVED BEFORE the constant above carried its `.recursive/` prefix.
5
+ *
6
+ * ⚠ READ, BUT DELIBERATELY NEITHER MOVED NOR DELETED. Read, because evidence a previous run recorded is
7
+ * not the plugin's to discard, and without the fallback the first settle after the move would start the
8
+ * new file from an empty book — a counter lost silently, which is the one outcome ruled out. NOT moved,
9
+ * because a delete is the single action that could re-create the very defect the prefix fixes: a legacy
10
+ * file that is TRACKED and COMMITTED is absent from the run's diff while it is clean, so removing it
11
+ * mid-run puts `D memory/.feedback.json` into the diff of every diff-audited phase authored before the
12
+ * delete, which is the same retro-invalidation. Left alone, an untracked legacy file is in the diff from
13
+ * the run's first phase (and is therefore accounted for), while a committed one stays invisible.
14
+ * `readFeedback` prefers {@link FEEDBACK_FILE}, so the legacy counters are folded forward by the next
15
+ * settle and this file then only sits there; it is machine-owned and disposable, so delete it by hand.
16
+ */
17
+ export declare const LEGACY_FEEDBACK_FILE = "memory/.feedback.json";
3
18
  /** Where a run records what it was shown. */
4
19
  export declare const INJECTIONS_FILE = "memory-injections.json";
5
20
  /** One entry the agent was shown, as the run recorded it. */
@@ -18,7 +33,15 @@ export interface FeedbackCounter {
18
33
  contradicted: number;
19
34
  }
20
35
  export type FeedbackBook = Record<string, FeedbackCounter>;
21
- /** Read the counters. A missing or unreadable file is an empty book, never an error. */
36
+ /**
37
+ * Read the counters. A missing or unreadable file is an empty book, never an error.
38
+ *
39
+ * ⚠ THE LEGACY PATH IS A FALLBACK AND ONLY A FALLBACK: it is consulted when — and only when — there is
40
+ * no usable file at {@link FEEDBACK_FILE} yet, which is exactly the first run after the path moved. The
41
+ * two are never merged, because they are two SNAPSHOTS of one counter and adding them would count a run
42
+ * twice. A file that exists at the current path but does not parse stays an empty book, which is what
43
+ * this function has always promised: a corrupt sidecar is not an invitation to read a different file.
44
+ */
22
45
  export declare function readFeedback(root: string, readFile?: (path: string) => string | null): FeedbackBook;
23
46
  /** Read what a run recorded being shown. */
24
47
  export declare function readInjections(runDir: string, readFile?: (path: string) => string | null): InjectionRecord[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@try-works/dsh-recursive-mode",
3
3
  "description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + 13 recursive_* tools (recursive_status, recursive_init, recursive_lock, recursive_lint, recursive_closeout, recursive_scratch, recursive_worktree, recursive_phase, recursive_audit_team, recursive_review, recursive_delegate, recursive_ask, recursive_preview)",
4
- "version": "0.4.3",
4
+ "version": "0.4.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -19,7 +19,9 @@ export { Board, listRuns } from './board.tsx'
19
19
  export { Inspector } from './inspector.tsx'
20
20
  export { DocViewer, parseDoc, parseInline } from './doc-viewer.tsx'
21
21
  export { RecursiveView } from './slots.ts'
22
- export { RecursiveSettings } from './settings.tsx'
22
+ export { RecursiveSettings, RecursiveSettingsLive } from './settings.tsx'
23
+ export { settingsView } from './settings-view.ts'
24
+ export type { SettingsView, SettingsRunView, SettingsRow } from './settings-view.ts'
23
25
  export { useLiveProjection } from './use-live.ts'
24
26
  export type { LiveProjectionSnapshot } from './use-live.ts'
25
27
  export { fetchLiveState, subscribeLiveEvents } from './host-api.ts'
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Pure display model for the Recursive settings panel (read-only report).
3
+ *
4
+ * WHY THIS MODULE EXISTS. The old panel rendered one sentence and no values — it
5
+ * claimed to report the host's configuration while reporting nothing. The data
6
+ * was already arriving: `host-api.ts` serves {root, projection, revision} on
7
+ * every fs change plus a 15s heartbeat, and `derive.ts` already derives board
8
+ * facts from the same projection. This module adds no data path; it is the same
9
+ * projection read for a different surface, and it is PURE (no React, no fs, no
10
+ * session window — the read-only-client + projection-over-files invariant, §11.9).
11
+ *
12
+ * THE ABSENCE RULE (`text()` + `SettingsRow.value === null`): a value is printed
13
+ * only when the projection carried it. A missing/blank value renders as
14
+ * `not reported` — never an empty row and never a plausible default. Note that
15
+ * `derive.cardFacts()` deliberately defaults a subagent's absent `status` to
16
+ * 'done'; this model does NOT, because a report that invents "done" is a report
17
+ * that lies. Required collection fields (`tampers`, `subagents`) that are present
18
+ * but empty print `none` — that IS their value; fields the card omits print
19
+ * `not reported`.
20
+ */
21
+ import type { PhasePosition, RecursiveRunCard } from '../types.ts'
22
+ import type { LiveProjectionValue } from './contract.ts'
23
+ import { listRuns } from './board.tsx'
24
+ import { cardFacts, cardPill, expandPhaseRows, phaseStatusPill, type PillKind } from './derive.ts'
25
+
26
+ /** A label/value line. `value === null` means the projection carried NO value. */
27
+ export interface SettingsRow {
28
+ id: string
29
+ label: string
30
+ value: string | null
31
+ }
32
+
33
+ /** One folded phase row, with the T21 single derived position printed verbatim. */
34
+ export interface SettingsPhaseRow {
35
+ phase: string
36
+ /** The phase doc filename, or null for a slot this run does not have (03.5). */
37
+ fileName: string | null
38
+ /** The raw folded status ('LOCKED' / 'DRAFT' / '—'). */
39
+ status: string
40
+ /** T21 single derived position; null when the producer carried none. */
41
+ position: PhasePosition | null
42
+ lockedAt: string | null
43
+ lockHash: string | null
44
+ present: boolean
45
+ pill: PillKind
46
+ }
47
+
48
+ /** One subagent record: role/provider exactly as the host fold recorded them. */
49
+ export interface SettingsSubagentRow {
50
+ childId: string
51
+ role: string | null
52
+ provider: string | null
53
+ status: string | null
54
+ }
55
+
56
+ /** One unresolved in-flight work item (T18). */
57
+ export interface SettingsPendingRow {
58
+ kind: string
59
+ delegationId: string
60
+ detail: string
61
+ }
62
+
63
+ /** One run's report. */
64
+ export interface SettingsRunView {
65
+ runId: string
66
+ worktreeRoot: string
67
+ state: string
68
+ pill: PillKind
69
+ /** Scalar facts; a null value means the card did not carry that field. */
70
+ rows: SettingsRow[]
71
+ phases: SettingsPhaseRow[]
72
+ /** null when the card carried no `subagents` key; [] when it carried an empty one. */
73
+ subagents: SettingsSubagentRow[] | null
74
+ /** null when the card carried no `pendingWork` key; [] when it carried an empty one. */
75
+ pendingWork: SettingsPendingRow[] | null
76
+ }
77
+
78
+ /** Whether the route has answered, and whether it resolved a recursive root. */
79
+ export type SettingsStatus = 'no-frame' | 'no-root' | 'connected'
80
+
81
+ /** The whole panel model. */
82
+ export interface SettingsView {
83
+ status: SettingsStatus
84
+ root: string | null
85
+ revision: number | null
86
+ runs: SettingsRunView[]
87
+ /**
88
+ * Route-level facts the payload does not carry AT ALL — the panel names them
89
+ * so a reader is never left to assume the client is hiding them.
90
+ */
91
+ notCarried: SettingsRow[]
92
+ }
93
+
94
+ /**
95
+ * The `/state` payload is {root, projection, revision} (host-api.ts /
96
+ * live-route.ts, both typed by LiveProjectionValue). Nothing else rides it, so
97
+ * anything else is reported as not carried rather than guessed.
98
+ */
99
+ export const NOT_CARRIED: readonly SettingsRow[] = [
100
+ { id: 'enforcement', label: 'Enforcement modes (preStep / toolGuards / tamper)', value: null },
101
+ { id: 'router-defaults', label: 'Router defaults (defaults.subagent) and per-role routes', value: null },
102
+ { id: 'scratch-format', label: 'Scratch format', value: null },
103
+ ]
104
+
105
+ /** A carried string, or null when the producer carried nothing to print. */
106
+ function text(value: string | undefined | null): string | null {
107
+ if (value === undefined || value === null) return null
108
+ return value.trim() === '' ? null : value
109
+ }
110
+
111
+ /**
112
+ * Read a field the WIRE may omit even where the type calls it required (an
113
+ * older or partial producer). The comparison itself has to happen here: with
114
+ * `strict`, `card.subagents === undefined` is a no-overlap error at the call
115
+ * site, but a missing field must still read as "not reported", not crash.
116
+ */
117
+ function carried<T>(value: T | undefined): T | undefined {
118
+ return value
119
+ }
120
+
121
+ /** `none` for a present-but-empty map, null for a map the card did not carry. */
122
+ function tamperValue(card: RecursiveRunCard): string | null {
123
+ const tampers = carried(card.tampers)
124
+ if (tampers === undefined) return null
125
+ const facts = Object.values(tampers)
126
+ if (facts.length === 0) return 'none'
127
+ return facts.map((f) => (text(f.path) ?? '') + ': ' + (text(f.reason) ?? 'no reason carried')).join(' · ')
128
+ }
129
+
130
+ /** Scalar facts for one run, in a fixed order. */
131
+ function runRows(card: RecursiveRunCard): SettingsRow[] {
132
+ const facts = cardFacts(card)
133
+ const gate = card.gateBlocked
134
+ return [
135
+ { id: 'state', label: 'run state', value: text(card.state) },
136
+ { id: 'state-reason', label: 'state reason', value: text(card.stateReason) },
137
+ { id: 'lock', label: 'lock position', value: facts.lockValidity },
138
+ { id: 'locked', label: 'locked phase groups', value: String(facts.lockedCount) + ' of ' + String(facts.totalPhases) + ' present' },
139
+ { id: 'current-phase', label: 'current phase', value: text(facts.currentPhase) },
140
+ {
141
+ id: 'gate',
142
+ label: 'gate block',
143
+ value: gate === undefined
144
+ ? null
145
+ : (text(gate.kind) ?? 'kind not carried') + ': ' + (gate.failures.length === 0 ? 'no failures listed' : gate.failures.join('; ')),
146
+ },
147
+ { id: 'tampers', label: 'tamper facts', value: tamperValue(card) },
148
+ { id: 'repo', label: 'repo', value: text(card.repo) },
149
+ { id: 'template', label: 'template', value: text(card.template) },
150
+ { id: 'merged', label: 'merged to repo root', value: text(card.mergedToRepoRoot) },
151
+ ]
152
+ }
153
+
154
+ /** The folded phase chain, with the card's own `position` read off the wire row. */
155
+ function phaseRows(card: RecursiveRunCard): SettingsPhaseRow[] {
156
+ return expandPhaseRows(card).map((row) => {
157
+ const wired = row.fileName === null ? undefined : card.phases[row.fileName]
158
+ return {
159
+ phase: row.phase,
160
+ fileName: row.fileName,
161
+ status: row.status,
162
+ position: wired?.position ?? null,
163
+ lockedAt: text(row.lockedAt),
164
+ lockHash: text(row.lockHash),
165
+ present: row.present,
166
+ pill: phaseStatusPill(row.status),
167
+ }
168
+ })
169
+ }
170
+
171
+ /** One run card -> its report. */
172
+ export function settingsRunView(card: RecursiveRunCard): SettingsRunView {
173
+ const subagents = carried(card.subagents)
174
+ const pendingWork = carried(card.pendingWork)
175
+ return {
176
+ runId: card.runId,
177
+ worktreeRoot: card.worktreeRoot,
178
+ state: card.state,
179
+ pill: cardPill(card),
180
+ rows: runRows(card),
181
+ phases: phaseRows(card),
182
+ subagents: subagents === undefined
183
+ ? null
184
+ : Object.values(subagents).map((s) => ({
185
+ childId: s.childId,
186
+ role: text(s.role),
187
+ provider: text(s.provider),
188
+ status: text(s.status),
189
+ })),
190
+ pendingWork: pendingWork === undefined
191
+ ? null
192
+ : pendingWork.map((p) => ({ kind: p.kind, delegationId: p.delegationId, detail: p.detail })),
193
+ }
194
+ }
195
+
196
+ /**
197
+ * The panel model for one live snapshot (null = the route has not answered yet).
198
+ * Every run in the projection is reported; nothing is filtered or defaulted.
199
+ */
200
+ export function settingsView(snapshot: LiveProjectionValue | null): SettingsView {
201
+ if (snapshot === null) {
202
+ return { status: 'no-frame', root: null, revision: null, runs: [], notCarried: [...NOT_CARRIED] }
203
+ }
204
+ return {
205
+ status: snapshot.root === null ? 'no-root' : 'connected',
206
+ root: text(snapshot.root),
207
+ revision: typeof snapshot.revision === 'number' ? snapshot.revision : null,
208
+ runs: listRuns(snapshot.projection).map(settingsRunView),
209
+ notCarried: [...NOT_CARRIED],
210
+ }
211
+ }