@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/README.md +20 -15
- package/lib/client/index.d.ts +3 -1
- package/lib/client/settings-view.d.ts +97 -0
- package/lib/client/settings.d.ts +25 -1
- package/lib/client.js +530 -11
- package/lib/enforcement.d.ts +37 -0
- package/lib/index.js +123 -18
- package/lib/memory-feedback.d.ts +25 -2
- package/package.json +1 -1
- package/src/client/index.ts +3 -1
- package/src/client/settings-view.ts +211 -0
- package/src/client/settings.tsx +194 -8
- package/src/client/slots.ts +15 -2
- package/src/client/styles.ts +237 -0
- package/src/enforcement.ts +74 -3
- package/src/index.ts +47 -10
- package/src/memory-feedback.ts +50 -5
- package/src/phase-rules.ts +10 -1
- package/src/policy-globs.ts +25 -3
- package/src/policy-write.ts +1 -1
- package/src/runtime.ts +29 -0
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")
|
|
1503
|
+
if (!normalized.endsWith(".md")) return null;
|
|
1499
1504
|
const abs = resolveFrom(worktreeRoot, normalized);
|
|
1500
|
-
if (!abs
|
|
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
|
-
/**
|
|
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
|
|
6002
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|
package/lib/memory-feedback.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
4
|
+
"version": "0.4.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
+
}
|