From a01674bc7c84aec571bce40b91be96b17ed3471a Mon Sep 17 00:00:00 2001
From: jaymar921
Date: Fri, 14 Aug 2026 14:51:52 +0800
Subject: [PATCH 1/4] fix(0.7.0): close the three findings the 0.6.1 SAST pass
deferred
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
npx was on the default allow-list, NON_INTERACTIVE_ENV set npm_config_yes so its own
"Ok to proceed?" never appeared, and it was absent from ALWAYS_CONFIRM — so under
auto-approve it fetched and ran arbitrary remote code with no click, in an extension
whose headline claim is that it works fully offline. A live 0.6.0 run has it in the
audit log, auto-approved and recorded as routine. All six spellings now confirm: npx,
npm exec/x/create, yarn dlx/create, pnpm dlx/create. Bare `npm init` is left alone
because it writes a manifest and touches no network, and a click for nothing is a
click trained away.
workspaceBootstrap wrote .gitignore and environment.json straight through path.join,
the only two writes in the extension that skipped pathGuard and therefore its symlink
check. Both now resolve through it. The guard needed a synchronous twin to get there,
since activation reads the profile immediately; the containment half is shared so the
two cannot drift on the part that decides.
mocha 11.8.0 with overrides pinning diff@9 and serialize-javascript@7.1.0, which is
what actually clears the advisories — upstream mocha still asks for diff@^7 and
serialize-javascript@^6, and `npm audit fix --force` would have moved sideways to
11.3.0 without fixing either. npm audit now reports zero across the full tree rather
than only under --omit=dev. Suite verified serially, in parallel mode (the
serialize-javascript path), and against a failing assertion (the diff reporter).
1222 unit tests passing, 0 lint errors.
---
app/core/environmentProfile.js | 19 +++++++-
app/core/workspaceBootstrap.js | 14 +++++-
app/security/pathGuard.js | 75 ++++++++++++++++++++++++++++++
app/security/scriptRunner.js | 59 ++++++++++++++++++++++++
package-lock.json | 38 ++++-----------
package.json | 6 ++-
test/unit/pathGuard.test.js | 84 ++++++++++++++++++++++++++++++++++
test/unit/scriptRunner.test.js | 37 +++++++++++++++
8 files changed, 298 insertions(+), 34 deletions(-)
diff --git a/app/core/environmentProfile.js b/app/core/environmentProfile.js
index 7010c51..be834e2 100644
--- a/app/core/environmentProfile.js
+++ b/app/core/environmentProfile.js
@@ -44,6 +44,7 @@ const os = require('os');
const path = require('path');
const logger = require('../utils/logger');
+const pathGuard = require('../security/pathGuard');
const { platformName } = require('../utils/platform');
/** Where the profile is persisted, relative to the workspace root. */
@@ -198,9 +199,23 @@ function render(profile, opts = {}) {
function persist(workspaceRoot, profile = detect()) {
if (!workspaceRoot) return profile;
- const file = path.join(workspaceRoot, ...PROFILE_PATH.split('/'));
+ // Routed through pathGuard for the symlink check, like every other write in the
+ // extension. The directory is created first so the realpath probe resolves the
+ // `.hirayacoder` link itself rather than the workspace root it would fall back to.
+ // See SAST-0.6.1 §2.
+ let file;
try {
- fs.mkdirSync(path.dirname(file), { recursive: true });
+ const resolved = pathGuard.resolvePath(workspaceRoot, PROFILE_PATH);
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- fixed name under the workspace root
+ fs.mkdirSync(path.dirname(resolved.absolute), { recursive: true });
+ file = pathGuard.assertRealPathSync(resolved).absolute;
+ } catch (err) {
+ logger.warn(`Refusing to write ${PROFILE_PATH}: ${/** @type {Error} */ (err).message}`);
+ return profile;
+ }
+
+ try {
+ // eslint-disable-next-line security/detect-non-literal-fs-filename -- guarded above
fs.writeFileSync(file, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
} catch (err) {
logger.warn(`Could not write ${PROFILE_PATH}: ${/** @type {Error} */ (err).message}`);
diff --git a/app/core/workspaceBootstrap.js b/app/core/workspaceBootstrap.js
index 6b5fc47..bf41f8a 100644
--- a/app/core/workspaceBootstrap.js
+++ b/app/core/workspaceBootstrap.js
@@ -42,9 +42,9 @@
*/
const fs = require('fs');
-const path = require('path');
const logger = require('../utils/logger');
+const pathGuard = require('../security/pathGuard');
const environmentProfile = require('./environmentProfile');
/** The entry written into `.gitignore`. Trailing slash: it is always a directory. */
@@ -89,7 +89,17 @@ function alreadyIgnored(contents) {
*/
function ensureGitignore(workspaceRoot) {
if (!workspaceRoot) return 'failed';
- const file = path.join(workspaceRoot, '.gitignore');
+
+ // Every other write in the extension resolves through pathGuard, which has a
+ // realpath stage precisely because a lexically-clean path can still be a symlink
+ // pointing outside the workspace. This one used to skip it. See SAST-0.6.1 §2.
+ let file;
+ try {
+ file = pathGuard.assertRealPathSync(pathGuard.resolvePath(workspaceRoot, '.gitignore')).absolute;
+ } catch (err) {
+ logger.warn(`Refusing to touch .gitignore: ${/** @type {Error} */ (err).message}`);
+ return 'failed';
+ }
try {
if (!fs.existsSync(file)) {
diff --git a/app/security/pathGuard.js b/app/security/pathGuard.js
index dc39691..70a562c 100644
--- a/app/security/pathGuard.js
+++ b/app/security/pathGuard.js
@@ -261,6 +261,23 @@ async function assertRealPath(resolved, opts = {}) {
// eslint-disable-next-line security/detect-non-literal-fs-filename
const realRoot = await fs.promises.realpath(resolved.root).catch(() => resolved.root);
+ return verifyRealContainment(resolved, real, realRoot, platform);
+}
+
+/**
+ * The containment half of the real-path check, shared by both twins.
+ *
+ * Split out so the sync and async forms cannot drift on the part that decides — the
+ * only thing they are allowed to differ on is how they reach the filesystem.
+ *
+ * @param {ResolvedPath} resolved
+ * @param {string} real
+ * @param {string} realRoot
+ * @param {string} platform
+ * @returns {ResolvedPath}
+ * @throws {PathGuardError}
+ */
+function verifyRealContainment(resolved, real, realRoot, platform) {
if (!isInside(realRoot, real, platform)) {
throw new PathGuardError(
'SYMLINK_ESCAPE',
@@ -272,11 +289,69 @@ async function assertRealPath(resolved, opts = {}) {
return resolved;
}
+/**
+ * Synchronous twin of {@link assertRealPath}, with identical rules.
+ *
+ * It exists for `core/workspaceBootstrap`, which runs during activation on a call path
+ * that is synchronous all the way up to `this.environment` being read immediately after.
+ * Making that path async to reach the guard would have been a wider change than the
+ * check it was there to add.
+ *
+ * The 0.6.1 SAST pass found `ensureGitignore` and `environmentProfile.persist` writing
+ * to `path.join(workspaceRoot, …)` directly — the only two writes in the extension that
+ * skipped this module. Reaching it needs a workspace whose `.gitignore` is already a
+ * symlink pointing somewhere the user did not intend, which is a strange thing to
+ * arrange for yourself, but the inconsistency was real and this closes it.
+ *
+ * @param {ResolvedPath} resolved
+ * @param {object} [opts]
+ * @param {string} [opts.platform]
+ * @returns {ResolvedPath}
+ * @throws {PathGuardError}
+ */
+function assertRealPathSync(resolved, opts = {}) {
+ const platform = opts.platform || os.platform();
+ let probe = resolved.absolute;
+ let real = null;
+
+ for (;;) {
+ try {
+ // Resolving an attacker-influenced path is precisely this function's purpose;
+ // the result is containment-checked against the workspace root just below.
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
+ real = fs.realpathSync(probe);
+ break;
+ } catch (err) {
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
+ if (code !== 'ENOENT') {
+ throw new PathGuardError('REALPATH_FAILED', `Could not verify "${resolved.relative}": ${/** @type {Error} */ (err).message}`, resolved.relative);
+ }
+ const parent = path.dirname(probe);
+ if (parent === probe) {
+ throw new PathGuardError('REALPATH_FAILED', `Could not verify "${resolved.relative}".`, resolved.relative);
+ }
+ probe = parent;
+ }
+ }
+
+ let realRoot = resolved.root;
+ try {
+ // The workspace root comes from VS Code, never from model output.
+ // eslint-disable-next-line security/detect-non-literal-fs-filename
+ realRoot = fs.realpathSync(resolved.root);
+ } catch {
+ // Fall back to the lexical root, matching the async twin's `.catch()`.
+ }
+
+ return verifyRealContainment(resolved, real, realRoot, platform);
+}
+
module.exports = {
PathGuardError,
resolvePath,
resolveForMutation,
assertRealPath,
+ assertRealPathSync,
isInside,
matchProtectedPrefix,
DEFAULT_PROTECTED_PREFIXES,
diff --git a/app/security/scriptRunner.js b/app/security/scriptRunner.js
index 56fdecd..bf27de1 100644
--- a/app/security/scriptRunner.js
+++ b/app/security/scriptRunner.js
@@ -63,6 +63,34 @@ const DEFAULT_ALLOWED_BINARIES = [
'java', 'javac',
];
+/**
+ * Reason attached to every rule that fetches a package and executes it.
+ *
+ * Kept as one constant because the six spellings below are the same act, and a user
+ * reading the prompt should not have to work out that `npm x` and `pnpm dlx` are `npx`.
+ */
+const REMOTE_PACKAGE_REASON = 'this downloads and runs a package from the internet';
+
+/**
+ * Does this subcommand name a package to go and fetch?
+ *
+ * `exec`, `x`, `dlx`, and `create` always do. `init` is the exception that earns the
+ * argument check: bare `npm init` writes a `package.json` into the current directory
+ * and touches no network at all, while `npm init vite` downloads `create-vite` and runs
+ * it. Confirming the first would be a click for nothing, which is how confirmation
+ * prompts get trained away.
+ *
+ * @param {string[]} args
+ * @returns {boolean}
+ */
+function fetchesRemotePackage(args) {
+ const subcommand = String(args[0] || '').toLowerCase();
+ if (['exec', 'x', 'dlx', 'create'].includes(subcommand)) return true;
+ // An initializer is the first argument that is not a flag.
+ if (subcommand === 'init') return args.slice(1).some((a) => !a.startsWith('-'));
+ return false;
+}
+
/**
* Allow-listed programs whose *subcommands* still demand an explicit click, even
* when auto-approve-scripts is on. Each rule is [binary, subcommand-matcher, why].
@@ -70,6 +98,36 @@ const DEFAULT_ALLOWED_BINARIES = [
* @type {Array<{binary: string, match: (args: string[]) => boolean, reason: string}>}
*/
const ALWAYS_CONFIRM = [
+ {
+ // Every argument form of `npx` ends in "fetch a package and execute it", including
+ // the ones that hit the local `node_modules/.bin` first — the fallback is still a
+ // registry download. There is no subcommand to discriminate on, so the rule is the
+ // binary.
+ //
+ // This is the finding the 0.6.1 SAST pass carried forward: `npx` was on the default
+ // allow-list, `NON_INTERACTIVE_ENV` sets `npm_config_yes` which suppresses npx's own
+ // "Ok to proceed?", and nothing here asked. Under auto-approve that ran arbitrary
+ // remote code with no click, in an extension whose headline claim is that it works
+ // fully offline. Observed on a live 0.6.0 run, auto-approved and logged as routine.
+ binary: 'npx',
+ match: () => true,
+ reason: REMOTE_PACKAGE_REASON,
+ },
+ {
+ binary: 'npm',
+ match: fetchesRemotePackage,
+ reason: REMOTE_PACKAGE_REASON,
+ },
+ {
+ binary: 'yarn',
+ match: fetchesRemotePackage,
+ reason: REMOTE_PACKAGE_REASON,
+ },
+ {
+ binary: 'pnpm',
+ match: fetchesRemotePackage,
+ reason: REMOTE_PACKAGE_REASON,
+ },
{
binary: 'git',
match: (args) => ['push', 'remote', 'clone', 'fetch', 'pull', 'submodule'].includes(args[0]),
@@ -521,6 +579,7 @@ module.exports = {
ScriptRunnerError,
DEFAULT_ALLOWED_BINARIES,
ALWAYS_CONFIRM,
+ fetchesRemotePackage,
SHELL_METACHARACTERS,
DEFAULT_TIMEOUT_MS,
MAX_OUTPUT_CHARS,
diff --git a/package-lock.json b/package-lock.json
index 792011c..a3314d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,7 +17,7 @@
"eslint": "^9.34.0",
"eslint-plugin-no-unsanitized": "^4.1.2",
"eslint-plugin-security": "^3.0.1",
- "mocha": "^11.7.1",
+ "mocha": "^11.8.0",
"sinon": "^21.0.0"
},
"engines": {
@@ -2006,9 +2006,9 @@
}
},
"node_modules/diff": {
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz",
- "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
+ "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -4630,16 +4630,6 @@
],
"license": "MIT"
},
- "node_modules/randombytes": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
- "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "safe-buffer": "^5.1.0"
- }
- },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -4953,13 +4943,13 @@
}
},
"node_modules/serialize-javascript": {
- "version": "6.0.2",
- "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
- "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.0.tgz",
+ "integrity": "sha512-RNEqWOyhhUQYN9V1GfHwu9AR/g+NTciH6Z5u3/no6X3/w+04J2lVDL+svFQVXgXrEGBMG2puMVN3gq2SNGuTGw==",
"dev": true,
"license": "BSD-3-Clause",
- "dependencies": {
- "randombytes": "^2.1.0"
+ "engines": {
+ "node": ">=20.0.0"
}
},
"node_modules/setimmediate": {
@@ -5147,16 +5137,6 @@
"url": "https://opencollective.com/sinon"
}
},
- "node_modules/sinon/node_modules/diff": {
- "version": "8.0.4",
- "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz",
- "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">=0.3.1"
- }
- },
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
diff --git a/package.json b/package.json
index 2c8b6fb..7eb38fa 100644
--- a/package.json
+++ b/package.json
@@ -424,8 +424,12 @@
"eslint": "^9.34.0",
"eslint-plugin-no-unsanitized": "^4.1.2",
"eslint-plugin-security": "^3.0.1",
- "mocha": "^11.7.1",
+ "mocha": "^11.8.0",
"sinon": "^21.0.0"
},
+ "overrides": {
+ "diff": "^9.0.0",
+ "serialize-javascript": "^7.1.0"
+ },
"dependencies": {}
}
diff --git a/test/unit/pathGuard.test.js b/test/unit/pathGuard.test.js
index 5443f62..8875899 100644
--- a/test/unit/pathGuard.test.js
+++ b/test/unit/pathGuard.test.js
@@ -10,6 +10,7 @@ const {
resolvePath,
resolveForMutation,
assertRealPath,
+ assertRealPathSync,
isInside,
matchProtectedPrefix,
} = require('../../app/security/pathGuard');
@@ -230,3 +231,86 @@ describe('pathGuard.assertRealPath', () => {
await assert.doesNotReject(() => assertRealPath(resolvePath(root, 'alias.js')));
});
});
+
+// The sync twin exists for `core/workspaceBootstrap`, which runs on a synchronous
+// activation path. These tests exist to keep the two from drifting: a difference in
+// verdict between them is the bug worth catching, so each case asserts both.
+describe('pathGuard.assertRealPathSync', () => {
+ /** @type {string} */
+ let tmp;
+
+ beforeEach(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-guard-sync-'));
+ fs.mkdirSync(path.join(tmp, 'workspace', 'src'), { recursive: true });
+ fs.writeFileSync(path.join(tmp, 'workspace', 'src', 'app.js'), 'ok');
+ fs.mkdirSync(path.join(tmp, 'outside'), { recursive: true });
+ fs.writeFileSync(path.join(tmp, 'outside', 'secret.txt'), 'top secret');
+ });
+
+ afterEach(() => {
+ fs.rmSync(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
+ });
+
+ it('accepts a real file inside the workspace, like its async twin', async () => {
+ const root = path.join(tmp, 'workspace');
+ assert.doesNotThrow(() => assertRealPathSync(resolvePath(root, 'src/app.js')));
+ await assert.doesNotReject(() => assertRealPath(resolvePath(root, 'src/app.js')));
+ });
+
+ it('accepts a file that does not exist yet by checking its parent', async () => {
+ const root = path.join(tmp, 'workspace');
+ assert.doesNotThrow(() => assertRealPathSync(resolvePath(root, 'src/brand/new/file.js')));
+ await assert.doesNotReject(() => assertRealPath(resolvePath(root, 'src/brand/new/file.js')));
+ });
+
+ it('rejects a .gitignore that is a symlink pointing outside the workspace', async function () {
+ const root = path.join(tmp, 'workspace');
+ try {
+ fs.symlinkSync(path.join(tmp, 'outside', 'secret.txt'), path.join(root, '.gitignore'));
+ } catch (err) {
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EPERM') return this.skip();
+ throw err;
+ }
+
+ // This is the exact shape SAST-0.6.1 §2 described: lexically spotless, and only
+ // realpath resolution sees that appending to it writes outside the workspace.
+ const resolved = resolvePath(root, '.gitignore');
+ assert.throws(
+ () => assertRealPathSync(resolved),
+ (err) => err instanceof PathGuardError && err.code === 'SYMLINK_ESCAPE'
+ );
+ await assert.rejects(
+ () => assertRealPath(resolved),
+ (err) => err instanceof PathGuardError && err.code === 'SYMLINK_ESCAPE'
+ );
+ });
+
+ it('rejects a file created through a linked directory', async () => {
+ const root = path.join(tmp, 'workspace');
+ // Junctions work unprivileged on Windows and realpath resolves them identically,
+ // so this case gets real coverage on every platform rather than skipping.
+ const linkType = process.platform === 'win32' ? 'junction' : 'dir';
+ fs.symlinkSync(path.join(tmp, 'outside'), path.join(root, '.hirayacoder'), linkType);
+
+ const resolved = resolvePath(root, '.hirayacoder/environment.json');
+ assert.throws(
+ () => assertRealPathSync(resolved),
+ (err) => err instanceof PathGuardError && err.code === 'SYMLINK_ESCAPE'
+ );
+ await assert.rejects(
+ () => assertRealPath(resolved),
+ (err) => err instanceof PathGuardError && err.code === 'SYMLINK_ESCAPE'
+ );
+ });
+
+ it('accepts a symlink that stays inside the workspace', function () {
+ const root = path.join(tmp, 'workspace');
+ try {
+ fs.symlinkSync(path.join(root, 'src', 'app.js'), path.join(root, 'alias.js'));
+ } catch (err) {
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === 'EPERM') return this.skip();
+ throw err;
+ }
+ assert.doesNotThrow(() => assertRealPathSync(resolvePath(root, 'alias.js')));
+ });
+});
diff --git a/test/unit/scriptRunner.test.js b/test/unit/scriptRunner.test.js
index 74a49c0..81daea7 100644
--- a/test/unit/scriptRunner.test.js
+++ b/test/unit/scriptRunner.test.js
@@ -145,6 +145,43 @@ describe('scriptRunner.requiresExplicitApproval', () => {
assert.strictEqual(requiresExplicitApproval(tokenize('git status')), null);
assert.strictEqual(requiresExplicitApproval(tokenize('git commit -m wip')), null);
});
+
+ // SAST-0.6.1 §1. `npx` was allow-listed, `npm_config_yes` suppressed its own
+ // "Ok to proceed?", and nothing here asked — so auto-approve ran arbitrary remote
+ // code with no click. Every spelling of "fetch a package and run it" is covered.
+ it('forces confirmation for every spelling of npx', () => {
+ for (const command of [
+ 'npx create-vite@latest todo-glass-app -- --template react',
+ 'npx cowsay hello',
+ 'npm exec create-vite',
+ 'npm x create-vite',
+ 'npm create vite@latest',
+ 'yarn dlx create-vite',
+ 'yarn create vite',
+ 'pnpm dlx create-vite',
+ 'pnpm create vite',
+ ]) {
+ const reason = requiresExplicitApproval(tokenize(command));
+ assert.ok(reason, `${command} should require a click`);
+ assert.match(reason, /downloads and runs a package from the internet/);
+ }
+ });
+
+ it('confirms an initializer but not a bare init, which touches no network', () => {
+ assert.ok(requiresExplicitApproval(tokenize('npm init vite')));
+ assert.ok(requiresExplicitApproval(tokenize('npm init -y react-app')));
+ // Bare `npm init` writes a package.json into the current directory and fetches
+ // nothing. A click for that is a click trained away.
+ assert.strictEqual(requiresExplicitApproval(tokenize('npm init')), null);
+ assert.strictEqual(requiresExplicitApproval(tokenize('npm init -y')), null);
+ assert.strictEqual(requiresExplicitApproval(tokenize('npm init --yes')), null);
+ });
+
+ it('does not let the npx rules swallow ordinary package-manager work', () => {
+ assert.strictEqual(requiresExplicitApproval(tokenize('npm run build')), null);
+ assert.strictEqual(requiresExplicitApproval(tokenize('yarn install')), null);
+ assert.strictEqual(requiresExplicitApproval(tokenize('pnpm test')), null);
+ });
});
describe('scriptRunner.resolveBinary', () => {
From 9adf823ed557377a8ebe9931396b9a0def13c5a6 Mon Sep 17 00:00:00 2001
From: jaymar921
Date: Fri, 14 Aug 2026 15:17:21 +0800
Subject: [PATCH 2/4] feat(0.7.0): get unstuck, read the request, and ask when
both fail
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four changes that share one premise: a 1B model that is stuck does not know it, and
everything it does next makes the run worse. From the 0.5.3 and 0.6.0 rounds, the three
endings were resend the failing action until the budget is gone, report success it did
not achieve, or abandon a task it was one step from finishing.
Undefined symbols now have diagnosis rules. ReferenceError, NameError, javac's cannot
find symbol, reading a property of undefined or null, AttributeError, x is not a
function, and NullPointerException — seven rules across JS, Python and Java, and each
sentence names the symbol the error named, because "something is undefined" is not
actionable and "`addTodo` is used but never imported there" is. `scriptDiagnosis` rules
may now compute their text from the match, which is what makes that possible; the
matcher moved from test to exec and gained a test pinning that repeated calls agree.
errorRecovery covers what no rule matches, and what a rule matched twice. The ladder is
guidance on the first failure, ask the user on the second — set against
reactLoop.REPEAT_LIMIT rather than a round number, because the loop ends a run after two
identical actions and a ladder waiting for a third would never reach its top rung. What
kept failing goes into memory, composed rather than model-written, since a failed step
is exactly what the translator has nothing to narrate about and exactly what the next
run needs.
commonSense reads the request before the model does. A name one edit from a real file is
repaired with both names stated; two candidates ask; no candidate says nothing, because
that is what creating a file looks like. The verb governing the filename is read from
the six words in front of it, so "update mian.js to add a header" is a typo rather than
a creation. Damerau-Levenshtein, not memoryStore.similarity — that one is Jaccard over
whole words and scores mian.js against main.js at zero.
clarification is the shared shape for asking: two to four options, exactly one
recommended, every option stating its effect on the queue, and free text always
available. The TodoList can now be added to, reworded, and skipped mid-run, and it keeps
a change log so the summary can say the checklist changed and why.
The property most worth keeping is the negative one, and it is tested from both sides:
a session with no onClarify never blocks. Closing a tab, pressing Stop, and a turn that
throws all settle an outstanding question, so a background run cannot hold its lane in
the turn queue on a card nobody can see.
Memory recall is now selected by relevance on every turn rather than only in
experimental step mode, and ranked by how much of the subject each note shares instead
of by any-token-matches. Rebuilding a route per item exposed that _routeForStep dropped
readOnlyTurn; carried through now, since that would otherwise hand the mutating tools
back to a look-only request on the default path.
1318 unit tests passing, 0 lint errors.
---
app/agent/agentSession.js | 410 +++++++++++++++++-
app/agent/clarification.js | 223 ++++++++++
app/agent/errorRecovery.js | 441 +++++++++++++++++++
app/agent/scriptDiagnosis.js | 108 ++++-
app/agent/todoList.js | 110 +++++
app/core/commonSense.js | 442 ++++++++++++++++++++
app/core/memoryStore.js | 27 +-
app/features/chatTab.js | 118 ++++++
app/webview/components/clarificationCard.js | 126 ++++++
app/webview/main.js | 14 +
app/webview/style.css | 81 ++++
test/unit/askingTheUser.test.js | 250 +++++++++++
test/unit/clarification.test.js | 135 ++++++
test/unit/commonSense.test.js | 166 ++++++++
test/unit/errorRecovery.test.js | 180 ++++++++
test/unit/memoryStore.test.js | 20 +
test/unit/todoList.test.js | 76 ++++
test/unit/undefinedErrors.test.js | 131 ++++++
18 files changed, 3034 insertions(+), 24 deletions(-)
create mode 100644 app/agent/clarification.js
create mode 100644 app/agent/errorRecovery.js
create mode 100644 app/core/commonSense.js
create mode 100644 app/webview/components/clarificationCard.js
create mode 100644 test/unit/askingTheUser.test.js
create mode 100644 test/unit/clarification.test.js
create mode 100644 test/unit/commonSense.test.js
create mode 100644 test/unit/errorRecovery.test.js
create mode 100644 test/unit/undefinedErrors.test.js
diff --git a/app/agent/agentSession.js b/app/agent/agentSession.js
index 52e7a49..a1121ec 100644
--- a/app/agent/agentSession.js
+++ b/app/agent/agentSession.js
@@ -41,6 +41,9 @@ const earnedHints = require('./earnedHints');
const stepBrief = require('./stepBrief');
const stepGuard = require('./stepGuard');
const answerCheck = require('./answerCheck');
+const commonSense = require('../core/commonSense');
+const clarification = require('./clarification');
+const { ErrorRecovery } = require('./errorRecovery');
const { TodoList } = require('./todoList');
/**
@@ -499,11 +502,82 @@ class AgentSession {
*/
this.conversation = [];
+ /**
+ * How a question reaches the user, and their answer comes back.
+ *
+ * Null is the ordinary state for a benchmark, a test, or any caller that has no
+ * panel — and every path that would raise a question checks first and settles for
+ * guidance instead. A run must never block on an answer that nothing can give.
+ *
+ * @type {((request: import('./clarification').Clarification) =>
+ * Promise) | null}
+ */
+ this.onClarify = typeof options.onClarify === 'function' ? options.onClarify : null;
+
+ /**
+ * What has failed this run, and what the user has already said about it.
+ *
+ * Rebuilt per `run` rather than per session: "you have tried this three times" is a
+ * claim about one turn, and a new message is a new chance.
+ *
+ * @type {ErrorRecovery}
+ */
+ this._recovery = new ErrorRecovery({ canAsk: false });
+
+ /**
+ * Set when the user answers a mid-run question with something the loop cannot
+ * express — skip this item, or stop altogether. Read by `_runWithTodos` once the
+ * loop it was raised inside has returned.
+ *
+ * @type {'' | 'skip' | 'stop'}
+ */
+ this._interrupt = '';
+
+ /** What the user said mid-run, for the summary. @type {string[]} */
+ this._userGuidance = [];
+
/** @type {AbortController | null} */
this._controller = null;
this.running = false;
}
+ /**
+ * Put a question to the user and wait for the answer.
+ *
+ * Returns the resolved effect, or null when there was nobody to ask — callers treat
+ * that as "carry on as you were", never as a refusal.
+ *
+ * @param {import('./clarification').Clarification} request
+ * @param {(event: object) => void} emit
+ * @returns {Promise<{effect: import('./clarification').ClarificationEffect, guidance: string, label: string} | null>}
+ * @private
+ */
+ async _ask(request, emit) {
+ if (!this.onClarify) return null;
+
+ emit({ type: 'clarification', request });
+ logger.info(`Pausing to ask: ${request.question}`);
+
+ /** @type {import('./clarification').ClarificationAnswer | null} */
+ let answer = null;
+ try {
+ answer = await this.onClarify(request);
+ } catch (err) {
+ // A question that could not be shown must not take the run down with it. The
+ // model is mid-item with a step budget and a change set; failing closed here
+ // means carrying on without the answer, not discarding the work.
+ logger.warn(`Could not ask the user: ${/** @type {Error} */ (err).message}`);
+ return null;
+ }
+
+ const resolved = clarification.resolve(request, answer);
+ emit({ type: 'clarification-answered', id: request.id, label: resolved.label });
+ logger.info(`The user answered "${resolved.label}" (${resolved.effect}).`);
+
+ if (resolved.guidance) this._userGuidance.push(resolved.guidance);
+ return resolved;
+ }
+
/** Stop the session after the action in flight completes. */
cancel() {
if (this._controller) this._controller.abort();
@@ -524,10 +598,24 @@ class AgentSession {
async run(task, options = {}) {
const mode = options.mode || 'agent';
const emit = options.onEvent || (() => {});
+ // Held on the session so `_execute` can raise a question from inside a loop it was
+ // not handed the emitter for. Cleared in the `finally` below with everything else
+ // that is per-turn.
+ this._emit = emit;
+ /** The TODO item in flight, so a question can say what it is about. @type {string} */
+ this._currentItem = '';
this.conversation = Array.isArray(options.conversation) ? options.conversation : [];
this._controller = new AbortController();
this.running = true;
this._startedAt = Date.now();
+ // Per turn, not per session: three failures ago was a different request, and a run
+ // that opened already out of patience would ask about a wall the user may have
+ // taken down in between.
+ this._recovery = new ErrorRecovery({ canAsk: Boolean(this.onClarify) });
+ this._interrupt = '';
+ this._userGuidance = [];
+ /** What was read differently from what was typed, for the summary. @type {string[]} */
+ this._interpretations = [];
// Snapshotted rather than measured per call: the client already totals every
// request it makes, so the difference across a session is the time spent waiting on
// Ollama — including the planning and TODO-splitting passes, which happen outside
@@ -535,6 +623,26 @@ class AgentSession {
this._modelMsAtStart = this._modelMsSoFar();
try {
+ // Read before routed. A request naming a file that is one letter away from a real
+ // one is not ambiguous to a person, and a small model handed it literally creates
+ // the misspelled file and reports success — so the reading happens here, before
+ // anything downstream treats the name as given. Agent mode only: Plan and Ask
+ // produce words rather than files, so a wrong name costs a sentence.
+ if (mode === 'agent') {
+ task = await this._interpretRequest(task, options, emit);
+ if (this._interrupt === 'stop') {
+ const stopped = {
+ summary: 'Stopped before starting, at your request.',
+ steps: [],
+ changeSet: new ChangeSet(),
+ stopReason: 'cancelled',
+ mode,
+ };
+ emit({ type: 'done', summary: stopped.summary });
+ return stopped;
+ }
+ }
+
// Only Agent mode is routed by intent — Plan and Ask are the user saying what they
// want, and second-guessing an explicit choice is not this classifier's job.
const intent = mode === 'agent' ? intentRouter.classify(task) : { intent: 'task', reason: 'mode was chosen' };
@@ -546,7 +654,12 @@ class AgentSession {
mode,
capability: this.capability,
thinkingCapacity: this.thinkingCapacity,
- memory: await this._renderMemory(),
+ // Selected against what was asked rather than by recency. A session's memory
+ // file outlives the subject that filled it, so on any turn but the first the
+ // most recent notes are about the *previous* request; the notes bearing on this
+ // one are further back. Recall is never worse for it — a message that matches
+ // nothing falls back to the same recency window this used to take.
+ memory: await this._renderMemory({ about: task }),
earnedHints: await this._earnedHints(mode),
environment: this.environment,
intent: intent.intent,
@@ -653,11 +766,12 @@ class AgentSession {
/** @type {SessionResult} */
const result = {
- summary: appendUnverifiedNote(
- appendUnfinishedNote(checkedSummary, outcome.steps),
- outcome,
- changeSet
- ),
+ summary:
+ appendUnverifiedNote(
+ appendUnfinishedNote(checkedSummary, outcome.steps),
+ outcome,
+ changeSet
+ ) + this._describeInterventions(),
steps: outcome.steps,
changeSet,
stopReason: outcome.stopReason,
@@ -672,9 +786,85 @@ class AgentSession {
} finally {
this.running = false;
this._controller = null;
+ this._emit = null;
+ this._currentItem = '';
}
}
+ /**
+ * Read the request the way a person would before handing it to the model.
+ *
+ * Returns the task to actually run, which is usually the one that came in. See
+ * `core/commonSense` for what is checked and why the line between fixing something
+ * and asking about it is drawn where it is.
+ *
+ * Never throws: an interpretation that fails leaves the request exactly as the user
+ * typed it, which is the behaviour every version before 0.7.0 had.
+ *
+ * @param {string} task
+ * @param {object} options
+ * @param {(event: object) => void} emit
+ * @returns {Promise}
+ * @private
+ */
+ async _interpretRequest(task, options, emit) {
+ let files = [];
+ try {
+ files = await this._workspaceFiles(null);
+ } catch (err) {
+ logger.debug(`Could not list the workspace to read the request against: ${/** @type {Error} */ (err).message}`);
+ return task;
+ }
+
+ /** @type {import('../core/commonSense').Interpretation} */
+ let reading;
+ try {
+ reading = commonSense.interpret({
+ task,
+ files,
+ conversation: this.conversation,
+ editorPath: options.editor ? options.editor.path : '',
+ canAsk: Boolean(this.onClarify),
+ });
+ } catch (err) {
+ logger.warn(`Could not read the request: ${/** @type {Error} */ (err).message}`);
+ return task;
+ }
+
+ if (reading.kind === 'repaired') {
+ this._interpretations.push(reading.note);
+ emit({ type: 'interpretation', note: reading.note });
+ // Into memory as well as the summary. A user who names the same file the same
+ // wrong way twice in one session should not have it re-derived from scratch, and
+ // the note is composed rather than model-written so it is safe to keep.
+ if (this.memory) {
+ try {
+ await this.memory.append(reading.note);
+ } catch (err) {
+ logger.debug(`Could not record the reading: ${/** @type {Error} */ (err).message}`);
+ }
+ }
+ return reading.task;
+ }
+
+ if (reading.kind === 'ask' && reading.clarification) {
+ const resolved = await this._ask(reading.clarification, emit);
+ if (!resolved) return task;
+ if (resolved.effect === 'stop') {
+ this._interrupt = 'stop';
+ return task;
+ }
+ // Appended rather than substituted. The user answered a question about their own
+ // request, so what they said is an addition to it — replacing the text would
+ // discard the half the question was not about.
+ const note = `You asked about this and the user said: ${resolved.guidance}`;
+ this._interpretations.push(note);
+ return `${task}\n\n${note}`;
+ }
+
+ return task;
+ }
+
/**
* Work through a request as a TODO list, one item at a time.
*
@@ -754,6 +944,8 @@ class AgentSession {
/** Set when a step failed twice and the run is stopping deliberately. */
let stopNotice = '';
let cancelled = false;
+ /** Set when the user, asked mid-run, chose to stop. Not the same as cancelling. */
+ let stoppedByUser = false;
while (todos.current() && remainingSteps > 0) {
if (this._controller.signal.aborted) {
@@ -763,6 +955,8 @@ class AgentSession {
const item = todos.current();
const position = todos.position();
+ // So a question raised from inside this item's loop can say what it is about.
+ this._currentItem = item.text;
// The snapshot rides along with the event because the UI has no other way to
// learn the list changed: it holds the items it was given at `todo` time and
// nothing else, so an index alone would leave it guessing at the other rows.
@@ -838,7 +1032,7 @@ class AgentSession {
// the oldest one in the file by the time the step runs. See
// `memoryStore.readRelevant`.
const context = await this._buildContext(itemTask, itemRoute, options.editor, {
- recallAbout: this.stepSessions ? item.text : '',
+ recallAbout: item.text,
});
// Each item runs a fresh loop, and a loop numbers its steps from its own
@@ -895,10 +1089,19 @@ class AgentSession {
attemptResult = { outcome, verdict, attempts: attempt };
+ // The user was asked mid-item and said stop. Distinct from a cancel: they made
+ // a decision about this run rather than abandoning it, and the summary says so.
+ if (this._interrupt === 'stop') {
+ stoppedByUser = true;
+ break;
+ }
if (outcome.stopReason === 'cancelled') {
cancelled = true;
break;
}
+ // Asked mid-item and told to skip. The retry below is exactly what they
+ // declined, so it does not happen.
+ if (this._interrupt === 'skip') break;
if (verdict.ok || attempt === maxAttempts) break;
// About to be written off, so it gets one more run with the diagnosis stated —
@@ -932,9 +1135,32 @@ class AgentSession {
}
const changedPaths = produced.map((change) => change.path);
+
+ // The user was asked about this item and chose to leave it. That is a decision,
+ // not a failure, and recording it as one would put a `[!]` against a row the user
+ // themselves closed — and would then feed "an earlier step failed" to every item
+ // after it.
+ if (this._interrupt === 'skip') {
+ this._interrupt = '';
+ todos.skipCurrent('you asked me to skip this one');
+ emit({
+ type: 'todo-item-done',
+ index: position,
+ status: 'skipped',
+ text: item.text,
+ items: snapshotTodos(todos),
+ });
+ continue;
+ }
+
todos.finishCurrent(status, outcomeText, itemStepCount, { changedPaths, attempts });
emit({ type: 'todo-item-done', index: position, status, text: item.text, items: snapshotTodos(todos) });
+ if (stoppedByUser) {
+ todos.skipRemaining('you stopped the run here');
+ break;
+ }
+
if (cancelled) {
todos.skipRemaining('the session was cancelled');
break;
@@ -968,6 +1194,10 @@ class AgentSession {
const summary =
`${progress.done} of ${progress.total} item(s) completed.${caveat}\n\n${todos.describe()}` +
(stopNotice ? `\n\n${stopNotice}` : '') +
+ // What the user was asked and what they said, before the per-item detail. A run
+ // that only succeeded because they answered a question should say so — otherwise
+ // the checklist reads as though the model worked it out on its own.
+ this._describeInterventions(todos) +
(summaries.length > 0 ? `\n\nDetail:\n${summaries.join('\n')}` : '');
return {
@@ -980,12 +1210,67 @@ class AgentSession {
};
}
+ /**
+ * What the run had to be told, and by whom, for the end of the summary.
+ *
+ * Three separate things end up here and they are not interchangeable: what was read
+ * differently from what the user typed, what the user said when asked mid-run, and
+ * what kept failing regardless. A summary that reports only the checklist hides all
+ * three — and the third is the one a user most needs, because a run that ends "4 of 4
+ * completed" after hitting the same error eleven times is technically true and
+ * actively misleading.
+ *
+ * @param {TodoList} [todos]
+ * @returns {string} Empty when the run needed none of it.
+ * @private
+ */
+ _describeInterventions(todos) {
+ const blocks = [];
+
+ if (this._interpretations && this._interpretations.length > 0) {
+ blocks.push(`How I read the request:\n${this._interpretations.map((note) => `- ${note}`).join('\n')}`);
+ }
+
+ if (this._userGuidance && this._userGuidance.length > 0) {
+ blocks.push(`What you told me when I asked:\n${this._userGuidance.map((note) => `- ${note}`).join('\n')}`);
+ }
+
+ if (todos) {
+ const changed = todos.describeChanges();
+ if (changed) blocks.push(changed);
+ }
+
+ const stuck = this._recovery ? this._recovery.persistent() : [];
+ if (stuck.length > 0) {
+ const lines = stuck
+ .slice(0, 3)
+ .map((entry) => `- ${entry.action} failed ${entry.count} times: ${entry.headline}`);
+ blocks.push(`What kept going wrong:\n${lines.join('\n')}`);
+ }
+
+ return blocks.length > 0 ? `\n\n${blocks.join('\n\n')}` : '';
+ }
+
/**
* The route for one step, with its memory block recalled against that step.
*
- * Returns the session's own route untouched unless step sessions are on, so the
- * ordinary TODO path keeps the single system prompt it has always had and pays for
- * nothing it does not use.
+ * ## Why every item gets this, not only a briefed step
+ *
+ * Until 0.7.0 this returned the session's route untouched unless step sessions were
+ * on, so a six-item run shared one memory block — selected by recency, before the
+ * list existed, against the whole request. That is the wrong block for every item
+ * after the first. The React benchmark shows the failure exactly: the item that had
+ * to assemble `App.jsx` ran sixth, and the notes naming `useTodos.js` and
+ * `TodoInput.jsx` — the two files it needed to import — were by then the oldest in
+ * the file and the first to fall out of a five-entry recency window. The note that
+ * survived was about the README.
+ *
+ * Recall by subject is never worse than recall by recency: an item whose text
+ * matches nothing gets exactly the recency window it would have had (see
+ * `memoryStore.readRelevant`). What it costs is one extra prompt assembly per item,
+ * which is string work against an already-loaded file — no inference, no disk.
+ *
+ * Returns the session's own route untouched when there is no memory to select from.
*
* @param {import('../core/promptRouter').Route} activeRoute
* @param {string} about The step's text.
@@ -993,7 +1278,7 @@ class AgentSession {
* @private
*/
async _routeForStep(activeRoute, about) {
- if (!this.stepSessions || !this.memory) return activeRoute;
+ if (!this.memory || !about) return activeRoute;
try {
return promptRouter.route({
@@ -1004,6 +1289,13 @@ class AgentSession {
earnedHints: await this._earnedHints(activeRoute.mode),
environment: this.environment,
intent: 'task',
+ // Carried, not recomputed. This route replaces the session's for the whole
+ // step, so dropping the flag would hand the mutating tools back to a turn the
+ // router had already decided was a look-only request — and now that every item
+ // rebuilds its route, that would be the default path rather than an
+ // experimental one. The decision belongs to the session's route; this only
+ // re-renders the memory block underneath it.
+ readOnlyTurn: activeRoute.readOnlyTurn,
});
} catch (err) {
// A step must never fail to start because its recall could not be assembled.
@@ -1421,12 +1713,15 @@ class AgentSession {
* has always received without anyone calling it a tool. Looking up the handler under
* a read-only mode is how that read is performed; it is not a grant.
*
- * @param {import('../core/promptRouter').Route} activeRoute
+ * @param {import('../core/promptRouter').Route | null} activeRoute
+ * Null when the read happens before a route exists — `_interpretRequest` needs the
+ * listing to decide whether a filename in the request resolves, and that decision
+ * comes before routing. Treated as read-only, which is what such a read is.
* @returns {Promise}
* @private
*/
async _workspaceFiles(activeRoute) {
- const loopless = activeRoute.strategy === 'none' || activeRoute.strategy === 'chat';
+ const loopless = !activeRoute || activeRoute.strategy === 'none' || activeRoute.strategy === 'chat';
// 'plan' is the read-only mode. Substituted only to resolve the handler; the gate
// below still audits the read under the mode the user is actually in.
const lookupMode = loopless ? 'plan' : activeRoute.mode;
@@ -1702,6 +1997,11 @@ class AgentSession {
// action passes through regardless of which tier produced it.
this._recordStep(action, result, activeRoute, Boolean(tool.mutating), ms);
+ // Same reason, and the reason this is the right place for it: a failure that
+ // repeats has to be counted across the whole turn, and only this method sees every
+ // action from both loop tiers.
+ if (!result.ok) result = await this._recover(action, result);
+
// The one line that reconstructs a session afterwards: what was asked for, what
// happened, and how long it took. Debug rather than info, because a run is dozens
// of these — but when a user reports "it said it edited the file and it did not",
@@ -1714,6 +2014,90 @@ class AgentSession {
return result;
}
+ /**
+ * Decide what a failed action gets told, and whether the user gets asked.
+ *
+ * Returns the result the loop will see — usually the same one with a sentence added.
+ * The observation is the only channel back to the model on a Tier B loop, so that is
+ * where the guidance has to go; there is no side band it would read.
+ *
+ * @param {object} action
+ * @param {import('./toolRegistry').ToolResult} result
+ * @returns {Promise}
+ * @private
+ */
+ async _recover(action, result) {
+ /** @type {import('./errorRecovery').Failure} */
+ const failure = {
+ action: String(action.action || ''),
+ path: action.path,
+ command: action.command,
+ error: result.error,
+ observation: String(result.observation || ''),
+ // `runScript` appends its own "What went wrong: …" sentence when a rule matched,
+ // and a second explanation saying the same thing differently is noise in a
+ // context window that has none spare. `detail.reason` is set exactly when that
+ // happened — see `tools/runScript`.
+ diagnosed: Boolean(result.detail && result.detail.reason),
+ item: this._currentItem || '',
+ };
+
+ /** @type {import('./errorRecovery').RecoveryDecision} */
+ let decision;
+ try {
+ decision = this._recovery.observe(failure);
+ } catch (err) {
+ logger.warn(`Could not assess the failure: ${/** @type {Error} */ (err).message}`);
+ return result;
+ }
+
+ if (decision.note && this.memory) {
+ try {
+ await this.memory.append(decision.note);
+ } catch (err) {
+ logger.debug(`Could not record the failure: ${/** @type {Error} */ (err).message}`);
+ }
+ }
+
+ if (decision.kind === 'ask' && decision.clarification) {
+ const resolved = await this._ask(decision.clarification, this._emit || (() => {}));
+ if (resolved) {
+ const note = this._recovery.recordAnswer(failure, resolved.guidance || resolved.label);
+ if (this.memory) {
+ try {
+ await this.memory.append(note);
+ } catch (err) {
+ logger.debug(`Could not record the answer: ${/** @type {Error} */ (err).message}`);
+ }
+ }
+
+ if (resolved.effect === 'stop') {
+ this._interrupt = 'stop';
+ // Aborting is what actually stops a loop mid-item; the flag alone is only
+ // read once the loop has returned, which could be several steps later.
+ if (this._controller) this._controller.abort();
+ return { ...result, observation: `${result.observation}\n\nThe user stopped the run here.` };
+ }
+ if (resolved.effect === 'skip') {
+ this._interrupt = 'skip';
+ }
+
+ return {
+ ...result,
+ observation: `${result.observation}\n\n${resolved.guidance || resolved.label}`,
+ };
+ }
+ // Nobody answered. Fall through to guidance rather than blocking.
+ return result;
+ }
+
+ if (decision.kind === 'guidance' && decision.guidance) {
+ return { ...result, observation: `${result.observation}\n\n${decision.guidance}` };
+ }
+
+ return result;
+ }
+
/**
* Condense the session into memory, at the configured frequency.
*
diff --git a/app/agent/clarification.js b/app/agent/clarification.js
new file mode 100644
index 0000000..1115c03
--- /dev/null
+++ b/app/agent/clarification.js
@@ -0,0 +1,223 @@
+'use strict';
+
+/**
+ * A question the agent puts to the user mid-run, with the options it can act on.
+ *
+ * ## Why an agent that runs offline still stops to ask
+ *
+ * The alternative to asking is guessing, and the guesses a small model makes when it
+ * is stuck are not neutral. Across the v0.5.3 and 0.6.0 rounds the three observed
+ * endings for a model that had run out of ideas were: repeat the failing action until
+ * the step budget ran out, announce success it had not achieved, or abandon a task it
+ * was one decision away from finishing. All three cost the user more than a question
+ * would have.
+ *
+ * So this is the escape hatch, and its rules come from what makes a question cheap to
+ * answer rather than from what is easy to generate:
+ *
+ * - **Two to four options, never more.** A question with seven options is a second
+ * task handed to the user. Two to four fits on a card and can be answered without
+ * reading twice.
+ * - **Exactly one is recommended.** "Here are your choices" moves the decision without
+ * helping with it. The agent has the trace, the diagnosis, and the change set; it
+ * knows more about the situation than the user has looked at, and it should say what
+ * it would do. `build` refuses a set that recommends none or several.
+ * - **Every option states its effect on the queue**, so answering is not a second
+ * guess about what happens next.
+ * - **Free text is always available.** The options are the agent's reading of the
+ * situation, and the whole reason it is asking is that its reading may be wrong.
+ *
+ * ## What this module is not
+ *
+ * It does not ask anything. It builds and validates the request; `agentSession` owns
+ * when to raise one and what to do with the answer, and `chatTab` owns showing it.
+ * The split matters because a malformed request must fail here, at construction, and
+ * not in the webview where the run is already blocked waiting on a card that cannot
+ * render.
+ *
+ * @module agent/clarification
+ */
+
+const logger = require('../utils/logger');
+
+/** Fewer than this is not a choice; more is a task. */
+const MIN_OPTIONS = 2;
+const MAX_OPTIONS = 4;
+
+/** Labels and questions are shown on a card in a narrow panel. */
+const MAX_LABEL_CHARS = 80;
+const MAX_QUESTION_CHARS = 300;
+const MAX_DETAIL_CHARS = 200;
+
+/**
+ * What the run does with the answer.
+ *
+ * `instruct` is the interesting one: the run continues on the same item, with what the
+ * user said added to the model's next prompt as a correction. That is the case the
+ * whole mechanism is for — the user knows the one fact the model was missing.
+ *
+ * @typedef {'instruct' | 'retry' | 'skip' | 'stop'} ClarificationEffect
+ */
+
+/**
+ * @typedef {object} ClarificationOption
+ * @property {string} id
+ * @property {string} label What will happen, in the imperative.
+ * @property {string} [detail] One sentence on the consequence.
+ * @property {boolean} [recommended]
+ * @property {ClarificationEffect} effect
+ * @property {string} [guidance] Given to the model when this option is chosen.
+ */
+
+/**
+ * @typedef {object} Clarification
+ * @property {string} id
+ * @property {'error' | 'ambiguous' | 'choice'} kind Why it is being asked.
+ * @property {string} question
+ * @property {string} [context] What was already tried, so the user is not guessing.
+ * @property {ClarificationOption[]} options
+ * @property {boolean} allowFreeText
+ */
+
+/**
+ * @typedef {object} ClarificationAnswer
+ * @property {string} id The clarification being answered.
+ * @property {string} [optionId]
+ * @property {string} [text] Free text, when the user typed instead of choosing.
+ * @property {boolean} [cancelled] The user closed the panel or stopped the run.
+ */
+
+let counter = 0;
+
+/** @returns {string} A per-run unique id. Not security-bearing; it only pairs answers to questions. */
+function nextId() {
+ counter += 1;
+ return `clarify-${Date.now().toString(36)}-${counter}`;
+}
+
+/**
+ * Trim a model- or code-supplied string to something a card can hold.
+ *
+ * @param {unknown} value
+ * @param {number} max
+ * @returns {string}
+ */
+function clamp(value, max) {
+ const text = String(value == null ? '' : value).replace(/\s+/g, ' ').trim();
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
+}
+
+/**
+ * Build a validated clarification request.
+ *
+ * Throws rather than repairing a malformed set, because every caller here is code in
+ * this repository rather than model output — a bad set is a bug to fix, and quietly
+ * padding it to two options would hide it behind a question the user cannot answer
+ * usefully.
+ *
+ * The one thing that *is* repaired is a missing recommendation: the first option is
+ * promoted, with a warning. A run already blocked on a question should not also fail.
+ *
+ * @param {object} spec
+ * @param {'error' | 'ambiguous' | 'choice'} spec.kind
+ * @param {string} spec.question
+ * @param {string} [spec.context]
+ * @param {Array & {id?: string}>} spec.options
+ * @param {boolean} [spec.allowFreeText]
+ * @returns {Clarification}
+ */
+function build(spec) {
+ const question = clamp(spec.question, MAX_QUESTION_CHARS);
+ if (!question) throw new Error('A clarification needs a question.');
+
+ const given = Array.isArray(spec.options) ? spec.options : [];
+ if (given.length < MIN_OPTIONS || given.length > MAX_OPTIONS) {
+ throw new Error(
+ `A clarification needs between ${MIN_OPTIONS} and ${MAX_OPTIONS} options; got ${given.length}.`
+ );
+ }
+
+ /** @type {ClarificationOption[]} */
+ const options = given.map((option, index) => {
+ const label = clamp(option.label, MAX_LABEL_CHARS);
+ if (!label) throw new Error(`Clarification option ${index + 1} has no label.`);
+ return {
+ id: option.id || `option-${index + 1}`,
+ label,
+ detail: clamp(option.detail, MAX_DETAIL_CHARS),
+ recommended: option.recommended === true,
+ effect: option.effect || 'instruct',
+ guidance: typeof option.guidance === 'string' ? option.guidance : '',
+ };
+ });
+
+ const recommended = options.filter((option) => option.recommended);
+ if (recommended.length > 1) {
+ throw new Error('A clarification must recommend exactly one option, not several.');
+ }
+ if (recommended.length === 0) {
+ logger.warn('A clarification was built with no recommended option; promoting the first.');
+ options[0].recommended = true;
+ }
+
+ return {
+ id: nextId(),
+ kind: spec.kind || 'choice',
+ question,
+ context: clamp(spec.context, MAX_QUESTION_CHARS),
+ options,
+ // Always, and deliberately not settable. The options are the agent's reading of a
+ // situation it has just demonstrated it does not fully understand.
+ allowFreeText: true,
+ };
+}
+
+/**
+ * Resolve an answer against the question it answers.
+ *
+ * Returns a decision even for input that matches nothing, because the caller is a run
+ * that is currently blocked: "the answer made no sense" has to resolve to *something*,
+ * and the safe something is to stop rather than to pick an option on the user's behalf.
+ *
+ * @param {Clarification} clarification
+ * @param {ClarificationAnswer | null} answer
+ * @returns {{effect: ClarificationEffect, guidance: string, label: string}}
+ */
+function resolve(clarification, answer) {
+ if (!answer || answer.cancelled) {
+ return { effect: 'stop', guidance: '', label: 'stopped' };
+ }
+
+ // Free text wins over a selected option: a user who typed something meant it, and it
+ // carries more than the option they may also have clicked.
+ const typed = clamp(answer.text, 500);
+ if (typed) {
+ return {
+ effect: 'instruct',
+ guidance: typed,
+ label: 'answered',
+ };
+ }
+
+ const chosen = clarification.options.find((option) => option.id === answer.optionId);
+ if (!chosen) {
+ logger.warn(`Clarification ${clarification.id} answered with an unknown option; stopping.`);
+ return { effect: 'stop', guidance: '', label: 'stopped' };
+ }
+
+ return {
+ effect: chosen.effect,
+ guidance: chosen.guidance || chosen.label,
+ label: chosen.label,
+ };
+}
+
+module.exports = {
+ build,
+ resolve,
+ clamp,
+ MIN_OPTIONS,
+ MAX_OPTIONS,
+ MAX_LABEL_CHARS,
+ MAX_QUESTION_CHARS,
+};
diff --git a/app/agent/errorRecovery.js b/app/agent/errorRecovery.js
new file mode 100644
index 0000000..5fefab9
--- /dev/null
+++ b/app/agent/errorRecovery.js
@@ -0,0 +1,441 @@
+'use strict';
+
+/**
+ * What to do about a failure the model is not getting past on its own.
+ *
+ * ## The gap this fills
+ *
+ * `scriptDiagnosis` classifies a *command* failure into a named reason and one
+ * actionable sentence, and it is the right answer whenever a rule matches. This module
+ * covers the two cases it cannot:
+ *
+ * 1. **The failure nobody wrote a rule for.** `diagnose` returns null, and the model
+ * is handed a stack trace with no sentence saying what to do about it.
+ * 2. **The failure that has already been diagnosed, twice, and is still happening.**
+ * The diagnosis was correct and the model did not act on it — or acted on it and
+ * it did not help. Repeating the same sentence a third time is not a strategy.
+ *
+ * Both end the same way if nothing intervenes. From the v0.5.3 and 0.6.0 rounds, a
+ * small model that is stuck does one of three things: resends the identical action
+ * until the step budget is gone, reports success it did not achieve, or abandons a
+ * task it was one step from finishing. `gemma3:1b` sent the same failing `node
+ * src/main.js` four times; `ornith:9b` resent a refused `javac` three times.
+ *
+ * ## The escalation ladder
+ *
+ * Repetition is the signal, because a failure that happens once is information and the
+ * same failure twice is a model that is not learning from it:
+ *
+ * | Seen | What happens |
+ * |---|---|
+ * | 1st, diagnosed | Nothing extra — the diagnosis already said it |
+ * | 1st, undiagnosed | Read the error literally; here is what to look at |
+ * | 2nd | Ask the user |
+ *
+ * The user is asked **last**, not first. An agent that asks whenever it is unsure is an
+ * agent that has moved its work to the user, and this project's whole premise is a
+ * model small enough that "unsure" is its resting state.
+ *
+ * ## Why the second time and not the third
+ *
+ * Because there is no third. `reactLoop.REPEAT_LIMIT` is 2: the loop ends a run once
+ * the model has sent the same action twice, which is the right call and long predates
+ * this module. A ladder that waited for a third occurrence would therefore never reach
+ * its top rung in the commonest case — the model resending an identical failing
+ * command — and the run would end with the user never asked and the step budget spent.
+ *
+ * So the rungs are set against the loop's own limit rather than against a round number.
+ * The second failure is the last moment at which asking can still change the outcome,
+ * which makes it the right one and not merely an available one. A failure reached by
+ * two *different* actions does not trip the loop's guard, and this counts those the
+ * same way — same problem, same question.
+ *
+ * ## Why the error goes into memory
+ *
+ * A failure the run never resolved is exactly what the *next* run needs to know, and it
+ * is the thing session memory was least likely to contain: `contextTranslator` records
+ * what steps did, and a step that failed did nothing worth narrating. So the error is
+ * recorded here, in composed text rather than model-written, alongside whatever the
+ * user said about it — because "the user was asked about this and said X" is the
+ * highest-value note in the file and the one no amount of re-reading the workspace
+ * recovers.
+ *
+ * @module agent/errorRecovery
+ */
+
+const logger = require('../utils/logger');
+const clarification = require('./clarification');
+
+/**
+ * How many times one failure repeats before the user is asked about it.
+ *
+ * Two, and it is pinned to `reactLoop.REPEAT_LIMIT` rather than chosen — see the
+ * module header. Raising it without raising that one means never asking at all.
+ */
+const REPEATS_BEFORE_ASKING = 2;
+
+/** Signatures are compared, not read; this is long enough to separate real failures. */
+const SIGNATURE_CHARS = 200;
+
+/** A failure message is quoted back to the user and into memory, so it is bounded. */
+const MAX_QUOTED_CHARS = 300;
+
+/**
+ * Tool failures that are the permission model working, not the model being stuck.
+ *
+ * A refused write is a *decision*, and the correct response to it is to stop trying —
+ * which the model is already told at the point of refusal. Escalating a declined
+ * delete to "shall I ask the user?" would ask them the same question twice, and the
+ * second time with less context than the confirmation dialog had.
+ */
+const NOT_STUCK = new Set([
+ 'PERMISSION_DENIED',
+ 'DECLINED',
+ 'CANCELLED',
+ 'TOOL_UNAVAILABLE',
+]);
+
+/**
+ * Reduce a failure message to something that compares equal across repeats.
+ *
+ * Line numbers, addresses, and paths change between two runs of the same broken code;
+ * the sentence does not. Digits go last and wholesale, which merges "line 12" with
+ * "line 40" — correct here, because this asks "is this the same problem again", not
+ * "is this the same character sequence".
+ *
+ * @param {string} action
+ * @param {string} message
+ * @returns {string}
+ */
+function signatureOf(action, message) {
+ const text = String(message == null ? '' : message)
+ // Windows absolute paths, then POSIX ones. Both appear in stack traces and both
+ // differ between machines running the identical failure.
+ .replace(/[A-Za-z]:[\\/][^\s'"()]+/g, '')
+ .replace(/(^|[\s'"(])\/[^\s'"()]{2,}/g, '$1')
+ .replace(/\b0x[0-9a-f]+\b/gi, '')
+ .replace(/\b\d+\b/g, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .toLowerCase()
+ .slice(0, SIGNATURE_CHARS);
+
+ return `${action}::${text}`;
+}
+
+/**
+ * The first line of a failure that says something, for quoting back.
+ *
+ * Stack frames are skipped: `at Object. (/app/x.js:4:11)` is where it
+ * happened, and the line above it is what happened. A user being asked about a failure
+ * needs the second one.
+ *
+ * @param {string} message
+ * @returns {string}
+ */
+function headline(message) {
+ const lines = String(message == null ? '' : message).split(/\r?\n/);
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ if (/^at\s/.test(trimmed)) continue;
+ if (/^\s*(File|Traceback)\b/.test(trimmed)) continue;
+ return clarification.clamp(trimmed, MAX_QUOTED_CHARS);
+ }
+ return clarification.clamp(String(message || '').trim(), MAX_QUOTED_CHARS);
+}
+
+/**
+ * @typedef {object} Failure
+ * @property {string} action The tool that failed.
+ * @property {string} [path]
+ * @property {string} [command]
+ * @property {string} [error] Machine-readable code, when the tool gave one.
+ * @property {string} observation What the model was shown.
+ * @property {boolean} diagnosed Whether `scriptDiagnosis` already explained it.
+ * @property {string} [item] The TODO item being worked on, for the question text.
+ */
+
+/**
+ * @typedef {object} RecoveryDecision
+ * @property {'none' | 'guidance' | 'ask'} kind
+ * @property {string} [guidance] Appended to the observation the model reads next.
+ * @property {string} [note] Composed memory text. Present whenever there is one.
+ * @property {import('./clarification').Clarification} [clarification]
+ * @property {number} count How many times this failure has now been seen.
+ */
+
+class ErrorRecovery {
+ /**
+ * @param {object} [options]
+ * @param {number} [options.repeatsBeforeAsking]
+ * @param {boolean} [options.canAsk]
+ * False when nothing is listening for a question — an Ask-mode turn, a benchmark,
+ * a session whose panel has gone away. The ladder then tops out at guidance, which
+ * is strictly better than blocking a run on a card nobody will ever see.
+ */
+ constructor(options = {}) {
+ this.repeatsBeforeAsking = options.repeatsBeforeAsking || REPEATS_BEFORE_ASKING;
+ this.canAsk = options.canAsk !== false;
+
+ /**
+ * Signature → what we know about that failure so far.
+ *
+ * Per-session rather than persisted: "you have tried this three times" is a claim
+ * about this run, and carrying it across runs would have a new session open already
+ * out of patience with a failure the user may have fixed in between.
+ *
+ * @type {Map}
+ */
+ this._seen = new Map();
+
+ /**
+ * What the user said when they were asked, keyed by signature, so the same failure
+ * later in the run is answered from what they already told us instead of asking
+ * twice.
+ *
+ * @type {Map}
+ */
+ this._answers = new Map();
+ }
+
+ /**
+ * Record a failure and decide what to do about it.
+ *
+ * @param {Failure} failure
+ * @returns {RecoveryDecision}
+ */
+ observe(failure) {
+ const action = String(failure.action || 'action');
+ const observation = String(failure.observation || '');
+
+ // A refusal is the permission model working. Nothing here applies.
+ if (failure.error && NOT_STUCK.has(failure.error)) {
+ return { kind: 'none', count: 0 };
+ }
+
+ const signature = signatureOf(action, observation);
+ const known = this._seen.get(signature) || {
+ count: 0,
+ headline: headline(observation),
+ action,
+ asked: false,
+ };
+ known.count += 1;
+ this._seen.set(signature, known);
+
+ const target = failure.path || failure.command || '';
+ const note = this._note(known, target, failure.diagnosed);
+
+ // Already asked about this one. The user's answer is the guidance from here on —
+ // asking again would be asking them to repeat themselves.
+ const answered = this._answers.get(signature);
+ if (answered) {
+ return {
+ kind: 'guidance',
+ guidance:
+ `You asked the user about this and they said: "${answered}" — do that, ` +
+ 'rather than trying the same thing again.',
+ note,
+ count: known.count,
+ };
+ }
+
+ if (known.count >= this.repeatsBeforeAsking && this.canAsk && !known.asked) {
+ known.asked = true;
+ logger.info(
+ `The same failure has happened ${known.count} times (${known.headline}); asking the user.`
+ );
+ return {
+ kind: 'ask',
+ clarification: this._question(known, failure, target),
+ note,
+ count: known.count,
+ };
+ }
+
+ if (known.count === 1) {
+ // A diagnosed failure has already been given its sentence by `scriptDiagnosis`,
+ // and a second one saying the same thing in different words is noise in a context
+ // window that has none to spare.
+ if (failure.diagnosed) return { kind: 'none', note, count: known.count };
+ return { kind: 'guidance', guidance: this._firstGuidance(failure), note, count: known.count };
+ }
+
+ return {
+ kind: 'guidance',
+ guidance: this._repeatGuidance(known, target),
+ note,
+ count: known.count,
+ };
+ }
+
+ /**
+ * Remember what the user said, so the rest of the run acts on it.
+ *
+ * @param {Failure} failure The failure they were asked about.
+ * @param {string} answer What they chose or typed.
+ * @returns {string} The memory note for the answer.
+ */
+ recordAnswer(failure, answer) {
+ const signature = signatureOf(String(failure.action || 'action'), String(failure.observation || ''));
+ const text = clarification.clamp(answer, MAX_QUOTED_CHARS);
+ if (text) this._answers.set(signature, text);
+
+ const known = this._seen.get(signature);
+ const about = known ? known.headline : headline(String(failure.observation || ''));
+ return `The user was asked about a repeated failure ("${about}") and said: ${text}`;
+ }
+
+ /**
+ * Has this run already given up on this failure and asked about it?
+ *
+ * @param {Failure} failure
+ * @returns {boolean}
+ */
+ hasAsked(failure) {
+ const known = this._seen.get(signatureOf(String(failure.action || 'action'), String(failure.observation || '')));
+ return Boolean(known && known.asked);
+ }
+
+ /**
+ * Everything that failed more than once this run, worst first.
+ *
+ * Used by the session summary: a run that ended "3 of 4 items completed" is more
+ * honest when it can also say which wall it kept hitting.
+ *
+ * @returns {Array<{headline: string, action: string, count: number}>}
+ */
+ persistent() {
+ return [...this._seen.values()]
+ .filter((known) => known.count > 1)
+ .sort((a, b) => b.count - a.count)
+ .map(({ headline: text, action, count }) => ({ headline: text, action, count }));
+ }
+
+ /**
+ * What to say the first time an unexplained failure appears.
+ *
+ * @param {Failure} failure
+ * @returns {string}
+ * @private
+ */
+ _firstGuidance(failure) {
+ const where = failure.path ? ` It was working on ${failure.path}.` : '';
+ return (
+ 'This failure does not match anything known, so read the error above literally rather than guessing: ' +
+ 'it names what went wrong and usually where.' +
+ where +
+ ' Change one thing that the error actually mentions, then try again. Do not resend the same ' +
+ 'action unchanged — it will fail the same way.'
+ );
+ }
+
+ /**
+ * What to say once it is clear the model is going round in a circle.
+ *
+ * Names the count, because "you have already tried this twice" is a fact the model
+ * cannot get from its own context on a Tier B loop — the trace is rebuilt each turn
+ * and the earlier attempt is no longer in it.
+ *
+ * @param {{count: number, headline: string}} known
+ * @param {string} target
+ * @returns {string}
+ * @private
+ */
+ _repeatGuidance(known, target) {
+ const what = target ? ` on ${target}` : '';
+ return (
+ `This is the same failure as before — it has now happened ${known.count} times${what}: ` +
+ `"${known.headline}". Whatever you changed between attempts did not address it. Do not try that ` +
+ 'approach a third time. Either work on a different part of the task, or fix the thing the error ' +
+ 'names directly — and if you genuinely cannot, say so plainly rather than repeating the attempt.'
+ );
+ }
+
+ /**
+ * The question put to the user once the ladder runs out.
+ *
+ * Three options, one recommended, and the recommendation is *skip* rather than retry:
+ * the same thing has now failed twice with targeted guidance in between, and
+ * recommending another attempt would recommend the thing that has already not worked.
+ * Skipping keeps the rest of the list moving, which is usually what the user wants
+ * and always what they can undo.
+ *
+ * @param {{count: number, headline: string}} known
+ * @param {Failure} failure
+ * @param {string} target
+ * @returns {import('./clarification').Clarification}
+ * @private
+ */
+ _question(known, failure, target) {
+ const what = target ? ` \`${target}\`` : '';
+ const item = failure.item ? ` while working on "${clarification.clamp(failure.item, 120)}"` : '';
+
+ return clarification.build({
+ kind: 'error',
+ question: `I am stuck on${what || ' this step'}. How should I carry on?`,
+ context:
+ `The same failure has happened ${known.count} times${item}: "${known.headline}". ` +
+ 'I have changed my approach between attempts and it keeps failing the same way.',
+ options: [
+ {
+ id: 'skip',
+ label: 'Skip this and carry on',
+ detail: 'Leaves this step unfinished and moves to the rest of the list.',
+ effect: 'skip',
+ recommended: true,
+ guidance:
+ 'The user chose to skip this step. Leave it alone, do not try it again, and move on to the ' +
+ 'next item.',
+ },
+ {
+ id: 'retry',
+ label: 'Try once more',
+ detail: 'Runs the same step again from a clean start.',
+ effect: 'retry',
+ guidance:
+ 'The user asked for one more attempt. Start from what the error says rather than from what ' +
+ 'you tried last time.',
+ },
+ {
+ id: 'stop',
+ label: 'Stop the run',
+ detail: 'Ends here, keeping every change made so far.',
+ effect: 'stop',
+ },
+ ],
+ });
+ }
+
+ /**
+ * The memory note for a failure.
+ *
+ * Composed here rather than written by the translator: a failed step has nothing to
+ * narrate, so the translator's grounding check rejects whatever it invents and the
+ * note ends up bare. This one is built from what is known for certain.
+ *
+ * @param {{count: number, headline: string, action: string}} known
+ * @param {string} target
+ * @param {boolean} diagnosed
+ * @returns {string}
+ * @private
+ */
+ _note(known, target, diagnosed) {
+ // Only worth remembering once it is a pattern. A single failure that the next step
+ // fixes is noise, and the recall window is small enough that noise costs something.
+ if (known.count < 2) return '';
+
+ const what = target ? ` on ${target}` : '';
+ const explained = diagnosed ? '' : ' It was not a recognised kind of failure.';
+ return `${known.action}${what} failed ${known.count} times with: ${known.headline}.${explained}`;
+ }
+}
+
+module.exports = {
+ ErrorRecovery,
+ signatureOf,
+ headline,
+ REPEATS_BEFORE_ASKING,
+ NOT_STUCK,
+};
diff --git a/app/agent/scriptDiagnosis.js b/app/agent/scriptDiagnosis.js
index d2722e4..a32dc7b 100644
--- a/app/agent/scriptDiagnosis.js
+++ b/app/agent/scriptDiagnosis.js
@@ -50,7 +50,12 @@
* once you squint. Each rule states the fix in the imperative, naming a concrete next
* command wherever there is one.
*
- * @type {Array<{reason: string, pattern: RegExp, summary: string, fix: string, retryable?: boolean}>}
+ * `fix` may be a function of the match when the sentence has to name what the error
+ * named. "Something is undefined" is not actionable to a 1B model; "`addTodo` is used
+ * in that file but never defined or imported" is the same information with the one
+ * detail that makes it a next move.
+ *
+ * @type {Array<{reason: string, pattern: RegExp, summary: string | ((m: RegExpExecArray) => string), fix: string | ((m: RegExpExecArray) => string), retryable?: boolean, fixFirst?: boolean}>}
*/
const RULES = [
{
@@ -138,6 +143,97 @@ const RULES = [
'use `"@tailwindcss/postcss": {}` in place of `tailwindcss: {}`. Then run the command again.',
fixFirst: true,
},
+ // ── Undefined symbols ────────────────────────────────────────────────────────
+ //
+ // The single most common way code written by a small model fails at runtime, and
+ // until 0.7.0 none of it matched a rule: the model got a stack trace, the generic
+ // "the error points at a file" fallback, and no mention of the name that was
+ // actually missing. It then rewrote the file from memory and produced the same
+ // error, which is the loop these rules exist to break.
+ //
+ // Ordered after MODULE_SYSTEM, which claims the one `is not defined` that is really
+ // a module-system mismatch (`module is not defined in ES module scope`) and has a
+ // completely different fix.
+ {
+ reason: 'UNDEFINED_SYMBOL',
+ pattern: /ReferenceError:\s*(\w+) is not defined/,
+ summary: (m) => `${m[1]} is used but never defined`,
+ fix: (m) =>
+ `\`${m[1]}\` is used in that file but is never defined or imported there. Open the file the error names and ` +
+ `either import \`${m[1]}\` from the file that exports it, or define it. If you meant a name that already ` +
+ 'exists, check the spelling matches exactly — it is case-sensitive. Then run the command again.',
+ fixFirst: true,
+ },
+ {
+ reason: 'UNDEFINED_SYMBOL',
+ pattern: /NameError: name '([^']+)' is not defined/,
+ summary: (m) => `${m[1]} is used but never defined`,
+ fix: (m) =>
+ `\`${m[1]}\` is used in that file but is never defined or imported there. Open the file the error names and ` +
+ `either add the import for \`${m[1]}\`, or define it above where it is used. Then run the command again.`,
+ fixFirst: true,
+ },
+ {
+ reason: 'UNDEFINED_SYMBOL',
+ // javac prints the location, then `symbol: variable foo` on a later line.
+ pattern: /cannot find symbol[\s\S]{0,300}?symbol:\s*(?:variable|method|class)\s+(\w+)/,
+ summary: (m) => `${m[1]} does not exist where it is used`,
+ fix: (m) =>
+ `javac cannot find \`${m[1]}\`. Either it is spelled differently where it is declared, or the class that ` +
+ 'declares it is not imported, or it was never written. Open the file and line the error names, check that ' +
+ 'name against where you defined it, and fix whichever side is wrong. Then compile again.',
+ fixFirst: true,
+ },
+ {
+ reason: 'UNDEFINED_PROPERTY',
+ // Node ≥16 phrasing, then the older one. Both name the property being read.
+ pattern: /Cannot read properties of (undefined|null) \(reading '([^']+)'\)|Cannot read property '([^']+)' of (undefined|null)/,
+ summary: (m) => `something was ${m[1] || m[4]} when '${m[2] || m[3]}' was read from it`,
+ fix: (m) => {
+ const property = m[2] || m[3];
+ const value = m[1] || m[4];
+ return (
+ `The value that \`${property}\` was read from is ${value} at that point — the property is fine, the thing ` +
+ `holding it does not exist yet. Open the file and line the error names and work out why it is ${value}: a ` +
+ 'function that returned nothing, a prop or argument never passed in, state read before it was set, or an ' +
+ `import that did not resolve. Fix the source of the ${value}, not the line that read it. Then run it again.`
+ );
+ },
+ fixFirst: true,
+ },
+ {
+ reason: 'UNDEFINED_PROPERTY',
+ pattern: /AttributeError: '(\w+)' object has no attribute '([^']+)'/,
+ summary: (m) => `a ${m[1]} has no attribute '${m[2]}'`,
+ fix: (m) =>
+ `\`${m[2]}\` was read from a \`${m[1]}\`, which does not have it.` +
+ (m[1] === 'NoneType'
+ ? ' NoneType means the value is None — something returned None where an object was expected, so fix whatever produced the None rather than the line that used it.'
+ : ` Either the attribute is spelled differently on that class, or the object is not the type you expected. Open the file the error names and check what \`${m[1]}\` actually defines.`) +
+ ' Then run it again.',
+ fixFirst: true,
+ },
+ {
+ reason: 'NOT_A_FUNCTION',
+ pattern: /TypeError: ([\w.]+) is not a function/,
+ summary: (m) => `${m[1]} is not a function`,
+ fix: (m) =>
+ `\`${m[1]}\` is being called, but what is there is not a function — commonly a wrong import shape ` +
+ '(default vs named), a typo, or a value that is undefined. Open the file the error names, check how ' +
+ `\`${m[1]}\` is imported against how the other file exports it, and fix whichever side is wrong. ` +
+ 'Then run the command again.',
+ fixFirst: true,
+ },
+ {
+ reason: 'NULL_DEREFERENCE',
+ pattern: /NullPointerException/,
+ summary: 'something was null when it was used',
+ fix:
+ 'A value was null where an object was expected. Open the file and line named in the stack trace, find which ' +
+ 'reference is null there, and fix what should have set it — initialise the field, pass the argument, or ' +
+ 'check for null before using it. Then run it again.',
+ fixFirst: true,
+ },
{
reason: 'SYNTAX_ERROR',
pattern: /SyntaxError|Unexpected token|Parse failure|error TS\d+|\berror: expected\b|Unterminated|Transform failed/i,
@@ -246,11 +342,15 @@ function matchRules(result) {
if (!text.trim()) return null;
for (const rule of RULES) {
- if (rule.pattern.test(text)) {
+ // `exec` rather than `test` so a rule whose sentence names what the error named
+ // has the capture groups to name it with. None of these patterns are global, so
+ // there is no `lastIndex` to reset between calls.
+ const match = rule.pattern.exec(text);
+ if (match) {
return {
reason: rule.reason,
- summary: rule.summary,
- fix: rule.fix,
+ summary: typeof rule.summary === 'function' ? rule.summary(match) : rule.summary,
+ fix: typeof rule.fix === 'function' ? rule.fix(match) : rule.fix,
retryable: Boolean(rule.retryable),
fixFirst: Boolean(rule.fixFirst),
};
diff --git a/app/agent/todoList.js b/app/agent/todoList.js
index c9480a6..96405aa 100644
--- a/app/agent/todoList.js
+++ b/app/agent/todoList.js
@@ -65,6 +65,19 @@ class TodoList {
/** @type {TodoItem[]} */
this.items = items.slice(0, MAX_ITEMS).map((text) => ({ text: String(text).trim(), status: 'pending' }));
if (this.items.length > 0) this.items[0].status = 'active';
+
+ /**
+ * Every change made to the list after it was planned, in order.
+ *
+ * The list is the one thing in a run that both the user and the model treat as
+ * settled — it is shown once, ticked off as it goes, and read back in the summary.
+ * So when it changes mid-run because the user was asked something, that has to be
+ * visible: a checklist that quietly grew an item reads afterwards as a model that
+ * did work nobody asked for.
+ *
+ * @type {Array<{kind: 'inserted' | 'reworded' | 'dropped', detail: string, at: number}>}
+ */
+ this.changes = [];
}
/** @returns {boolean} */
@@ -133,6 +146,103 @@ class TodoList {
return this.items.filter((item) => wanted.includes(item.status)).map((item) => item.text);
}
+ /**
+ * Add items directly after the one being worked on.
+ *
+ * Used when the user, having been asked, says the work needs a step nobody planned
+ * for. They go *after* the active item rather than at the end, because a step the
+ * current one turned out to need is a step the ones after it need too.
+ *
+ * The `MAX_ITEMS` ceiling still applies — it is the number a small model can hold,
+ * and the user answering a question does not change that. Anything over the ceiling
+ * is refused rather than silently dropped, so the caller can say so.
+ *
+ * @param {string[]} texts
+ * @returns {number} How many were actually added.
+ */
+ insertAfterCurrent(texts) {
+ const index = this.items.findIndex((item) => item.status === 'active');
+ if (index === -1) return 0;
+
+ const room = MAX_ITEMS - this.items.length;
+ if (room <= 0) {
+ logger.warn(`Cannot add to the checklist: it is already at the ${MAX_ITEMS}-item ceiling.`);
+ return 0;
+ }
+
+ /** @type {TodoItem[]} */
+ const added = texts
+ .map((text) => String(text).trim())
+ .filter(Boolean)
+ .slice(0, room)
+ .map((text) => ({ text, status: /** @type {TodoStatus} */ ('pending') }));
+ if (added.length === 0) return 0;
+
+ this.items.splice(index + 1, 0, ...added);
+ for (const item of added) {
+ this.changes.push({ kind: 'inserted', detail: item.text, at: index + 1 });
+ logger.info(`Checklist gained an item after ${index + 1}: ${item.text}`);
+ }
+ return added.length;
+ }
+
+ /**
+ * Restate the active item.
+ *
+ * The original text is kept in the change log rather than overwritten in place: the
+ * summary has to be able to say what the item *was*, or a user reading it afterwards
+ * cannot tell that their answer is why it succeeded.
+ *
+ * @param {string} text
+ * @returns {boolean}
+ */
+ replaceCurrent(text) {
+ const active = this.current();
+ const wanted = String(text || '').trim();
+ if (!active || !wanted || wanted === active.text) return false;
+
+ this.changes.push({ kind: 'reworded', detail: `"${active.text}" → "${wanted}"`, at: this.position() });
+ logger.info(`Checklist item ${this.position()} reworded: ${active.text} → ${wanted}`);
+ active.text = wanted;
+ return true;
+ }
+
+ /**
+ * Close the active item as skipped and move on, leaving the rest of the list alone.
+ *
+ * Distinct from `skipRemaining`, which gives up on everything. This is the user
+ * saying "not that one" — the run continues.
+ *
+ * @param {string} reason
+ * @returns {TodoItem | null} The next item.
+ */
+ skipCurrent(reason) {
+ const active = this.current();
+ if (!active) return null;
+ this.changes.push({ kind: 'dropped', detail: active.text, at: this.position() });
+ return this.finishCurrent('skipped', reason);
+ }
+
+ /**
+ * What changed about the list after it was planned, in plain language.
+ *
+ * @returns {string} Empty when the list ran as planned.
+ */
+ describeChanges() {
+ if (this.changes.length === 0) return '';
+ const lines = this.changes.map((change) => {
+ switch (change.kind) {
+ case 'inserted':
+ return `- Added at position ${change.at + 1}: ${change.detail}`;
+ case 'reworded':
+ return `- Item ${change.at} reworded: ${change.detail}`;
+ default:
+ return `- Item ${change.at} dropped: ${change.detail}`;
+ }
+ });
+ return `The checklist changed while it ran:\n${lines.join('\n')}`;
+ }
+
/** Mark every remaining item as skipped — used when the session is cut short. */
skipRemaining(reason) {
for (const item of this.items) {
diff --git a/app/core/commonSense.js b/app/core/commonSense.js
new file mode 100644
index 0000000..22b1dd0
--- /dev/null
+++ b/app/core/commonSense.js
@@ -0,0 +1,442 @@
+'use strict';
+
+/**
+ * Reading a request the way a person would, before handing it to a 1B model.
+ *
+ * ## The failure this is about
+ *
+ * A request does not have to be well-formed to be obvious. "Update mian.js" in a
+ * project containing `main.js` is not ambiguous to a human being for even a moment.
+ * To a small model it is a path that does not resolve, and what happens next is the
+ * part that costs: it creates `mian.js`, empty or near enough, reports the update
+ * done, and the user now has two files where they had one. Observed shape, repeatedly
+ * — the model does not question the premise of a request, it executes it literally,
+ * and a literal reading of a typo is a new file.
+ *
+ * The same applies to a reference with nothing behind it. "Fix it" as the first
+ * message of a session names nothing; a model handed that will pick a file, and the
+ * file it picks is essentially arbitrary.
+ *
+ * ## The rule this follows
+ *
+ * **Work around it when the workaround is obvious; ask when it is a real choice.**
+ * That line is drawn by the evidence, not by confidence:
+ *
+ * - Exactly one near-match → repair it, say so in the summary. A user who wrote
+ * `mian.js` next to a `main.js` meant `main.js`, and stopping to ask would be
+ * pedantry with a dialog on it.
+ * - Two to four near-matches → ask, offering them. This is the case where guessing is
+ * a coin toss with the user's files as the stake.
+ * - No near-match → say nothing. The file may simply not exist yet, which is what
+ * "create" requests look like, and a check that fires on those would be worse than
+ * no check.
+ *
+ * ## Why this is patterns and not a model call
+ *
+ * Same reason as `intentRouter` and `factStore`: asking a 1B model whether a request
+ * makes sense costs an inference and returns the confidence of a model that could not
+ * answer the request either. Everything here is decided from the workspace listing and
+ * the text, so it is deterministic, free, and testable — and it can be wrong in only
+ * one direction, since a check that finds nothing leaves the request exactly as it was.
+ *
+ * @module core/commonSense
+ */
+
+const clarification = require('../agent/clarification');
+const logger = require('../utils/logger');
+
+/**
+ * How alike two filenames must be before one is treated as a typo of the other.
+ *
+ * Paired with `MAX_TYPO_DISTANCE` below, which is the constraint that actually does the
+ * work on short names: a ratio alone would rate `app.js` against `api.js` at 0.83 for
+ * the same single edit that separates `mian.js` from `main.js`, and those are not the
+ * same kind of guess.
+ */
+const TYPO_THRESHOLD = 0.75;
+
+/**
+ * The most edits a typo is allowed to be.
+ *
+ * Two, because a doubled and a dropped character in one filename is an ordinary slip
+ * and three is a different word. `memoryStore.similarity` is not used here despite
+ * doing a similar-sounding job: it is Jaccard over *whole words*, so it scores
+ * `mian.js` against `main.js` at zero — correct for the note de-duplication it was
+ * written for, useless for a misspelling.
+ */
+const MAX_TYPO_DISTANCE = 2;
+
+/** Beyond this many candidates the question is worse than the guess. */
+const MAX_CANDIDATES = 3;
+
+/**
+ * Verbs that presuppose the thing already exists.
+ *
+ * The distinction is the whole safety of this module. "Update mian.js" is a mistake;
+ * "create mian.js" is a filename, and it is not this module's business to correct a
+ * name the user chose. Only the first list is checked.
+ */
+const EXPECTS_EXISTING = /\b(update|edit|fix|change|modify|refactor|rename|read|open|delete|remove|check|review|revert|document|test)\b/i;
+
+/**
+ * Verbs that create, so a name the user is inventing is left alone.
+ *
+ * Tested against the words immediately before the filename rather than the whole
+ * message, which is a distinction with teeth: "update mian.js **to add** a header" is a
+ * typo in a request that happens to contain the word "add", and a whole-message test
+ * reads it as a creation and silently declines to help. The window is short for the
+ * same reason — "create the parser, then update mian.js" must not have its second
+ * clause excused by its first.
+ */
+const CREATES = /\b(create|add|make|write|new|generate|scaffold|build|init|implement|call(?:ed)?|name[ds]?)\b/i;
+
+/** How many words before a filename are read as its verb. */
+const VERB_WINDOW = 6;
+
+/**
+ * A stem shorter than this cannot be judged. `a.js` and `b.js` are one edit apart and
+ * have nothing to do with each other.
+ */
+const MIN_STEM_CHARS = 3;
+
+/**
+ * A token that looks like a path or a filename.
+ *
+ * Requires an extension, so ordinary prose survives: "update the parser" names no file
+ * and must not be read as one. Backticks and quotes are stripped by the caller.
+ */
+const PATH_TOKEN = /(?:[\w.@-]+[/\\])*[\w.@-]+\.[a-z0-9]{1,6}\b/gi;
+
+/**
+ * Extensions worth treating as a file reference. A version number like `1.2` and a
+ * sentence ending in `.js` are told apart here rather than by cleverness.
+ */
+const SOURCE_EXTENSION =
+ /\.(jsx?|tsx?|mjs|cjs|json|css|s[ac]ss|html?|md|ya?ml|py|java|go|rs|rb|php|vue|svelte|txt|toml|ini|xml|sql|sh|bat|ps1)$/i;
+
+/**
+ * A message that refers to something without naming it.
+ *
+ * Anchored, and the pronoun has to be the object of the verb — "fix it" matches,
+ * "fix items in the list" does not.
+ */
+const DANGLING_REFERENCE = /^\s*(?:please\s+)?(?:can you\s+|could you\s+)?(fix|update|change|finish|redo|revert|delete|remove|continue|do)\s+(it|this|that|those|these|them|the thing|the file|that file|this file)\s*[.!?]?\s*$/i;
+
+/**
+ * @typedef {object} Interpretation
+ * @property {'ok' | 'repaired' | 'ask'} kind
+ * @property {string} [task] The rewritten request, when repaired.
+ * @property {string} [note] What was changed and why, for the summary and memory.
+ * @property {import('../agent/clarification').Clarification} [clarification]
+ */
+
+/**
+ * Every filename-looking token in a message.
+ *
+ * @param {string} task
+ * @returns {string[]} Unique, in the order they appear.
+ */
+function referencedPaths(task) {
+ const text = String(task || '').replace(/[`'"]/g, ' ');
+ const found = text.match(PATH_TOKEN) || [];
+
+ /** @type {string[]} */
+ const unique = [];
+ for (const raw of found) {
+ const token = raw.replace(/\\/g, '/');
+ if (!SOURCE_EXTENSION.test(token)) continue;
+ if (!unique.includes(token)) unique.push(token);
+ }
+ return unique;
+}
+
+/**
+ * Does this path exist in the workspace listing?
+ *
+ * Compared case-insensitively and on the tail, so "update useTodos.js" matches
+ * `src/hooks/useTodos.js` — a user naming a file rarely types its full path, and
+ * treating that as a miss would fire this module on nearly every well-formed request.
+ *
+ * @param {string} candidate
+ * @param {string[]} files Workspace-relative paths.
+ * @returns {boolean}
+ */
+function existsIn(candidate, files) {
+ const wanted = candidate.toLowerCase();
+ // A reference whose folders are wrong but whose *name* is right counts as existing.
+ // That is a different mistake from a typo and it needs no help: the model is given
+ // the workspace listing, so `src/hooks/useTodos.js` is one read away from
+ // `app/useTodos.js`. Treating it as a miss would hand it to the typo matcher, which
+ // would find the same file and "correct" a name that was never misspelled.
+ const wantedBase = wanted.split('/').pop();
+ return files.some((file) => {
+ const path = file.toLowerCase().replace(/\\/g, '/');
+ return path === wanted || path.endsWith(`/${wanted}`) || path.split('/').pop() === wantedBase;
+ });
+}
+
+/**
+ * Edit distance counting a transposition as one edit, not two.
+ *
+ * The transposition case is the reason this is not plain Levenshtein: `mian` for `main`
+ * is the single commonest way a filename gets mistyped, and Levenshtein charges it two
+ * edits — the same as `main` to `mail` plus another change. On a seven-character name
+ * that is the difference between a confident match and a miss.
+ *
+ * Bounded by construction: both inputs are basenames, and the caller has already
+ * rejected anything without a file extension.
+ *
+ * @param {string} a
+ * @param {string} b
+ * @returns {number}
+ */
+function editDistance(a, b) {
+ if (a === b) return 0;
+ if (!a.length) return b.length;
+ if (!b.length) return a.length;
+
+ // Full matrix rather than two rolling rows: a transposition needs the row before
+ // last, and these are filenames — the matrix is tens of cells, not thousands.
+ /** @type {number[][]} */
+ const d = [];
+ for (let i = 0; i <= a.length; i += 1) d.push(new Array(b.length + 1).fill(0));
+ for (let i = 0; i <= a.length; i += 1) d[i][0] = i;
+ for (let j = 0; j <= b.length; j += 1) d[0][j] = j;
+
+ for (let i = 1; i <= a.length; i += 1) {
+ for (let j = 1; j <= b.length; j += 1) {
+ const cost = a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1;
+ let best = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
+ if (i > 1 && j > 1 && a.charAt(i - 1) === b.charAt(j - 2) && a.charAt(i - 2) === b.charAt(j - 1)) {
+ best = Math.min(best, d[i - 2][j - 2] + 1);
+ }
+ d[i][j] = best;
+ }
+ }
+
+ return d[a.length][b.length];
+}
+
+/**
+ * How alike two filenames are, 0 to 1.
+ *
+ * @param {string} a
+ * @param {string} b
+ * @returns {number}
+ */
+function nameSimilarity(a, b) {
+ const longest = Math.max(a.length, b.length);
+ if (longest === 0) return 0;
+ return 1 - editDistance(a, b) / longest;
+}
+
+/**
+ * Workspace files whose name is close enough to be a typo of this one.
+ *
+ * Compared on the basename only. A path that is wrong in its folders but right in its
+ * filename is the common case — `src/mian.js` for `app/main.js` — and comparing whole
+ * paths would score that on the folders, which are not what was misspelled.
+ *
+ * @param {string} candidate
+ * @param {string[]} files
+ * @returns {string[]} Best first, at most `MAX_CANDIDATES`.
+ */
+function nearMatches(candidate, files) {
+ const wanted = String(candidate).split('/').pop().toLowerCase();
+ if (wanted.replace(SOURCE_EXTENSION, '').length < MIN_STEM_CHARS) return [];
+
+ /** @type {Array<{file: string, score: number}>} */
+ const scored = [];
+ for (const file of files) {
+ const base = file.replace(/\\/g, '/').split('/').pop().toLowerCase();
+ if (base === wanted) continue;
+
+ // An extension mismatch is a different file, not a typo: `main.js` and `main.css`
+ // score high on characters and are never the same mistake.
+ const wantedExt = (wanted.match(SOURCE_EXTENSION) || [''])[0];
+ const baseExt = (base.match(SOURCE_EXTENSION) || [''])[0];
+ if (wantedExt && baseExt && wantedExt.toLowerCase() !== baseExt.toLowerCase()) continue;
+
+ if (editDistance(base, wanted) > MAX_TYPO_DISTANCE) continue;
+ const score = nameSimilarity(base, wanted);
+ if (score >= TYPO_THRESHOLD) scored.push({ file, score });
+ }
+
+ scored.sort((a, b) => b.score - a.score);
+ return scored.slice(0, MAX_CANDIDATES).map((entry) => entry.file);
+}
+
+/**
+ * Is this filename one the request is asking to bring into existence?
+ *
+ * Reads the few words in front of the name, which is where the verb governing it sits
+ * in every phrasing that came up: "create X", "add a file called X", "write X".
+ *
+ * @param {string} task
+ * @param {string} referenced
+ * @returns {boolean}
+ */
+function isBeingCreated(task, referenced) {
+ const text = String(task || '').replace(/[`'"]/g, ' ');
+ const at = text.toLowerCase().indexOf(referenced.toLowerCase());
+ if (at <= 0) return false;
+
+ const before = text.slice(0, at).trim().split(/\s+/).slice(-VERB_WINDOW).join(' ');
+ return CREATES.test(before);
+}
+
+/**
+ * Read a request against the workspace it is about.
+ *
+ * @param {object} input
+ * @param {string} input.task
+ * @param {string[]} [input.files] Workspace-relative paths.
+ * @param {Array<{role: string, text: string}>} [input.conversation]
+ * @param {string} [input.editorPath] The file open in the editor, if any.
+ * @param {boolean} [input.canAsk] False when nothing can show a question.
+ * @returns {Interpretation}
+ */
+function interpret(input) {
+ const task = String(input.task || '');
+ const files = Array.isArray(input.files) ? input.files : [];
+ const canAsk = input.canAsk !== false;
+
+ const dangling = danglingReference(task, input);
+ if (dangling) return dangling;
+
+ // Nothing to check a path against. A session with no workspace listing is not
+ // evidence that a file is missing.
+ if (files.length === 0) return { kind: 'ok' };
+
+ // Nothing in the message presupposes an existing file, so a name that does not
+ // resolve is not yet evidence of anything.
+ if (!EXPECTS_EXISTING.test(task)) return { kind: 'ok' };
+
+ for (const referenced of referencedPaths(task)) {
+ if (existsIn(referenced, files)) continue;
+ // A name the user is inventing is theirs. Judged per path, not per message: a
+ // request can read one file and create another.
+ if (isBeingCreated(task, referenced)) continue;
+
+ const candidates = nearMatches(referenced, files);
+ if (candidates.length === 0) continue;
+
+ if (candidates.length === 1) {
+ const chosen = candidates[0];
+ logger.info(`"${referenced}" is not in this workspace; reading it as "${chosen}".`);
+ return {
+ kind: 'repaired',
+ // Both names, not a silent substitution. The user has to be able to see that
+ // their request was altered and disagree with it — and the model needs the
+ // real path, since it is the one that will resolve.
+ task: task.replace(referenced, chosen),
+ note: `The request named "${referenced}", which does not exist here. Read as "${chosen}".`,
+ };
+ }
+
+ if (!canAsk) {
+ logger.info(`"${referenced}" matches ${candidates.length} files and nothing can ask; leaving it alone.`);
+ return { kind: 'ok' };
+ }
+
+ return {
+ kind: 'ask',
+ clarification: clarification.build({
+ kind: 'ambiguous',
+ question: `There is no "${referenced}" in this project. Which file did you mean?`,
+ context: `${candidates.length} files have a similar name.`,
+ options: candidates.map((file, index) => ({
+ id: `file-${index + 1}`,
+ label: file,
+ effect: 'instruct',
+ // The first is recommended because `nearMatches` sorts by closeness, so it
+ // is the most similar name rather than an arbitrary one.
+ recommended: index === 0,
+ guidance: `The user means ${file}. Work on that file, and do not create "${referenced}".`,
+ })),
+ }),
+ };
+ }
+
+ return { kind: 'ok' };
+}
+
+/**
+ * "Fix it" with nothing to attach "it" to.
+ *
+ * The editor is checked first because it is nearly always the answer: a user typing
+ * "fix it" is looking at the thing they mean. Only a request with no conversation, no
+ * open file, and nothing recently changed is genuinely unanswerable.
+ *
+ * @param {string} task
+ * @param {object} input
+ * @returns {Interpretation | null}
+ */
+function danglingReference(task, input) {
+ const match = DANGLING_REFERENCE.exec(task);
+ if (!match) return null;
+
+ const conversation = Array.isArray(input.conversation) ? input.conversation : [];
+ // An earlier turn is an antecedent, and the context builder already carries it. This
+ // module has nothing to add to a message that is only short.
+ if (conversation.length > 0) return null;
+
+ const verb = match[1].toLowerCase();
+
+ if (input.editorPath) {
+ logger.info(`"${task.trim()}" names nothing; taking it as the open file, ${input.editorPath}.`);
+ return {
+ kind: 'repaired',
+ task: `${task.trim()} — the file open in the editor, ${input.editorPath}`,
+ note: `The request did not say what to ${verb}. Took it to mean the open file, ${input.editorPath}.`,
+ };
+ }
+
+ if (input.canAsk === false) return { kind: 'ok' };
+
+ const files = Array.isArray(input.files) ? input.files : [];
+ // Offered rather than assumed. With no open file and no conversation there is no
+ // evidence at all, and picking one of these would be a guess wearing a decision's
+ // clothes.
+ const offer = files.slice(0, 2);
+ if (offer.length < 1) return { kind: 'ok' };
+
+ return {
+ kind: 'ask',
+ clarification: clarification.build({
+ kind: 'ambiguous',
+ question: `What would you like me to ${verb}?`,
+ context: 'This is the first message in the session, so there is nothing for "it" to refer to yet.',
+ options: [
+ ...offer.map((file, index) => ({
+ id: `file-${index + 1}`,
+ label: file,
+ effect: /** @type {const} */ ('instruct'),
+ recommended: index === 0,
+ guidance: `The user means ${file}.`,
+ })),
+ {
+ id: 'describe',
+ label: 'Something else — I will say',
+ detail: 'Type what you want changed.',
+ effect: /** @type {const} */ ('instruct'),
+ },
+ ],
+ }),
+ };
+}
+
+module.exports = {
+ interpret,
+ referencedPaths,
+ nearMatches,
+ existsIn,
+ danglingReference,
+ editDistance,
+ nameSimilarity,
+ TYPO_THRESHOLD,
+ MAX_TYPO_DISTANCE,
+ MAX_CANDIDATES,
+ EXPECTS_EXISTING,
+ CREATES,
+};
diff --git a/app/core/memoryStore.js b/app/core/memoryStore.js
index 7d9d60f..fe93a66 100644
--- a/app/core/memoryStore.js
+++ b/app/core/memoryStore.js
@@ -577,17 +577,30 @@ class MemoryStore {
/** @type {Set} */
const picked = new Set();
- // Related first, newest of them first, so a long history does not fill the window
- // with the earliest thing it ever matched.
- for (let index = this.entries.length - 1; index >= 0 && picked.size < limit; index -= 1) {
+ // How many of the subject's own tokens each entry shares. Counted rather than
+ // treated as a yes/no, because "any one token matches" puts a note that mentions
+ // `src` level with the note that names `src/hooks/useTodos.js` and the identifier
+ // inside it — and on a recall window of five, the weak match displacing the strong
+ // one is the whole failure this selector exists to prevent.
+ /** @type {Array<{index: number, score: number}>} */
+ const scored = [];
+ for (let index = this.entries.length - 1; index >= 0; index -= 1) {
// eslint-disable-next-line security/detect-object-injection -- numeric loop index.
const words = recallTokens(this.entries[index]);
+ let score = 0;
for (const word of words) {
- if (wanted.has(word)) {
- picked.add(index);
- break;
- }
+ if (wanted.has(word)) score += 1;
}
+ if (score > 0) scored.push({ index, score });
+ }
+
+ // Strongest first; newest breaks a tie, so a long history does not fill the window
+ // with the earliest thing it ever matched. `scored` is already newest-first, and
+ // `sort` is stable, so the tie-break needs no comparator of its own.
+ scored.sort((a, b) => b.score - a.score);
+ for (const { index } of scored) {
+ if (picked.size >= limit) break;
+ picked.add(index);
}
// Then recency, for the remainder.
diff --git a/app/features/chatTab.js b/app/features/chatTab.js
index b3615bc..2738e67 100644
--- a/app/features/chatTab.js
+++ b/app/features/chatTab.js
@@ -77,6 +77,16 @@ class ChatTab {
*/
this._detachedAt = null;
+ /**
+ * The question this tab is currently blocked on, if any.
+ *
+ * The run holding the other end of `resolve` cannot proceed until it is called, so
+ * every path that removes the card has to settle it — see `_cancelClarification`.
+ *
+ * @type {{id: string, resolve: (answer: object | null) => void} | null}
+ */
+ this._pendingClarification = null;
+
/** @type {vscode.WebviewPanel | null} */
this.panel = null;
/** @type {AgentSession | null} */
@@ -246,6 +256,8 @@ class ChatTab {
return this._attachImage();
case 'detach':
return this._detach(message);
+ case 'clarify':
+ return this._answerClarification(message);
default:
logger.warn(`Ignored unknown webview message: ${String(type)}`);
return undefined;
@@ -257,6 +269,84 @@ class ChatTab {
if (this.panel) this.panel.webview.postMessage(message);
}
+ /**
+ * Put a question to the user and wait for the card to come back answered.
+ *
+ * ## The two ways this must not hang
+ *
+ * A run is blocked while this promise is pending, so every way out of the panel has
+ * to resolve it. Closing the tab disposes the panel, which fires `onDidDispose` and
+ * settles this as cancelled (see `_retire`); pressing Stop aborts the session, which
+ * lands in the same place. Without both, a user who closes a tab mid-question leaves
+ * an agent session pinned on a promise nothing will ever resolve — holding its lane
+ * in the turn queue against every other tab.
+ *
+ * Only one question can be outstanding at a time, which is a property of the caller:
+ * `agentSession` asks and awaits inside a single loop step. A second arriving while
+ * one is pending is a bug, so the first is cancelled rather than silently dropped.
+ *
+ * @param {import('../agent/clarification').Clarification} request
+ * @returns {Promise}
+ * @private
+ */
+ _askUser(request) {
+ if (!this.panel) return Promise.resolve(null);
+
+ if (this._pendingClarification) {
+ logger.warn('A second question arrived while one was still open; cancelling the first.');
+ this._pendingClarification.resolve({ id: this._pendingClarification.id, cancelled: true });
+ }
+
+ return new Promise((resolve) => {
+ this._pendingClarification = { id: request.id, resolve };
+ this._post({ type: 'clarify', request });
+ });
+ }
+
+ /**
+ * The user answered — or the card was dismissed.
+ *
+ * @param {object} message
+ * @private
+ */
+ _answerClarification(message) {
+ const pending = this._pendingClarification;
+ if (!pending) {
+ logger.warn('An answer arrived with no question outstanding; ignoring it.');
+ return;
+ }
+ // Ids are matched so a stale card from a previous turn cannot answer the current
+ // question. The webview is trusted to render, never to decide what it is replying to.
+ if (message.id && message.id !== pending.id) {
+ logger.warn('An answer arrived for a question that is no longer open; ignoring it.');
+ return;
+ }
+
+ this._pendingClarification = null;
+ pending.resolve({
+ id: pending.id,
+ optionId: typeof message.optionId === 'string' ? message.optionId : undefined,
+ text: typeof message.text === 'string' ? message.text : undefined,
+ cancelled: message.cancelled === true,
+ });
+ }
+
+ /**
+ * Settle any outstanding question as cancelled.
+ *
+ * Called from every path that takes the panel away from the user mid-run. Safe to
+ * call when nothing is pending.
+ *
+ * @private
+ */
+ _cancelClarification() {
+ if (!this._pendingClarification) return;
+ logger.info('A question was still open when the session ended; treating it as cancelled.');
+ const pending = this._pendingClarification;
+ this._pendingClarification = null;
+ pending.resolve({ id: pending.id, cancelled: true });
+ }
+
/** @private */
async _sendInit() {
// Restored before the first paint, so a resumed session shows its conversation
@@ -605,6 +695,10 @@ class ChatTab {
environment: this.app.environment,
stepSessions: this.stepSessions,
images: images.map((image) => image.base64),
+ // How the session reaches the user when it is stuck or the request was
+ // ambiguous. Bound to this tab, so the question appears in the conversation it
+ // came from rather than in whichever tab happens to be focused.
+ onClarify: (request) => this._askUser(request),
});
try {
@@ -633,6 +727,9 @@ class ChatTab {
logger.error(`Chat turn failed: ${message}`);
this._post({ type: 'error', message });
} finally {
+ // A turn that threw, or finished while a question was somehow still open, must
+ // not leave the card up with nothing behind it.
+ this._cancelClarification();
this.session = null;
this._starting = false;
this._queueAbort = null;
@@ -682,6 +779,19 @@ class ChatTab {
type: 'status',
text: `Retrying step ${event.index}: ${String(event.reason || 'it did not land').slice(0, 120)}`,
});
+ // The card itself is posted by `_askUser`, which needs the promise. This only
+ // moves the status line, so a run that is waiting on someone does not look like a
+ // run that has hung.
+ case 'clarification':
+ return this._post({ type: 'status', text: 'Waiting for your answer…' });
+ case 'clarification-answered':
+ return this._post({ type: 'status', text: `You chose: ${String(event.label || '').slice(0, 80)}` });
+ // How the request was read, when that differed from what was typed. Shown as it
+ // happens rather than only in the summary — a user watching the agent open
+ // `main.js` after they typed `mian.js` should not have to wait until the end to
+ // find out why.
+ case 'interpretation':
+ return this._post({ type: 'status', text: String(event.note || '').slice(0, 160) });
default:
return undefined;
}
@@ -712,6 +822,11 @@ class ChatTab {
if (panel && this.panel && panel !== this.panel) return;
this.panel = null;
+ // Before anything else. The run below is allowed to continue in the background, and
+ // a background run blocked on a card that no longer exists would hold its lane in
+ // the turn queue forever — the one way this feature could wedge the whole extension.
+ this._cancelClarification();
+
if (!this.session) {
// Nothing has started. A closed tab must not keep the lane: without this, a tab
// closed while queued would hold its place and every other tab would sit behind a
@@ -736,6 +851,9 @@ class ChatTab {
/** Stop whatever this tab is doing, queued or running. */
cancel() {
+ // A session waiting on an answer is not watching its abort signal — it is parked on
+ // a promise. Settling the question is what lets it reach the signal at all.
+ this._cancelClarification();
if (this._queueAbort) this._queueAbort.abort();
if (this.session) this.session.cancel();
}
diff --git a/app/webview/components/clarificationCard.js b/app/webview/components/clarificationCard.js
new file mode 100644
index 0000000..eb37f5c
--- /dev/null
+++ b/app/webview/components/clarificationCard.js
@@ -0,0 +1,126 @@
+/**
+ * The card the agent shows when it stops to ask the user something.
+ *
+ * A run is *blocked* while this is on screen, which is what shapes the design. Every
+ * other component here reports on work that is happening; this one is the work not
+ * happening, so it has to be answerable at a glance:
+ *
+ * - The recommended option is first and marked, because the agent has the trace and
+ * the user has a card. Presenting four equal choices moves the decision without
+ * helping with it.
+ * - Every option says what happens next, not just what it is called.
+ * - There is always a way to type something else. The options are the agent's reading
+ * of a situation it has just demonstrated it does not fully understand.
+ * - Once answered the card stays, showing what was chosen. It is part of the record of
+ * the run — a summary that says "you told me to skip that" should have something to
+ * point at.
+ *
+ * Nothing here is built from a string of HTML: the question, the context and every
+ * label originate in the extension host but describe model output and file paths, and
+ * the rule in this folder is that model-adjacent text is data. See the frontend design
+ * notes, §6.
+ */
+
+/**
+ * @param {object} request The clarification, as built by `agent/clarification`.
+ * @param {(answer: object) => void} onAnswer
+ * @returns {HTMLElement}
+ */
+export function renderClarification(request, onAnswer) {
+ const wrapper = document.createElement('div');
+ wrapper.className = 'clarify';
+
+ const question = document.createElement('p');
+ question.className = 'clarify-question';
+ question.textContent = request.question;
+ wrapper.appendChild(question);
+
+ if (request.context) {
+ const context = document.createElement('p');
+ context.className = 'clarify-context';
+ context.textContent = request.context;
+ wrapper.appendChild(context);
+ }
+
+ const options = document.createElement('div');
+ options.className = 'clarify-options';
+
+ /** Answering twice would resolve a promise the host has already moved past. */
+ let answered = false;
+
+ /**
+ * @param {object} answer
+ * @param {string} chosenLabel
+ */
+ function answer(answer_, chosenLabel) {
+ if (answered) return;
+ answered = true;
+ onAnswer({ ...answer_, id: request.id });
+
+ // Replaced rather than disabled: a row of dead buttons reads as a card that failed,
+ // and this one succeeded.
+ const chosen = document.createElement('p');
+ chosen.className = 'clarify-chosen';
+ chosen.textContent = `You chose: ${chosenLabel}`;
+ options.replaceWith(chosen);
+ if (freeText.isConnected) freeText.remove();
+ }
+
+ for (const option of request.options || []) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ // The recommended option gets the send button's treatment — the one accent already
+ // used for "this is the action". A fifth accent would stop it meaning anything.
+ button.className = option.recommended ? 'send clarify-option' : 'control clarify-option';
+
+ const label = document.createElement('span');
+ label.className = 'clarify-label';
+ label.textContent = option.recommended ? `${option.label} — recommended` : option.label;
+ button.appendChild(label);
+
+ if (option.detail) {
+ const detail = document.createElement('span');
+ detail.className = 'clarify-detail';
+ detail.textContent = option.detail;
+ button.appendChild(detail);
+ }
+
+ button.addEventListener('click', () => answer({ optionId: option.id }, option.label));
+ options.appendChild(button);
+ }
+
+ wrapper.appendChild(options);
+
+ const freeText = document.createElement('div');
+ freeText.className = 'clarify-free';
+
+ const input = document.createElement('input');
+ input.type = 'text';
+ input.className = 'control clarify-input';
+ input.placeholder = 'Or tell me what to do instead…';
+ input.setAttribute('aria-label', 'Answer in your own words');
+
+ const submit = document.createElement('button');
+ submit.type = 'button';
+ submit.className = 'control';
+ submit.textContent = 'Send';
+
+ function sendTyped() {
+ const text = input.value.trim();
+ if (text) answer({ text }, text);
+ }
+
+ submit.addEventListener('click', sendTyped);
+ input.addEventListener('keydown', (event) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ sendTyped();
+ }
+ });
+
+ freeText.appendChild(input);
+ freeText.appendChild(submit);
+ wrapper.appendChild(freeText);
+
+ return wrapper;
+}
diff --git a/app/webview/main.js b/app/webview/main.js
index 0d16ea1..5a08bcf 100644
--- a/app/webview/main.js
+++ b/app/webview/main.js
@@ -10,6 +10,7 @@
import { createMessage, appendImages, TraceView, renderTodos, renderChanges } from './components/messageBubble.js';
import { ThinkingIndicator } from './components/thinkingIndicator.js';
import { renderPlanChecklist } from './components/planChecklist.js';
+import { renderClarification } from './components/clarificationCard.js';
import { render } from './components/markdown.js';
const vscode = acquireVsCodeApi();
@@ -241,6 +242,19 @@ const handlers = {
else state.body.appendChild(fresh);
},
+ // The agent has stopped and is waiting on an answer. The card goes into the running
+ // message rather than below it, so it sits with the work it is about — and it stays
+ // there once answered, as part of the record of the run.
+ clarify(msg) {
+ if (!state.body || !msg.request) return;
+ state.body.appendChild(
+ renderClarification(msg.request, (answer) => {
+ vscode.postMessage({ type: 'clarify', ...answer });
+ })
+ );
+ scrollToEnd();
+ },
+
action(msg) {
if (!state.trace) return;
state.trace.addAction(msg.step, msg.action);
diff --git a/app/webview/style.css b/app/webview/style.css
index 5f2eb96..eec0b02 100644
--- a/app/webview/style.css
+++ b/app/webview/style.css
@@ -671,3 +671,84 @@ select.control {
clip: rect(0 0 0 0);
white-space: nowrap;
}
+
+/*
+ The agent asking the user something.
+
+ Bordered and inset rather than accented: the run is stopped while this is up, and
+ the thing that needs to catch the eye is the *recommended option* inside it, not the
+ container. Giving the card the sunrise as well would put two competing marks in one
+ small area and leave neither meaning anything.
+*/
+.clarify {
+ margin: var(--sp-3) 0 0;
+ padding: var(--sp-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--surface);
+}
+
+.clarify-question {
+ margin: 0;
+ font-weight: 600;
+}
+
+.clarify-context {
+ margin: var(--sp-1) 0 0;
+ font-size: var(--fs-sm);
+ color: var(--muted);
+}
+
+.clarify-options {
+ display: flex;
+ flex-direction: column;
+ gap: var(--sp-2);
+ margin-top: var(--sp-3);
+}
+
+/*
+ Stacked, full width, and tall enough for two lines. A row of side-by-side buttons
+ truncates the labels — which are file paths often enough that the truncated part is
+ the part that distinguishes them.
+*/
+.clarify-option {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ width: 100%;
+ height: auto;
+ padding: var(--sp-2) var(--sp-3);
+ text-align: left;
+ white-space: normal;
+}
+
+.clarify-label {
+ font-weight: 600;
+}
+
+/*
+ Inherits colour rather than using --muted, because on the recommended option this
+ detail sits on the sunrise gradient where the muted grey is unreadable.
+*/
+.clarify-detail {
+ font-size: var(--fs-xs);
+ opacity: 0.85;
+}
+
+.clarify-free {
+ display: flex;
+ gap: var(--sp-2);
+ margin-top: var(--sp-3);
+}
+
+.clarify-input {
+ flex: 1;
+ min-width: 0;
+}
+
+.clarify-chosen {
+ margin: var(--sp-3) 0 0;
+ font-size: var(--fs-sm);
+ color: var(--muted);
+}
diff --git a/test/unit/askingTheUser.test.js b/test/unit/askingTheUser.test.js
new file mode 100644
index 0000000..4f2052a
--- /dev/null
+++ b/test/unit/askingTheUser.test.js
@@ -0,0 +1,250 @@
+'use strict';
+
+/**
+ * The 0.7.0 recovery path, end to end against a scripted Ollama and a real workspace.
+ *
+ * Three separate mechanisms meet in `AgentSession` and the interesting properties are
+ * all in how they compose, which is why these are here rather than split across the
+ * three unit files:
+ *
+ * - a request is read before it is routed (`core/commonSense`),
+ * - a failure that repeats escalates and eventually asks (`agent/errorRecovery`),
+ * - what the user answers changes the run and the checklist (`agent/clarification`).
+ *
+ * The property that matters most is the negative one: **a session with nothing to ask
+ * must never block.** Every path is exercised with `onClarify` absent as well as
+ * present, because that is the configuration a benchmark, a detached tab, and every
+ * pre-0.7.0 caller runs in.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const { AgentSession } = require('../../app/agent/agentSession');
+const { PermissionGate } = require('../../app/security/permissionGate');
+const { PermissionModes } = require('../../app/security/permissionModes');
+const { AuditLog } = require('../../app/security/auditLog');
+const { MemoryStore } = require('../../app/core/memoryStore');
+
+const TIER_B = { tier: 'B', strategy: 'react', label: 'Lite', model: 'llama3.2:1b' };
+
+/** @param {Array} replies */
+function scriptedClient(replies) {
+ return {
+ calls: 0,
+ bodies: /** @type {any[]} */ ([]),
+ async chat(body) {
+ this.bodies.push(body);
+ const reply = replies[Math.min(this.calls, replies.length - 1)];
+ this.calls += 1;
+ return { message: typeof reply === 'string' ? { content: reply } : reply };
+ },
+ };
+}
+
+const json = (action) => JSON.stringify(action);
+
+describe('asking the user', () => {
+ /** @type {string} */
+ let root;
+ /** @type {object[]} */
+ let events;
+
+ function makeSession(opts = {}) {
+ const modes = new PermissionModes({ initial: { autoEdit: true, autoApproveScripts: true } });
+ const gate = new PermissionGate({
+ workspaceRoot: root,
+ modes,
+ auditLog: new AuditLog(root),
+ confirm: async () => true,
+ });
+
+ return new AgentSession({
+ client: /** @type {any} */ (opts.client),
+ model: 'test-model',
+ capability: TIER_B,
+ gate,
+ workspaceRoot: root,
+ sessionId: '1',
+ memory: opts.memory,
+ onClarify: opts.onClarify,
+ });
+ }
+
+ /** @param {object} [options] */
+ function run(session, task, options = {}) {
+ events = [];
+ return session.run(task, { onEvent: (event) => events.push(event), ...options });
+ }
+
+ beforeEach(() => {
+ root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-ask-')));
+ fs.mkdirSync(path.join(root, 'src'), { recursive: true });
+ fs.writeFileSync(path.join(root, 'src', 'main.js'), 'export function main() {\n return 1;\n}\n');
+ fs.writeFileSync(path.join(root, 'README.md'), '# Test project\n');
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
+ });
+
+ describe('reading the request', () => {
+ it('opens the file the user meant, not the one they typed', async () => {
+ const client = scriptedClient([json({ action: 'read_file', path: 'src/main.js' }), json({ action: 'done' })]);
+ const session = makeSession({ client });
+
+ const result = await run(session, 'update mian.js to add a comment');
+
+ const note = events.find((event) => event.type === 'interpretation');
+ assert.ok(note, 'the reading was not reported to the panel');
+ assert.match(note.note, /mian\.js/);
+ assert.match(note.note, /src\/main\.js/);
+ // And the summary says so, so a user can disagree with it.
+ assert.match(result.summary, /How I read the request/);
+ });
+
+ it('records the reading in memory, so the same slip is not re-derived', async () => {
+ const memory = new MemoryStore(root, 1);
+ const client = scriptedClient([json({ action: 'done' })]);
+ const session = makeSession({ client, memory });
+
+ await run(session, 'update mian.js to add a comment');
+ await memory.flush();
+
+ assert.ok((await memory.readAll()).some((entry) => entry.includes('mian.js')));
+ });
+
+ it('leaves a request that names a real file exactly as it was', async () => {
+ const client = scriptedClient([json({ action: 'done' })]);
+ const session = makeSession({ client });
+
+ const result = await run(session, 'read src/main.js');
+ assert.strictEqual(events.some((event) => event.type === 'interpretation'), false);
+ assert.doesNotMatch(result.summary, /How I read the request/);
+ });
+
+ it('asks which file when two are equally plausible', async () => {
+ fs.writeFileSync(path.join(root, 'src', 'todo.js'), 'export const a = 1;\n');
+ fs.writeFileSync(path.join(root, 'src', 'todos.js'), 'export const b = 2;\n');
+
+ /** @type {object | null} */
+ let asked = null;
+ const client = scriptedClient([json({ action: 'done' })]);
+ const session = makeSession({
+ client,
+ onClarify: async (request) => {
+ asked = request;
+ return { id: request.id, optionId: request.options[0].id };
+ },
+ });
+
+ await run(session, 'update todoo.js');
+
+ assert.ok(asked, 'the run did not ask');
+ assert.ok(asked.options.length >= 2 && asked.options.length <= 4);
+ assert.strictEqual(asked.options.filter((option) => option.recommended).length, 1);
+ });
+
+ it('does not ask when there is nobody to ask', async () => {
+ fs.writeFileSync(path.join(root, 'src', 'todo.js'), 'export const a = 1;\n');
+ fs.writeFileSync(path.join(root, 'src', 'todos.js'), 'export const b = 2;\n');
+
+ const client = scriptedClient([json({ action: 'done' })]);
+ const session = makeSession({ client });
+
+ // The assertion is that this resolves at all.
+ const result = await run(session, 'update todoo.js');
+ assert.ok(result.summary);
+ });
+
+ it('stops before starting when the user says stop', async () => {
+ fs.writeFileSync(path.join(root, 'src', 'todo.js'), 'export const a = 1;\n');
+ fs.writeFileSync(path.join(root, 'src', 'todos.js'), 'export const b = 2;\n');
+
+ const client = scriptedClient([json({ action: 'done' })]);
+ const session = makeSession({
+ client,
+ onClarify: async (request) => ({ id: request.id, cancelled: true }),
+ });
+
+ const result = await run(session, 'update todoo.js');
+
+ assert.strictEqual(result.stopReason, 'cancelled');
+ assert.strictEqual(client.calls, 0, 'nothing should have been sent to the model');
+ });
+ });
+
+ describe('a failure that will not go away', () => {
+ /** A script that always fails the same way. */
+ function failingScript() {
+ return json({ action: 'run_script', command: 'node src/broken.js' });
+ }
+
+ it('tells the model to change approach when there is nobody to ask', async () => {
+ const client = scriptedClient([failingScript(), failingScript(), json({ action: 'done' })]);
+ const session = makeSession({ client });
+
+ await run(session, 'run the broken script');
+
+ // The second failure's observation should carry the escalation. It is the only
+ // channel back to the model on a Tier B loop.
+ const observations = events.filter((event) => event.type === 'observation');
+ const escalated = observations.some((event) => /same failure as before/i.test(event.result.observation));
+ assert.ok(escalated, 'the repeat was never called out to the model');
+ });
+
+ it('asks on the second failure, which is before the loop gives up', async () => {
+ // `reactLoop.REPEAT_LIMIT` is 2: the loop ends a run once the model has sent the
+ // same action twice. This is the last point at which asking can change anything.
+ /** @type {object | null} */
+ let asked = null;
+ const client = scriptedClient([failingScript(), failingScript(), json({ action: 'done' })]);
+ const session = makeSession({
+ client,
+ onClarify: async (request) => {
+ asked = request;
+ return { id: request.id, text: 'the entry point is src/main.js, not src/broken.js' };
+ },
+ });
+
+ const result = await run(session, 'run the broken script');
+
+ assert.ok(asked, 'the run never asked, having failed twice');
+ assert.strictEqual(asked.kind, 'error');
+ assert.match(result.summary, /What you told me when I asked/);
+ assert.match(result.summary, /src\/main\.js, not src\/broken\.js/);
+ });
+
+ it('never blocks a run that has nobody to ask', async () => {
+ const client = scriptedClient([
+ failingScript(),
+ failingScript(),
+ failingScript(),
+ failingScript(),
+ json({ action: 'done' }),
+ ]);
+ const session = makeSession({ client });
+
+ const result = await run(session, 'run the broken script');
+ assert.ok(result.summary);
+ });
+
+ it('reports what it kept hitting rather than only what it completed', async () => {
+ const client = scriptedClient([failingScript(), failingScript(), json({ action: 'done' })]);
+ const session = makeSession({ client });
+
+ const result = await run(session, 'run the broken script');
+ assert.match(result.summary, /What kept going wrong/);
+ });
+
+ it('says nothing extra about a run where nothing went wrong twice', async () => {
+ const client = scriptedClient([json({ action: 'read_file', path: 'src/main.js' }), json({ action: 'done' })]);
+ const session = makeSession({ client });
+
+ const result = await run(session, 'read the main file');
+ assert.doesNotMatch(result.summary, /What kept going wrong/);
+ });
+ });
+});
diff --git a/test/unit/clarification.test.js b/test/unit/clarification.test.js
new file mode 100644
index 0000000..6375302
--- /dev/null
+++ b/test/unit/clarification.test.js
@@ -0,0 +1,135 @@
+'use strict';
+
+const assert = require('assert');
+
+const clarification = require('../../app/agent/clarification');
+
+describe('clarification.build', () => {
+ /** @param {number} count */
+ function options(count) {
+ return Array.from({ length: count }, (_, index) => ({
+ id: `o${index + 1}`,
+ label: `Option ${index + 1}`,
+ effect: 'instruct',
+ recommended: index === 0,
+ }));
+ }
+
+ it('builds a question with its options', () => {
+ const request = clarification.build({
+ kind: 'error',
+ question: 'How should I carry on?',
+ context: 'It failed three times.',
+ options: options(3),
+ });
+
+ assert.strictEqual(request.question, 'How should I carry on?');
+ assert.strictEqual(request.options.length, 3);
+ assert.ok(request.id);
+ });
+
+ it('refuses fewer than two options, because that is not a choice', () => {
+ assert.throws(() => clarification.build({ question: 'Which?', options: options(1) }), /between 2 and 4/);
+ });
+
+ it('refuses more than four, because that is a second task for the user', () => {
+ assert.throws(() => clarification.build({ question: 'Which?', options: options(5) }), /between 2 and 4/);
+ });
+
+ it('refuses a set that recommends two things at once', () => {
+ const both = options(2).map((option) => ({ ...option, recommended: true }));
+ assert.throws(() => clarification.build({ question: 'Which?', options: both }), /exactly one/);
+ });
+
+ it('promotes the first option rather than failing a run with no recommendation', () => {
+ // A blocked run must not also throw. "Here are your choices" with nothing marked is
+ // a worse question, not an unanswerable one.
+ const none = options(2).map((option) => ({ ...option, recommended: false }));
+ const request = clarification.build({ question: 'Which?', options: none });
+ assert.strictEqual(request.options[0].recommended, true);
+ assert.strictEqual(request.options[1].recommended, false);
+ });
+
+ it('always allows free text, whatever the caller asked for', () => {
+ // Not settable on purpose: the options are the agent's reading of a situation it
+ // has just shown it does not fully understand.
+ const request = clarification.build({ question: 'Which?', options: options(2), allowFreeText: false });
+ assert.strictEqual(request.allowFreeText, true);
+ });
+
+ it('trims a question and a label down to what a card can hold', () => {
+ const request = clarification.build({
+ question: 'q'.repeat(400),
+ options: [
+ { label: 'l'.repeat(200), effect: 'skip', recommended: true },
+ { label: 'Second', effect: 'stop' },
+ ],
+ });
+
+ assert.ok(request.question.length <= clarification.MAX_QUESTION_CHARS);
+ assert.ok(request.options[0].label.length <= clarification.MAX_LABEL_CHARS);
+ assert.ok(request.question.endsWith('…'));
+ });
+
+ it('refuses a question with nothing in it', () => {
+ assert.throws(() => clarification.build({ question: ' ', options: options(2) }), /needs a question/);
+ });
+
+ it('gives every question its own id', () => {
+ const a = clarification.build({ question: 'One?', options: options(2) });
+ const b = clarification.build({ question: 'Two?', options: options(2) });
+ assert.notStrictEqual(a.id, b.id);
+ });
+});
+
+describe('clarification.resolve', () => {
+ const request = clarification.build({
+ kind: 'error',
+ question: 'How should I carry on?',
+ options: [
+ { id: 'skip', label: 'Skip it', effect: 'skip', recommended: true, guidance: 'Leave it alone.' },
+ { id: 'stop', label: 'Stop', effect: 'stop' },
+ ],
+ });
+
+ it('resolves a chosen option to its effect and guidance', () => {
+ const resolved = clarification.resolve(request, { id: request.id, optionId: 'skip' });
+ assert.strictEqual(resolved.effect, 'skip');
+ assert.strictEqual(resolved.guidance, 'Leave it alone.');
+ });
+
+ it('falls back to the label when an option carries no guidance', () => {
+ const resolved = clarification.resolve(request, { id: request.id, optionId: 'stop' });
+ assert.strictEqual(resolved.guidance, 'Stop');
+ });
+
+ it('prefers what the user typed over what they clicked', () => {
+ // They meant the words. The option may have been clicked on the way to the box.
+ const resolved = clarification.resolve(request, {
+ id: request.id,
+ optionId: 'skip',
+ text: 'use the other config file',
+ });
+ assert.strictEqual(resolved.effect, 'instruct');
+ assert.strictEqual(resolved.guidance, 'use the other config file');
+ });
+
+ it('stops on a cancelled answer', () => {
+ assert.strictEqual(clarification.resolve(request, { id: request.id, cancelled: true }).effect, 'stop');
+ });
+
+ it('stops when there is no answer at all', () => {
+ assert.strictEqual(clarification.resolve(request, null).effect, 'stop');
+ });
+
+ it('stops rather than guessing when the option is unknown', () => {
+ // The run is blocked, so this has to resolve to something — and picking an option
+ // on the user's behalf is the one thing it must not do.
+ assert.strictEqual(clarification.resolve(request, { id: request.id, optionId: 'nope' }).effect, 'stop');
+ });
+
+ it('ignores whitespace-only free text rather than treating it as an instruction', () => {
+ const resolved = clarification.resolve(request, { id: request.id, optionId: 'skip', text: ' ' });
+ assert.strictEqual(resolved.effect, 'skip');
+ });
+});
diff --git a/test/unit/commonSense.test.js b/test/unit/commonSense.test.js
new file mode 100644
index 0000000..a00879d
--- /dev/null
+++ b/test/unit/commonSense.test.js
@@ -0,0 +1,166 @@
+'use strict';
+
+const assert = require('assert');
+
+const commonSense = require('../../app/core/commonSense');
+
+const FILES = [
+ 'src/main.js',
+ 'src/api.js',
+ 'src/hooks/useTodos.js',
+ 'src/components/TodoInput.jsx',
+ 'README.md',
+ 'vite.config.js',
+ 'package.json',
+];
+
+describe('commonSense.referencedPaths', () => {
+ it('finds the filenames in a request', () => {
+ assert.deepStrictEqual(commonSense.referencedPaths('update src/main.js and README.md'), [
+ 'src/main.js',
+ 'README.md',
+ ]);
+ });
+
+ it('is not fooled by prose that names no file', () => {
+ assert.deepStrictEqual(commonSense.referencedPaths('update the parser so it handles decimals'), []);
+ });
+
+ it('ignores a version number', () => {
+ assert.deepStrictEqual(commonSense.referencedPaths('upgrade to react 18.2'), []);
+ });
+
+ it('reads a backtick-quoted path', () => {
+ assert.deepStrictEqual(commonSense.referencedPaths('fix `src/main.js` please'), ['src/main.js']);
+ });
+
+ it('normalises a windows separator', () => {
+ assert.deepStrictEqual(commonSense.referencedPaths('edit src\\main.js'), ['src/main.js']);
+ });
+});
+
+describe('commonSense.nearMatches', () => {
+ it('finds the file a transposition was meant to name', () => {
+ assert.deepStrictEqual(commonSense.nearMatches('mian.js', FILES), ['src/main.js']);
+ });
+
+ it('matches on the basename, not the folders around it', () => {
+ assert.deepStrictEqual(commonSense.nearMatches('app/usetodo.js', FILES), ['src/hooks/useTodos.js']);
+ });
+
+ it('does not call a right name in a wrong folder a typo', () => {
+ // Nothing was misspelled. `existsIn` claims this one, and the model resolves it
+ // from the workspace listing it is already given.
+ assert.strictEqual(commonSense.existsIn('app/useTodos.js', FILES), true);
+ assert.strictEqual(commonSense.interpret({ task: 'update app/useTodos.js', files: FILES }).kind, 'ok');
+ });
+
+ it('refuses to call a different extension a typo', () => {
+ // `main.js` and `main.css` score high on characters and are never the same mistake.
+ assert.deepStrictEqual(commonSense.nearMatches('main.css', FILES), []);
+ });
+
+ it('refuses to judge a name too short to judge', () => {
+ // `a.js` and `b.js` are one edit apart and have nothing to do with each other.
+ assert.deepStrictEqual(commonSense.nearMatches('a.js', ['b.js', 'c.js']), []);
+ });
+
+ it('finds nothing for a genuinely new name', () => {
+ assert.deepStrictEqual(commonSense.nearMatches('parser.js', FILES), []);
+ });
+});
+
+describe('commonSense.interpret — a misspelled file', () => {
+ it('reads a typo as the file that exists', () => {
+ const reading = commonSense.interpret({ task: 'update mian.js to add a header', files: FILES });
+
+ assert.strictEqual(reading.kind, 'repaired');
+ assert.strictEqual(reading.task, 'update src/main.js to add a header');
+ // Both names, so the user can see their request was altered and disagree with it.
+ assert.match(reading.note, /mian\.js/);
+ assert.match(reading.note, /src\/main\.js/);
+ });
+
+ it('leaves a name the user is inventing alone', () => {
+ assert.strictEqual(commonSense.interpret({ task: 'create mian.js', files: FILES }).kind, 'ok');
+ assert.strictEqual(commonSense.interpret({ task: 'add a new file called mian.js', files: FILES }).kind, 'ok');
+ });
+
+ it('is not put off by the word "add" later in the sentence', () => {
+ // The verb that governs the filename is the one in front of it. A whole-message
+ // test reads this as a creation and silently declines to help.
+ const reading = commonSense.interpret({ task: 'update mian.js to add a header', files: FILES });
+ assert.strictEqual(reading.kind, 'repaired');
+ });
+
+ it('corrects only the half of the request that is wrong', () => {
+ const reading = commonSense.interpret({ task: 'read mian.js and create parser.js', files: FILES });
+ assert.strictEqual(reading.kind, 'repaired');
+ assert.match(reading.task, /src\/main\.js/);
+ // The invented name survives untouched.
+ assert.match(reading.task, /parser\.js/);
+ });
+
+ it('says nothing about a file that simply does not exist yet', () => {
+ assert.strictEqual(commonSense.interpret({ task: 'update parser.js', files: FILES }).kind, 'ok');
+ });
+
+ it('says nothing when the file is right', () => {
+ assert.strictEqual(commonSense.interpret({ task: 'read src/main.js', files: FILES }).kind, 'ok');
+ });
+
+ it('matches a bare filename against its full path', () => {
+ // A user naming a file rarely types its folders.
+ assert.strictEqual(commonSense.interpret({ task: 'read useTodos.js', files: FILES }).kind, 'ok');
+ });
+
+ it('asks when two files are equally plausible', () => {
+ const files = ['src/todo.js', 'src/todos.js'];
+ const reading = commonSense.interpret({ task: 'update todoo.js', files });
+
+ assert.strictEqual(reading.kind, 'ask');
+ assert.strictEqual(reading.clarification.options.length, 2);
+ assert.ok(reading.clarification.options.some((option) => option.label === 'src/todo.js'));
+ });
+
+ it('leaves an ambiguous name alone when nothing can ask', () => {
+ const files = ['src/todo.js', 'src/todos.js'];
+ const reading = commonSense.interpret({ task: 'update todoo.js', files, canAsk: false });
+ assert.strictEqual(reading.kind, 'ok');
+ });
+
+ it('says nothing without a workspace listing to check against', () => {
+ assert.strictEqual(commonSense.interpret({ task: 'update mian.js', files: [] }).kind, 'ok');
+ });
+});
+
+describe('commonSense.interpret — a reference to nothing', () => {
+ it('takes "fix it" as the file the user is looking at', () => {
+ const reading = commonSense.interpret({ task: 'fix it', files: FILES, editorPath: 'src/api.js' });
+
+ assert.strictEqual(reading.kind, 'repaired');
+ assert.match(reading.task, /src\/api\.js/);
+ });
+
+ it('leaves it alone when an earlier turn gives "it" something to mean', () => {
+ const reading = commonSense.interpret({
+ task: 'fix it',
+ files: FILES,
+ conversation: [{ role: 'user', text: 'the parser drops decimals' }],
+ });
+ assert.strictEqual(reading.kind, 'ok');
+ });
+
+ it('asks when there is nothing at all for "it" to refer to', () => {
+ const reading = commonSense.interpret({ task: 'fix it', files: FILES });
+
+ assert.strictEqual(reading.kind, 'ask');
+ assert.match(reading.clarification.question, /fix/);
+ assert.ok(reading.clarification.options.length >= 2);
+ });
+
+ it('does not fire on a message that merely starts with the same verb', () => {
+ assert.strictEqual(commonSense.interpret({ task: 'fix the parser in src/main.js', files: FILES }).kind, 'ok');
+ assert.strictEqual(commonSense.interpret({ task: 'update items in the list', files: FILES }).kind, 'ok');
+ });
+});
diff --git a/test/unit/errorRecovery.test.js b/test/unit/errorRecovery.test.js
new file mode 100644
index 0000000..6d2f95c
--- /dev/null
+++ b/test/unit/errorRecovery.test.js
@@ -0,0 +1,180 @@
+'use strict';
+
+const assert = require('assert');
+
+const { ErrorRecovery, signatureOf, headline } = require('../../app/agent/errorRecovery');
+
+/**
+ * @param {object} [overrides]
+ * @returns {import('../../app/agent/errorRecovery').Failure}
+ */
+function failure(overrides = {}) {
+ return {
+ action: 'run_script',
+ command: 'node src/main.js',
+ observation: "ReferenceError: addTodo is not defined\n at Object. (/app/src/main.js:4:11)",
+ diagnosed: false,
+ ...overrides,
+ };
+}
+
+describe('errorRecovery.signatureOf', () => {
+ it('treats the same failure at different lines as the same failure', () => {
+ const a = signatureOf('run_script', 'ReferenceError: x is not defined at /app/src/main.js:4:11');
+ const b = signatureOf('run_script', 'ReferenceError: x is not defined at /app/src/main.js:40:2');
+ assert.strictEqual(a, b);
+ });
+
+ it('treats the same failure on two machines as the same failure', () => {
+ const posix = signatureOf('run_script', "Cannot find module '/home/jay/app/src/x.js'");
+ const win = signatureOf('run_script', "Cannot find module 'C:\\Users\\jay\\app\\src\\x.js'");
+ assert.strictEqual(posix, win);
+ });
+
+ it('keeps genuinely different failures apart', () => {
+ assert.notStrictEqual(
+ signatureOf('run_script', 'ReferenceError: addTodo is not defined'),
+ signatureOf('run_script', 'ReferenceError: removeTodo is not defined')
+ );
+ });
+
+ it('separates the same message from two different tools', () => {
+ assert.notStrictEqual(signatureOf('run_script', 'EACCES'), signatureOf('write_file', 'EACCES'));
+ });
+});
+
+describe('errorRecovery.headline', () => {
+ it('takes the sentence, not the stack frame under it', () => {
+ const text = "TypeError: todos.map is not a function\n at App (/app/src/App.jsx:12:20)\n at renderWithHooks";
+ assert.strictEqual(headline(text), 'TypeError: todos.map is not a function');
+ });
+
+ it('skips a python traceback header to reach the error', () => {
+ const text = 'Traceback (most recent call last):\n File "main.py", line 3\nNameError: name \'foo\' is not defined';
+ assert.match(headline(text), /NameError/);
+ });
+});
+
+describe('ErrorRecovery.observe', () => {
+ it('says nothing the first time a diagnosed failure appears', () => {
+ // scriptDiagnosis already gave it a sentence; a second one saying the same thing
+ // differently is noise in a context window that has none spare.
+ const recovery = new ErrorRecovery();
+ const decision = recovery.observe(failure({ diagnosed: true }));
+ assert.strictEqual(decision.kind, 'none');
+ });
+
+ it('tells the model to read the error when nothing recognised it', () => {
+ const recovery = new ErrorRecovery();
+ const decision = recovery.observe(failure());
+ assert.strictEqual(decision.kind, 'guidance');
+ assert.match(decision.guidance, /read the error above literally/i);
+ });
+
+ it('names the count once the same failure comes back with nobody to ask', () => {
+ const recovery = new ErrorRecovery({ canAsk: false });
+ recovery.observe(failure());
+ const second = recovery.observe(failure());
+
+ assert.strictEqual(second.kind, 'guidance');
+ assert.strictEqual(second.count, 2);
+ assert.match(second.guidance, /same failure as before/i);
+ assert.match(second.guidance, /2 times/);
+ });
+
+ it('asks the user the second time, which is the last moment it can', () => {
+ // `reactLoop.REPEAT_LIMIT` is 2, so the loop ends the run after two identical
+ // actions. Waiting for a third would mean never asking at all.
+ const recovery = new ErrorRecovery();
+ recovery.observe(failure());
+ const second = recovery.observe(failure());
+
+ assert.strictEqual(second.kind, 'ask');
+ assert.ok(second.clarification);
+ assert.ok(second.clarification.options.length >= 2 && second.clarification.options.length <= 4);
+ // Recommending another attempt would recommend the thing that has already not
+ // worked, with guidance in between.
+ const recommended = second.clarification.options.find((option) => option.recommended);
+ assert.strictEqual(recommended.effect, 'skip');
+ });
+
+ it('never asks when there is nobody to ask', () => {
+ const recovery = new ErrorRecovery({ canAsk: false });
+ for (let i = 0; i < 6; i += 1) {
+ const decision = recovery.observe(failure());
+ assert.notStrictEqual(decision.kind, 'ask', 'a session with no panel must not block on a question');
+ }
+ });
+
+ it('asks only once about the same wall', () => {
+ const recovery = new ErrorRecovery();
+ for (let i = 0; i < 2; i += 1) recovery.observe(failure());
+ const third = recovery.observe(failure());
+ assert.strictEqual(third.kind, 'guidance');
+ });
+
+ it('leaves a refused action alone — that is the permission model, not a wall', () => {
+ const recovery = new ErrorRecovery();
+ for (let i = 0; i < 5; i += 1) {
+ const decision = recovery.observe(
+ failure({ action: 'delete_file', error: 'PERMISSION_DENIED', observation: 'The user declined.' })
+ );
+ assert.strictEqual(decision.kind, 'none');
+ }
+ });
+
+ it('repeats the user’s answer back rather than asking again', () => {
+ const recovery = new ErrorRecovery();
+ for (let i = 0; i < 2; i += 1) recovery.observe(failure());
+ recovery.recordAnswer(failure(), 'import addTodo from ./hooks/useTodos');
+
+ const next = recovery.observe(failure());
+ assert.strictEqual(next.kind, 'guidance');
+ assert.match(next.guidance, /the user about this and they said/i);
+ assert.match(next.guidance, /useTodos/);
+ });
+
+ it('does not remember a failure that only happened once', () => {
+ // A single failure the next step fixes is noise, and the recall window is small
+ // enough that noise costs something.
+ const recovery = new ErrorRecovery();
+ assert.strictEqual(recovery.observe(failure()).note, '');
+ });
+
+ it('remembers one that keeps happening', () => {
+ const recovery = new ErrorRecovery();
+ recovery.observe(failure());
+ const second = recovery.observe(failure());
+ assert.match(second.note, /failed 2 times/);
+ assert.match(second.note, /addTodo is not defined/);
+ });
+
+ it('counts two different failures separately', () => {
+ const recovery = new ErrorRecovery();
+ recovery.observe(failure());
+ const other = recovery.observe(failure({ observation: 'EADDRINUSE: address already in use' }));
+ assert.strictEqual(other.count, 1);
+ });
+});
+
+describe('ErrorRecovery.persistent', () => {
+ it('reports what a run kept hitting, worst first', () => {
+ const recovery = new ErrorRecovery({ canAsk: false });
+ recovery.observe(failure());
+ recovery.observe(failure());
+ recovery.observe(failure());
+ recovery.observe(failure({ observation: 'EADDRINUSE: address already in use' }));
+ recovery.observe(failure({ observation: 'EADDRINUSE: address already in use' }));
+
+ const stuck = recovery.persistent();
+ assert.strictEqual(stuck.length, 2);
+ assert.strictEqual(stuck[0].count, 3);
+ assert.match(stuck[0].headline, /addTodo/);
+ });
+
+ it('says nothing about a run that hit nothing twice', () => {
+ const recovery = new ErrorRecovery({ canAsk: false });
+ recovery.observe(failure());
+ assert.deepStrictEqual(recovery.persistent(), []);
+ });
+});
diff --git a/test/unit/memoryStore.test.js b/test/unit/memoryStore.test.js
index 8e0dc96..e0cb34b 100644
--- a/test/unit/memoryStore.test.js
+++ b/test/unit/memoryStore.test.js
@@ -392,6 +392,26 @@ describe('superseding stale entries', () => {
assert.deepStrictEqual(recalled, await store.readRecent(2));
});
+ it('prefers the note that shares more of the subject than the one sharing less', async () => {
+ // `vite.config.js` and `src/hooks/useTodos.js` both match a subject naming
+ // useTodos and vite — but only one of them is what the step is about. On a
+ // one-entry window the count is the only thing that can separate them, and
+ // before 0.7.0 any single shared token was enough, so the newest weak match won.
+ const recalled = await store.readRelevant('Wire useTodos into the todos hook for addTodo', 1);
+
+ assert.strictEqual(recalled.length, 1);
+ assert.ok(
+ recalled[0].includes('useTodos.js'),
+ `expected the strongest match, got: ${recalled[0]}`
+ );
+ });
+
+ it('still breaks a tie by recency', async () => {
+ // Two notes, one shared token each ("npm"): the newer one wins.
+ const recalled = await store.readRelevant('npm', 1);
+ assert.ok(recalled[0].includes('npm run build'), `expected the newer npm note, got: ${recalled[0]}`);
+ });
+
it('fills the remainder of the window with recent notes', async () => {
const recalled = await store.readRelevant('Update README.md', 3);
assert.strictEqual(recalled.length, 3);
diff --git a/test/unit/todoList.test.js b/test/unit/todoList.test.js
index 41faf47..1a9f3ee 100644
--- a/test/unit/todoList.test.js
+++ b/test/unit/todoList.test.js
@@ -143,3 +143,79 @@ describe('TodoList', () => {
assert.match(todos.render(), /\[x\] 1\. One/);
});
});
+
+describe('TodoList — changed by the user mid-run', () => {
+ it('adds an item after the one being worked on, not at the end', () => {
+ // A step the current item turned out to need is a step the ones after it need too.
+ const todos = new TodoList(['One', 'Two', 'Three']);
+ assert.strictEqual(todos.insertAfterCurrent(['One and a half']), 1);
+
+ assert.deepStrictEqual(
+ todos.items.map((item) => item.text),
+ ['One', 'One and a half', 'Two', 'Three']
+ );
+ });
+
+ it('leaves the new item pending and the current one active', () => {
+ const todos = new TodoList(['One', 'Two']);
+ todos.insertAfterCurrent(['Extra']);
+ assert.strictEqual(todos.current().text, 'One');
+ assert.strictEqual(todos.items[1].status, 'pending');
+ });
+
+ it('refuses to push the list past the ceiling a small model can hold', () => {
+ const todos = new TodoList(['1', '2', '3', '4', '5', '6']);
+ assert.strictEqual(todos.insertAfterCurrent(['7']), 0);
+ assert.strictEqual(todos.items.length, 6);
+ });
+
+ it('adds only as many as there is room for', () => {
+ const todos = new TodoList(['1', '2', '3', '4', '5']);
+ assert.strictEqual(todos.insertAfterCurrent(['6', '7', '8']), 1);
+ assert.strictEqual(todos.items.length, 6);
+ });
+
+ it('rewords the active item and keeps what it was', () => {
+ const todos = new TodoList(['Do the thing', 'Two']);
+ assert.strictEqual(todos.replaceCurrent('Do the specific thing'), true);
+
+ assert.strictEqual(todos.current().text, 'Do the specific thing');
+ // The summary has to be able to say what it was, or a user cannot tell that their
+ // answer is why it succeeded.
+ assert.match(todos.describeChanges(), /Do the thing.*Do the specific thing/);
+ });
+
+ it('ignores a reword that changes nothing', () => {
+ const todos = new TodoList(['One', 'Two']);
+ assert.strictEqual(todos.replaceCurrent('One'), false);
+ assert.strictEqual(todos.replaceCurrent(' '), false);
+ assert.strictEqual(todos.changes.length, 0);
+ });
+
+ it('skips one item without giving up on the rest', () => {
+ const todos = new TodoList(['One', 'Two', 'Three']);
+ const next = todos.skipCurrent('you asked me to skip this one');
+
+ assert.strictEqual(todos.items[0].status, 'skipped');
+ assert.strictEqual(next.text, 'Two');
+ assert.strictEqual(todos.items[2].status, 'pending', 'skipping one must not abandon the others');
+ });
+
+ it('says nothing about a list that ran as planned', () => {
+ const todos = new TodoList(['One', 'Two']);
+ todos.finishCurrent('done');
+ assert.strictEqual(todos.describeChanges(), '');
+ });
+
+ it('reports every change it made, in order', () => {
+ const todos = new TodoList(['One', 'Two']);
+ todos.replaceCurrent('One, precisely');
+ todos.insertAfterCurrent(['One and a half']);
+ todos.skipCurrent('you asked me to skip this one');
+
+ const described = todos.describeChanges();
+ assert.match(described, /reworded/);
+ assert.match(described, /Added at position 2: One and a half/);
+ assert.match(described, /dropped/);
+ });
+});
diff --git a/test/unit/undefinedErrors.test.js b/test/unit/undefinedErrors.test.js
new file mode 100644
index 0000000..6cb7d99
--- /dev/null
+++ b/test/unit/undefinedErrors.test.js
@@ -0,0 +1,131 @@
+'use strict';
+
+/**
+ * The rules for code that fails because something in it does not exist.
+ *
+ * This is the commonest way code written by a small model fails at runtime, and until
+ * 0.7.0 none of it matched a rule — the model got a stack trace, the generic "the error
+ * points at a file" fallback, and no mention of the name that was actually missing. It
+ * then rewrote the file from memory and produced the same error.
+ *
+ * What each of these asserts is not "a rule matched" but "the sentence names the
+ * symbol", because that is the difference between a diagnosis a 1B model can act on and
+ * one it cannot.
+ */
+
+const assert = require('assert');
+
+const { diagnose } = require('../../app/agent/scriptDiagnosis');
+
+/** @param {string} stderr */
+function of(stderr) {
+ return diagnose({ stderr, stdout: '', code: 1 }, { command: 'node src/main.js' });
+}
+
+describe('scriptDiagnosis — undefined symbols', () => {
+ it('names the identifier a ReferenceError is about', () => {
+ const result = of('ReferenceError: addTodo is not defined\n at Object. (/app/src/main.js:4:11)');
+
+ assert.strictEqual(result.reason, 'UNDEFINED_SYMBOL');
+ assert.match(result.summary, /addTodo/);
+ assert.match(result.fix, /`addTodo`/);
+ assert.match(result.fix, /import|define/i);
+ assert.strictEqual(result.fixFirst, true, 'the file must be fixed before the command is run again');
+ assert.strictEqual(result.retryable, false);
+ });
+
+ it('names the identifier a python NameError is about', () => {
+ const result = of("Traceback (most recent call last):\n File \"main.py\", line 3\nNameError: name 'render' is not defined");
+
+ assert.strictEqual(result.reason, 'UNDEFINED_SYMBOL');
+ assert.match(result.fix, /`render`/);
+ });
+
+ it('names the symbol javac could not find', () => {
+ const result = of(
+ 'Main.java:7: error: cannot find symbol\n greeter.greet();\n ^\n symbol: method greet\n location: variable greeter'
+ );
+
+ assert.strictEqual(result.reason, 'UNDEFINED_SYMBOL');
+ assert.match(result.fix, /`greet`/);
+ });
+
+ it('does not mistake an ESM module error for an undefined variable', () => {
+ // `module is not defined in ES module scope` is a ReferenceError with a completely
+ // different fix, and MODULE_SYSTEM has to keep claiming it.
+ const result = of('ReferenceError: module is not defined in ES module scope');
+ assert.strictEqual(result.reason, 'MODULE_SYSTEM');
+ });
+});
+
+describe('scriptDiagnosis — reading a property of nothing', () => {
+ it('names the property and says the holder is the problem', () => {
+ const result = of("TypeError: Cannot read properties of undefined (reading 'map')");
+
+ assert.strictEqual(result.reason, 'UNDEFINED_PROPERTY');
+ assert.match(result.fix, /`map`/);
+ // The point of the sentence: fix what produced the undefined, not the line that
+ // read it. A model told only "map failed" adds a `?.` and moves on.
+ assert.match(result.fix, /Fix the source of the undefined/i);
+ assert.strictEqual(result.fixFirst, true);
+ });
+
+ it('handles the older node phrasing too', () => {
+ const result = of("TypeError: Cannot read property 'length' of null");
+ assert.strictEqual(result.reason, 'UNDEFINED_PROPERTY');
+ assert.match(result.fix, /`length`/);
+ assert.match(result.fix, /null/);
+ });
+
+ it('explains a python NoneType attribute error as a None, not a typo', () => {
+ const result = of("AttributeError: 'NoneType' object has no attribute 'title'");
+ assert.strictEqual(result.reason, 'UNDEFINED_PROPERTY');
+ assert.match(result.fix, /None/);
+ assert.match(result.fix, /rather than the line that used it/i);
+ });
+
+ it('suggests a spelling check for an attribute on a real class', () => {
+ const result = of("AttributeError: 'Parser' object has no attribute 'parseAll'");
+ assert.match(result.fix, /`parseAll`/);
+ assert.match(result.fix, /spelled differently/i);
+ });
+
+ it('reads a NullPointerException as a null that should have been set', () => {
+ const result = of('Exception in thread "main" java.lang.NullPointerException\n\tat Main.main(Main.java:9)');
+ assert.strictEqual(result.reason, 'NULL_DEREFERENCE');
+ assert.match(result.fix, /initialise the field|pass the argument/i);
+ });
+});
+
+describe('scriptDiagnosis — calling something that is not a function', () => {
+ it('names the call and points at the import shape', () => {
+ const result = of('TypeError: todos.map is not a function');
+
+ assert.strictEqual(result.reason, 'NOT_A_FUNCTION');
+ assert.match(result.fix, /`todos\.map`/);
+ assert.match(result.fix, /default vs named/i);
+ });
+});
+
+describe('scriptDiagnosis — the rules that were already there', () => {
+ it('still prefers a missing file over a missing package', () => {
+ const result = of("Error: Cannot find module '/app/src/main.js'");
+ assert.strictEqual(result.reason, 'WRONG_PATH');
+ });
+
+ it('still recognises a missing dependency', () => {
+ const result = of("Error: Cannot find module 'express'");
+ assert.strictEqual(result.reason, 'MISSING_DEPENDENCY');
+ });
+
+ it('still says nothing about output that means nothing', () => {
+ assert.strictEqual(diagnose({ stderr: '', stdout: 'ok', code: 1 }, { command: 'true' }), null);
+ });
+
+ it('gives the same answer twice for the same input', () => {
+ // The rules are `exec`ed rather than `test`ed now, so a stateful regex would show
+ // up as a second call returning null.
+ const text = 'ReferenceError: addTodo is not defined';
+ assert.deepStrictEqual(of(text), of(text));
+ });
+});
From 1d776cf23ed5340645f805ef5830df380d6d8d1d Mon Sep 17 00:00:00 2001
From: jaymar921
Date: Fri, 14 Aug 2026 15:25:27 +0800
Subject: [PATCH 3/4] feat(0.7.0): let an answer change the checklist, and
bound two regexes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An answer that only reaches the model is half-applied. The item's text is what a retry
is briefed on, what stepGuard checks the changed files against, and what the summary
reads back — so a free-text answer now rewords the running item, and a skip closes it as
the user's decision rather than as a failure. The distinction matters twice over: a
failed item puts a [!] against a row the user themselves closed, and it feeds "an
earlier step failed" to every item after it.
The checklist keeps a change log, so the summary can say it changed and why.
insertAfterCurrent is gone. It was written for a user path that does not exist — nothing
offers "add a step" — and an unreachable method that mutates the one structure both the
user and the model treat as settled is worse than a slightly narrower feature.
Two regexes in commonSense were flagged by eslint-plugin-security and both were real
rather than noise, which is worth saying because the last two passes reviewed and
dismissed every one of these:
- DANGLING_REFERENCE had `^\s*` and `\s*$` around an optional character, the classic
ambiguous shape. 68ms at 10,000 trailing spaces, 1,660ms at 50,000. The caller trims
and the anchors are gone: 0ms.
- PATH_TOKEN matches linearly but is scanned with /g, so a string with no match gets one
scan per start position — 3,089ms on 50,000 characters of `a/a/a/…`. Input is now
bounded to 4,000 characters, the same discipline the TODO list uses on its items.
Neither is an attack — it is the user's own composer — but three seconds of frozen
extension host is a bug either way. Both are pinned by tests with a 250ms budget.
Also removed two eslint-disable directives added earlier in this branch that suppressed
nothing. Warnings are back to 25 against a 23 baseline; the two additions are the
regexes above, measured and documented in place rather than waved through.
1320 unit tests and 16 integration tests passing, 0 lint errors.
---
app/agent/agentSession.js | 29 ++++++++++
app/agent/todoList.js | 57 +++-----------------
app/core/commonSense.js | 39 ++++++++++++--
app/core/environmentProfile.js | 2 -
test/unit/askingTheUser.test.js | 96 +++++++++++++++++++++++++++++++++
test/unit/commonSense.test.js | 27 ++++++++++
test/unit/todoList.test.js | 36 +------------
7 files changed, 196 insertions(+), 90 deletions(-)
diff --git a/app/agent/agentSession.js b/app/agent/agentSession.js
index a1121ec..903bcb5 100644
--- a/app/agent/agentSession.js
+++ b/app/agent/agentSession.js
@@ -536,6 +536,15 @@ class AgentSession {
/** What the user said mid-run, for the summary. @type {string[]} */
this._userGuidance = [];
+ /**
+ * The checklist currently running, so an answer given inside a loop can adjust it.
+ *
+ * Null except between `_runWithTodos` building the list and reporting it.
+ *
+ * @type {TodoList | null}
+ */
+ this._todos = null;
+
/** @type {AbortController | null} */
this._controller = null;
this.running = false;
@@ -788,6 +797,9 @@ class AgentSession {
this._controller = null;
this._emit = null;
this._currentItem = '';
+ // Also cleared on the paths that leave `_runWithTodos` early — a cancel, or a
+ // throw — where the line at the end of it is never reached.
+ this._todos = null;
}
}
@@ -917,6 +929,10 @@ class AgentSession {
}
const todos = new TodoList(items);
+ // Reachable from `_recover`, which runs inside a loop this method is awaiting and
+ // has no other way to reach the checklist. Cleared in the `finally` below so a
+ // later turn cannot write into a list that has already been reported.
+ this._todos = todos;
emit({ type: 'todo', items: todos.items.map((item) => item.text) });
logger.info(`Running ${todos.items.length} TODO item(s) one at a time.`);
@@ -1200,6 +1216,9 @@ class AgentSession {
this._describeInterventions(todos) +
(summaries.length > 0 ? `\n\nDetail:\n${summaries.join('\n')}` : '');
+ // The list is reported now, so nothing may write into it afterwards.
+ this._todos = null;
+
return {
summary: appendUnfinishedNote(summary, allSteps),
steps: allSteps,
@@ -2082,6 +2101,16 @@ class AgentSession {
this._interrupt = 'skip';
}
+ // The user has just said what this step is actually supposed to do, so the
+ // checklist should say it too. Not cosmetic: the item's text is what the retry
+ // is briefed on, what `stepGuard` checks the changed files against, and what
+ // the summary reads back — leaving it as the model's original wording means
+ // the one authoritative statement of the step is the only place their
+ // correction does not reach.
+ if (resolved.effect === 'instruct' && this._todos && resolved.guidance) {
+ this._todos.replaceCurrent(`${this._todos.current().text} — ${resolved.guidance}`);
+ }
+
return {
...result,
observation: `${result.observation}\n\n${resolved.guidance || resolved.label}`,
diff --git a/app/agent/todoList.js b/app/agent/todoList.js
index 96405aa..a9a15a9 100644
--- a/app/agent/todoList.js
+++ b/app/agent/todoList.js
@@ -75,7 +75,7 @@ class TodoList {
* visible: a checklist that quietly grew an item reads afterwards as a model that
* did work nobody asked for.
*
- * @type {Array<{kind: 'inserted' | 'reworded' | 'dropped', detail: string, at: number}>}
+ * @type {Array<{kind: 'reworded' | 'dropped', detail: string, at: number}>}
*/
this.changes = [];
}
@@ -146,46 +146,6 @@ class TodoList {
return this.items.filter((item) => wanted.includes(item.status)).map((item) => item.text);
}
- /**
- * Add items directly after the one being worked on.
- *
- * Used when the user, having been asked, says the work needs a step nobody planned
- * for. They go *after* the active item rather than at the end, because a step the
- * current one turned out to need is a step the ones after it need too.
- *
- * The `MAX_ITEMS` ceiling still applies — it is the number a small model can hold,
- * and the user answering a question does not change that. Anything over the ceiling
- * is refused rather than silently dropped, so the caller can say so.
- *
- * @param {string[]} texts
- * @returns {number} How many were actually added.
- */
- insertAfterCurrent(texts) {
- const index = this.items.findIndex((item) => item.status === 'active');
- if (index === -1) return 0;
-
- const room = MAX_ITEMS - this.items.length;
- if (room <= 0) {
- logger.warn(`Cannot add to the checklist: it is already at the ${MAX_ITEMS}-item ceiling.`);
- return 0;
- }
-
- /** @type {TodoItem[]} */
- const added = texts
- .map((text) => String(text).trim())
- .filter(Boolean)
- .slice(0, room)
- .map((text) => ({ text, status: /** @type {TodoStatus} */ ('pending') }));
- if (added.length === 0) return 0;
-
- this.items.splice(index + 1, 0, ...added);
- for (const item of added) {
- this.changes.push({ kind: 'inserted', detail: item.text, at: index + 1 });
- logger.info(`Checklist gained an item after ${index + 1}: ${item.text}`);
- }
- return added.length;
- }
-
/**
* Restate the active item.
*
@@ -230,16 +190,11 @@ class TodoList {
*/
describeChanges() {
if (this.changes.length === 0) return '';
- const lines = this.changes.map((change) => {
- switch (change.kind) {
- case 'inserted':
- return `- Added at position ${change.at + 1}: ${change.detail}`;
- case 'reworded':
- return `- Item ${change.at} reworded: ${change.detail}`;
- default:
- return `- Item ${change.at} dropped: ${change.detail}`;
- }
- });
+ const lines = this.changes.map((change) =>
+ change.kind === 'reworded'
+ ? `- Item ${change.at} reworded: ${change.detail}`
+ : `- Item ${change.at} dropped: ${change.detail}`
+ );
return `The checklist changed while it ran:\n${lines.join('\n')}`;
}
diff --git a/app/core/commonSense.js b/app/core/commonSense.js
index 22b1dd0..bf4ff50 100644
--- a/app/core/commonSense.js
+++ b/app/core/commonSense.js
@@ -104,9 +104,25 @@ const MIN_STEM_CHARS = 3;
*
* Requires an extension, so ordinary prose survives: "update the parser" names no file
* and must not be read as one. Backticks and quotes are stripped by the caller.
+ *
+ * The nested quantifier is unambiguous — each repetition of the outer group must
+ * consume a separator, and `[\w.@-]` cannot match one — so a single match is linear.
+ * What is *not* linear is scanning with `/g` for matches that are not there: every
+ * start position in the string gets its own scan, which is O(n²) overall. Measured at
+ * 111 ms on 10,000 characters of `a/a/a/…` and 3,089 ms at 50,000. That is a paste
+ * into the composer rather than an attack, but three seconds of frozen extension host
+ * is a bug either way, which is what `MAX_SCANNED_CHARS` is for.
*/
const PATH_TOKEN = /(?:[\w.@-]+[/\\])*[\w.@-]+\.[a-z0-9]{1,6}\b/gi;
+/**
+ * How much of a request is scanned for filenames.
+ *
+ * A message longer than this is not a request naming a file the user mistyped; it is a
+ * paste. The same bounding discipline the TODO list uses on its items.
+ */
+const MAX_SCANNED_CHARS = 4000;
+
/**
* Extensions worth treating as a file reference. A version number like `1.2` and a
* sentence ending in `.js` are told apart here rather than by cleverness.
@@ -119,8 +135,17 @@ const SOURCE_EXTENSION =
*
* Anchored, and the pronoun has to be the object of the verb — "fix it" matches,
* "fix items in the list" does not.
+ *
+ * The caller trims before testing, and that is load-bearing rather than tidiness. With
+ * `^\s*` and `\s*$` around the body this had the classic ambiguous shape — two
+ * unbounded whitespace runs either side of an optional character — and a message
+ * ending in a long run of spaces could be split between them in quadratically many
+ * ways. Measured on the version with the anchors: 68 ms at 10,000 trailing spaces and
+ * **1,660 ms at 50,000**, against 0 ms for the trimmed form here. The optional groups
+ * that remain each begin with a distinct literal (`please`, `can`, `could`), so there
+ * is one way to match any candidate.
*/
-const DANGLING_REFERENCE = /^\s*(?:please\s+)?(?:can you\s+|could you\s+)?(fix|update|change|finish|redo|revert|delete|remove|continue|do)\s+(it|this|that|those|these|them|the thing|the file|that file|this file)\s*[.!?]?\s*$/i;
+const DANGLING_REFERENCE = /^(?:please\s+)?(?:can you\s+|could you\s+)?(fix|update|change|finish|redo|revert|delete|remove|continue|do)\s+(it|this|that|those|these|them|the thing|the file|that file|this file)[.!?]?$/i;
/**
* @typedef {object} Interpretation
@@ -137,7 +162,7 @@ const DANGLING_REFERENCE = /^\s*(?:please\s+)?(?:can you\s+|could you\s+)?(fix|u
* @returns {string[]} Unique, in the order they appear.
*/
function referencedPaths(task) {
- const text = String(task || '').replace(/[`'"]/g, ' ');
+ const text = String(task || '').slice(0, MAX_SCANNED_CHARS).replace(/[`'"]/g, ' ');
const found = text.match(PATH_TOKEN) || [];
/** @type {string[]} */
@@ -197,6 +222,11 @@ function editDistance(a, b) {
// Full matrix rather than two rolling rows: a transposition needs the row before
// last, and these are filenames — the matrix is tens of cells, not thousands.
+ //
+ // Every index below is a loop counter bounded by the length of one of the two
+ // strings, and every read is from the matrix this function just built. There is no
+ // key here that came from anywhere, which is what the rule is looking for.
+ /* eslint-disable security/detect-object-injection -- numeric loop indices into a local matrix */
/** @type {number[][]} */
const d = [];
for (let i = 0; i <= a.length; i += 1) d.push(new Array(b.length + 1).fill(0));
@@ -215,6 +245,7 @@ function editDistance(a, b) {
}
return d[a.length][b.length];
+ /* eslint-enable security/detect-object-injection */
}
/**
@@ -373,7 +404,9 @@ function interpret(input) {
* @returns {Interpretation | null}
*/
function danglingReference(task, input) {
- const match = DANGLING_REFERENCE.exec(task);
+ // Trimmed here so the pattern does not have to carry `\s*` anchors — see the note on
+ // DANGLING_REFERENCE for what those cost.
+ const match = DANGLING_REFERENCE.exec(String(task || '').trim());
if (!match) return null;
const conversation = Array.isArray(input.conversation) ? input.conversation : [];
diff --git a/app/core/environmentProfile.js b/app/core/environmentProfile.js
index be834e2..22b14db 100644
--- a/app/core/environmentProfile.js
+++ b/app/core/environmentProfile.js
@@ -206,7 +206,6 @@ function persist(workspaceRoot, profile = detect()) {
let file;
try {
const resolved = pathGuard.resolvePath(workspaceRoot, PROFILE_PATH);
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- fixed name under the workspace root
fs.mkdirSync(path.dirname(resolved.absolute), { recursive: true });
file = pathGuard.assertRealPathSync(resolved).absolute;
} catch (err) {
@@ -215,7 +214,6 @@ function persist(workspaceRoot, profile = detect()) {
}
try {
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- guarded above
fs.writeFileSync(file, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
} catch (err) {
logger.warn(`Could not write ${PROFILE_PATH}: ${/** @type {Error} */ (err).message}`);
diff --git a/test/unit/askingTheUser.test.js b/test/unit/askingTheUser.test.js
index 4f2052a..cd54261 100644
--- a/test/unit/askingTheUser.test.js
+++ b/test/unit/askingTheUser.test.js
@@ -247,4 +247,100 @@ describe('asking the user', () => {
assert.doesNotMatch(result.summary, /What kept going wrong/);
});
});
+
+ describe('the checklist, when the user changes it mid-run', () => {
+ const TIER_A = { tier: 'A', strategy: 'react', label: 'Agentic', model: 'test', canPlanTodos: true };
+
+ /**
+ * A session whose model can hold a checklist, so `_runWithTodos` is the path taken.
+ *
+ * @param {object} opts
+ */
+ function planningSession(opts) {
+ const session = makeSession(opts);
+ session.capability = TIER_A;
+ return session;
+ }
+
+ /**
+ * The planner replies with a numbered list, then each item runs its own loop.
+ *
+ * @param {string[]} items
+ * @param {string[]} rest
+ */
+ function withPlan(items, rest) {
+ return scriptedClient([items.map((text, index) => `${index + 1}. ${text}`).join('\n'), ...rest]);
+ }
+
+ const failing = () => json({ action: 'run_script', command: 'node src/broken.js' });
+
+ it('records a skip as the user’s decision, not as a failed step', async () => {
+ const client = withPlan(
+ ['Update src/broken.js', 'Update README.md'],
+ [
+ failing(),
+ failing(),
+ json({ action: 'write_file', path: 'README.md', code: '# Updated\n' }),
+ json({ action: 'done', summary: 'readme updated' }),
+ ]
+ );
+
+ const session = planningSession({
+ client,
+ onClarify: async (request) => {
+ const skip = request.options.find((option) => option.effect === 'skip');
+ return { id: request.id, optionId: skip.id };
+ },
+ });
+
+ const result = await run(session, 'update src/broken.js and update README.md');
+
+ assert.strictEqual(result.todos[0].status, 'skipped', 'a step the user closed must not read as failed');
+ assert.match(result.summary, /you asked me to skip this one/);
+ // And the rest of the list still ran.
+ assert.notStrictEqual(result.todos[1].status, 'skipped');
+ });
+
+ it('rewords the running item to carry what the user said', async () => {
+ const client = withPlan(
+ ['Update src/broken.js', 'Update README.md'],
+ [
+ failing(),
+ failing(),
+ json({ action: 'done', summary: 'gave up' }),
+ json({ action: 'write_file', path: 'README.md', code: '# Updated\n' }),
+ json({ action: 'done', summary: 'readme updated' }),
+ ]
+ );
+
+ const session = planningSession({
+ client,
+ onClarify: async (request) => ({ id: request.id, text: 'the entry point is src/main.js' }),
+ });
+
+ const result = await run(session, 'update src/broken.js and update README.md');
+
+ // The item's text is what a retry is briefed on, what stepGuard checks against,
+ // and what the summary reads back.
+ assert.match(result.todos[0].text, /the entry point is src\/main\.js/);
+ assert.match(result.summary, /The checklist changed while it ran/);
+ assert.match(result.summary, /reworded/);
+ });
+
+ it('leaves the checklist alone when nothing was asked', async () => {
+ const client = withPlan(
+ ['Update README.md', 'Update src/main.js'],
+ [
+ json({ action: 'write_file', path: 'README.md', code: '# One\n' }),
+ json({ action: 'done', summary: 'readme' }),
+ json({ action: 'write_file', path: 'src/main.js', code: 'export function main() {\n return 2;\n}\n' }),
+ json({ action: 'done', summary: 'main' }),
+ ]
+ );
+ const session = planningSession({ client });
+
+ const result = await run(session, 'update README.md and update src/main.js');
+ assert.doesNotMatch(result.summary, /The checklist changed/);
+ });
+ });
});
diff --git a/test/unit/commonSense.test.js b/test/unit/commonSense.test.js
index a00879d..b98b204 100644
--- a/test/unit/commonSense.test.js
+++ b/test/unit/commonSense.test.js
@@ -163,4 +163,31 @@ describe('commonSense.interpret — a reference to nothing', () => {
assert.strictEqual(commonSense.interpret({ task: 'fix the parser in src/main.js', files: FILES }).kind, 'ok');
assert.strictEqual(commonSense.interpret({ task: 'update items in the list', files: FILES }).kind, 'ok');
});
+
+ it('still matches when the message is padded with whitespace', () => {
+ const reading = commonSense.interpret({ task: ' fix it. \n', files: FILES, editorPath: 'src/api.js' });
+ assert.strictEqual(reading.kind, 'repaired');
+ });
+});
+
+describe('commonSense — input that is trying to be expensive', () => {
+ // Both of these were measured super-linear before 0.7.0 bounded them: the dangling
+ // pattern at 1,660 ms on 50,000 trailing spaces, and the path scan at 3,089 ms on
+ // 50,000 characters of `a/a/a/…`. Neither is an attack — it is the user's own
+ // composer — but three seconds of frozen extension host is a bug either way.
+ const BUDGET_MS = 250;
+
+ it('reads a message with a huge whitespace tail in linear time', () => {
+ const task = `fix it${' '.repeat(50000)}X`;
+ const started = Date.now();
+ commonSense.interpret({ task, files: FILES });
+ assert.ok(Date.now() - started < BUDGET_MS, `took ${Date.now() - started}ms`);
+ });
+
+ it('scans a huge path-like paste in bounded time', () => {
+ const task = `update ${'a/'.repeat(25000)}${'a'.repeat(50)}!`;
+ const started = Date.now();
+ commonSense.referencedPaths(task);
+ assert.ok(Date.now() - started < BUDGET_MS, `took ${Date.now() - started}ms`);
+ });
});
diff --git a/test/unit/todoList.test.js b/test/unit/todoList.test.js
index 1a9f3ee..821d126 100644
--- a/test/unit/todoList.test.js
+++ b/test/unit/todoList.test.js
@@ -145,36 +145,6 @@ describe('TodoList', () => {
});
describe('TodoList — changed by the user mid-run', () => {
- it('adds an item after the one being worked on, not at the end', () => {
- // A step the current item turned out to need is a step the ones after it need too.
- const todos = new TodoList(['One', 'Two', 'Three']);
- assert.strictEqual(todos.insertAfterCurrent(['One and a half']), 1);
-
- assert.deepStrictEqual(
- todos.items.map((item) => item.text),
- ['One', 'One and a half', 'Two', 'Three']
- );
- });
-
- it('leaves the new item pending and the current one active', () => {
- const todos = new TodoList(['One', 'Two']);
- todos.insertAfterCurrent(['Extra']);
- assert.strictEqual(todos.current().text, 'One');
- assert.strictEqual(todos.items[1].status, 'pending');
- });
-
- it('refuses to push the list past the ceiling a small model can hold', () => {
- const todos = new TodoList(['1', '2', '3', '4', '5', '6']);
- assert.strictEqual(todos.insertAfterCurrent(['7']), 0);
- assert.strictEqual(todos.items.length, 6);
- });
-
- it('adds only as many as there is room for', () => {
- const todos = new TodoList(['1', '2', '3', '4', '5']);
- assert.strictEqual(todos.insertAfterCurrent(['6', '7', '8']), 1);
- assert.strictEqual(todos.items.length, 6);
- });
-
it('rewords the active item and keeps what it was', () => {
const todos = new TodoList(['Do the thing', 'Two']);
assert.strictEqual(todos.replaceCurrent('Do the specific thing'), true);
@@ -210,12 +180,10 @@ describe('TodoList — changed by the user mid-run', () => {
it('reports every change it made, in order', () => {
const todos = new TodoList(['One', 'Two']);
todos.replaceCurrent('One, precisely');
- todos.insertAfterCurrent(['One and a half']);
todos.skipCurrent('you asked me to skip this one');
const described = todos.describeChanges();
- assert.match(described, /reworded/);
- assert.match(described, /Added at position 2: One and a half/);
- assert.match(described, /dropped/);
+ assert.match(described, /Item 1 reworded/);
+ assert.match(described, /Item 1 dropped/);
});
});
From 41432392b7dca993e0c9c8ac57ee7f02efe63ed3 Mon Sep 17 00:00:00 2001
From: jaymar921
Date: Fri, 14 Aug 2026 15:34:26 +0800
Subject: [PATCH 4/4] docs(0.7.0): version bump, SAST report, and the two
marketplace images
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
security/sast-report-2026-08-14-0.7.0.md supersedes doc/SAST-0.6.1.md. First pass where
no Critical or High is carried forward with a standing risk: npx now confirms, both
pathGuard bypasses are closed, and the mocha advisories are fixed rather than deferred —
npm audit reports zero across the full tree, not only under --omit=dev.
Two findings were opened and closed inside the pass, and both deserve saying out loud
because the 0.1.0 and 0.6.1 passes each reviewed this warning class and dismissed all of
it. DANGLING_REFERENCE and PATH_TOKEN were genuinely super-linear — 1,660ms and 3,089ms
on 50,000 characters. "Reviewed and dismissed" had become the default answer for
detect-unsafe-regex, and these two were the cases where it was wrong. A flagged pattern
is now measured on adversarial input before it is accepted, and the measurement is
written beside the pattern rather than only in the report.
retire.js is recorded as NOT run, with the reason and the command to run it. Fetching it
would have meant `npx retire` — downloading and executing a package from the registry,
which is the exact behaviour the headline finding of this pass exists to require a
decision about. Running it silently inside the pass that closed that finding would have
been the wrong way to close it.
Semgrep's `python -m semgrep` shim prints a deprecation notice and exits without
scanning, which reads exactly like a clean run. The report says to invoke the executable
directly. 91 rules over 77 targets, 2 findings, both the known child_process call.
Both marketing images regenerated at v0.7.0 and checked visually rather than assumed
correct. The "New in" tag moved from "Knows your machine" to "Big requests become a
checklist" — the card 0.7.0 actually changed — and the source README now records that
the tag moves with the release and that only one card ever carries it.
ARCHITECTURE.md had 46 modules and 573 unit tests against a real 73 and 1323, and said
nothing about how a failing step is handled. It now covers why diagnosis, recovery, and
clarification are three modules, and why the ladder is two rungs rather than a round
number.
1323 unit tests, 16 integration tests, 0 lint errors, 0 vulnerabilities.
---
CHANGELOG.md | 178 +++++++++++++++++++++++
README.md | 2 +-
app/agent/errorRecovery.js | 11 --
doc/ARCHITECTURE.md | 28 +++-
docs/images/capabilities.png | Bin 474552 -> 478533 bytes
docs/images/hero-offline-agent.png | Bin 378977 -> 379057 bytes
docs/images/src/README.md | 9 +-
docs/images/src/capabilities.html | 6 +-
docs/images/src/hero-offline-agent.html | 2 +-
package-lock.json | 4 +-
package.json | 2 +-
security/sast-report-2026-08-14-0.7.0.md | 171 ++++++++++++++++++++++
test/unit/commonSense.test.js | 23 +++
13 files changed, 410 insertions(+), 26 deletions(-)
create mode 100644 security/sast-report-2026-08-14-0.7.0.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e9ba66..17cfecc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,184 @@ All notable changes to HirayaCoder are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.7.0] — unreleased
+
+Everything here follows from one observation: a small model that is stuck does not know
+it is stuck, and everything it does next makes the run worse. Across the 0.5.3 and 0.6.0
+rounds a model that had run out of ideas ended a run in one of three ways — resend the
+failing action until the step budget was gone, announce success it had not achieved, or
+abandon a task it was one decision away from finishing. All three cost the user more
+than a question would have.
+
+So 0.7.0 adds the three things that were missing between "the action failed" and "the
+run ended badly": a diagnosis for the failures nobody had written a rule for, an
+escalation ladder when the diagnosis does not take, and — last, not first — a way to ask
+the user.
+
+### Added — undefined symbols are now a diagnosis, not a stack trace
+
+The commonest way code written by a small model fails at runtime had no rule at all. The
+model got forty lines of stack trace, the generic "the error points at a file" fallback,
+and no mention of the name that was actually missing; it then rewrote the file from
+memory and produced the same error.
+
+Seven rules across JavaScript, Python and Java: `ReferenceError`, `NameError`, javac's
+`cannot find symbol`, reading a property of `undefined` or `null`, `AttributeError`,
+`x is not a function`, and `NullPointerException`. Each names the symbol the error named,
+because *"something is undefined"* is not actionable and *"`addTodo` is used in that file
+but never defined or imported there"* is the same information with the one detail that
+makes it a next move.
+
+- `scriptDiagnosis` rules may now compute their sentence from the match, which is what
+ makes naming the symbol possible. The matcher moved from `test` to `exec`, with a test
+ pinning that repeated calls agree.
+- The property-of-undefined rule says to fix *what produced the undefined*, not the line
+ that read it — a model told only "map failed" adds a `?.` and moves on.
+- `module is not defined in ES module scope` is still claimed by `MODULE_SYSTEM`, which
+ is a `ReferenceError` with a completely different fix.
+
+### Added — an escalation ladder, and the user at the end of it
+
+`agent/errorRecovery` covers what no rule matches, and what a rule matched twice.
+Failures are compared by signature — line numbers, addresses and absolute paths
+normalised out — so the same problem is recognised across attempts and across machines.
+
+| Seen | What happens |
+|---|---|
+| 1st, diagnosed | Nothing extra; the diagnosis already said it |
+| 1st, undiagnosed | Read the error literally; here is what to look at |
+| 2nd | Ask the user |
+
+Asking on the *second* failure rather than a rounder number is set against
+`reactLoop.REPEAT_LIMIT`, which is 2: the loop ends a run once the model has sent the
+same action twice, so a ladder waiting for a third would never reach its top rung and
+the run would end with the user never asked. The second failure is the last moment at
+which asking can still change the outcome.
+
+A refused write or a declined delete is **not** treated as being stuck. That is the
+permission model working, and escalating it would ask the user the same question twice,
+the second time with less context than the confirmation dialog had.
+
+### Added — the agent asks, with options rather than an open question
+
+`agent/clarification` is the shape of a mid-run question: two to four options, exactly
+one recommended, every option stating what it does to the queue, and free text always
+available. A question with seven options is a second task handed to the user; four
+unranked options move the decision without helping with it. When the same thing has
+failed twice, the recommendation is **skip**, because recommending another attempt would
+recommend the thing that has already not worked.
+
+The card renders in the chat panel and stays there once answered, as part of the record
+of the run.
+
+The property that mattered most while building this is the negative one, and it is
+tested from both sides: **a session with nothing to ask never blocks.** Closing the tab,
+pressing Stop, and a turn that throws all settle an outstanding question, so a run
+continuing in the background cannot hold its lane in the turn queue waiting on a card
+nobody can see.
+
+### Added — the request is read before the model sees it
+
+"Update mian.js" in a project containing `main.js` is not ambiguous to a person for even
+a moment. To a small model it is a path that does not resolve, and what happens next is
+the part that costs: it creates `mian.js`, reports the update done, and the user now has
+two files where they had one.
+
+`core/commonSense` decides from the evidence rather than from confidence:
+
+- **Exactly one near-match** — repaired, with both names stated in the summary and in
+ memory, so the user can see their request was altered and disagree with it.
+- **Two to four near-matches** — asked, offering them.
+- **No near-match** — nothing said. The file may simply not exist yet, and a check that
+ fired on those would be worse than no check.
+
+The verb governing a filename is read from the few words in front of it, so "update
+mian.js **to add** a header" is a typo rather than a creation. A right name in a wrong
+folder is left alone — nothing was misspelled, and the model has the workspace listing.
+"Fix it" as the first message of a session resolves to the open editor file, or asks.
+
+Comparison is Damerau-Levenshtein, not `memoryStore.similarity`: that one is Jaccard over
+whole words and scores `mian.js` against `main.js` at zero.
+
+### Changed — the checklist can change while it runs
+
+An answer that only reaches the model is half-applied. The item's text is what a retry is
+briefed on, what `stepGuard` checks the changed files against, and what the summary reads
+back — so a free-text answer now rewords the running item, and a skip closes it as the
+user's decision rather than as a failure. That distinction matters twice: a failed item
+puts a `[!]` against a row the user themselves closed, and it feeds "an earlier step
+failed" to every item after it.
+
+`TodoList` keeps a change log, and the summary reports three things it previously hid:
+how the request was read, what the user said when asked, and what kept failing anyway.
+A run that ends "4 of 4 completed" after hitting the same error eleven times is
+technically true and actively misleading.
+
+### Changed — memory is recalled by relevance on every turn
+
+`readRelevant` existed but only fired in experimental step sessions, so an ordinary
+six-item run shared one memory block, selected by recency, against the whole request
+before the list existed. That is the wrong block for every item after the first: on the
+React benchmark the item assembling `App.jsx` ran sixth, by which point the notes naming
+`useTodos.js` and `TodoInput.jsx` were the oldest in the file and the first to fall out
+of a five-entry window. The note that survived was about the README.
+
+Recall is now by subject on every turn, and ranked by **how much** of the subject each
+note shares rather than by whether any single token matched — a weak match displacing a
+strong one is the failure the selector exists to prevent. It is never worse than recency:
+a subject that matches nothing gets exactly the window it would have had.
+
+### Fixed — `_routeForStep` dropped `readOnlyTurn`
+
+Rebuilding a route per item exposed this. The step route replaced the session's for the
+whole step but did not carry the read-only flag, so the mutating tools came back for a
+turn the router had already decided was a look-only request. Latent while the path was
+experimental; carried through now that every item rebuilds its route.
+
+### Fixed — the three findings the 0.6.1 SAST pass deferred
+
+- **`npx` reached the network and never asked.** It was on the default allow-list,
+ `NON_INTERACTIVE_ENV` sets `npm_config_yes` which suppresses npx's own *"Ok to
+ proceed?"*, and it was absent from `ALWAYS_CONFIRM` — so under auto-approve it fetched
+ and ran arbitrary remote code with no click, in an extension whose headline claim is
+ that it works fully offline. A live 0.6.0 run has it in the audit log, auto-approved
+ and recorded as routine. All six spellings now confirm: `npx`, `npm exec`/`x`/`create`,
+ `yarn dlx`/`create`, `pnpm dlx`/`create`. Bare `npm init` is left alone — it writes a
+ manifest and touches no network, and a click for nothing is a click trained away.
+- **Two writes skipped `pathGuard`.** `ensureGitignore` and `environmentProfile.persist`
+ wrote through `path.join` directly, the only two writes in the extension that missed
+ the symlink check. Both now resolve through the guard, which gained a synchronous twin
+ for the activation path; the containment half is shared so the two cannot drift on the
+ part that decides.
+- **Dev-only advisories in the `mocha` tree.** mocha 11.8.0 with `overrides` pinning
+ `diff@9` and `serialize-javascript@7.1.0`, which is what actually clears them —
+ upstream mocha still asks for `diff@^7` and `serialize-javascript@^6`, and
+ `npm audit fix --force` would have moved sideways to 11.3.0 without fixing either.
+ `npm audit` now reports zero across the full tree rather than only under `--omit=dev`.
+ Verified serially, in parallel mode, and against a failing assertion.
+
+### Fixed — two regexes that were genuinely super-linear
+
+Both flagged by `eslint-plugin-security` in `core/commonSense`, and worth recording
+because the previous two passes reviewed and dismissed every warning of this class:
+
+- `DANGLING_REFERENCE` had `^\s*` and `\s*$` around an optional character — the classic
+ ambiguous shape. **68 ms at 10,000 trailing spaces, 1,660 ms at 50,000.** The caller
+ trims and the anchors are gone: 0 ms.
+- `PATH_TOKEN` matches linearly but is scanned with `/g`, so a string with no match gets
+ one scan per start position — **3,089 ms on 50,000 characters** of `a/a/a/…`. Input is
+ now bounded to 4,000 characters.
+
+Neither is an attack — it is the user's own composer — but three seconds of frozen
+extension host is a bug either way. Both are pinned by tests with a 250 ms budget.
+
+### Removed
+
+- `TodoList.insertAfterCurrent`, written for a user path that does not exist. An
+ unreachable method that mutates the one structure both the user and the model treat as
+ settled is worse than a slightly narrower feature.
+- `ErrorRecovery.hasAsked`, which had no caller.
+
## [0.6.1] — unreleased
A macOS run of the same React TODO spec on the 0.6.0 build, `ornith:9b`, high thinking
diff --git a/README.md b/README.md
index 6f9cc61..ecdf32f 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
-
+
*A local Filipino-inspired AI coder that brings imagination and speed to your VS Code workflow.*
diff --git a/app/agent/errorRecovery.js b/app/agent/errorRecovery.js
index 5fefab9..72e29c4 100644
--- a/app/agent/errorRecovery.js
+++ b/app/agent/errorRecovery.js
@@ -287,17 +287,6 @@ class ErrorRecovery {
return `The user was asked about a repeated failure ("${about}") and said: ${text}`;
}
- /**
- * Has this run already given up on this failure and asked about it?
- *
- * @param {Failure} failure
- * @returns {boolean}
- */
- hasAsked(failure) {
- const known = this._seen.get(signatureOf(String(failure.action || 'action'), String(failure.observation || '')));
- return Boolean(known && known.asked);
- }
-
/**
* Everything that failed more than once this run, worst first.
*
diff --git a/doc/ARCHITECTURE.md b/doc/ARCHITECTURE.md
index e41c415..98b8252 100644
--- a/doc/ARCHITECTURE.md
+++ b/doc/ARCHITECTURE.md
@@ -1,7 +1,7 @@
# HirayaCoder — Architecture
-How the extension is put together and, more usefully, *why*. Roughly 13,000 lines of
-JavaScript across 46 modules in `app/`, with **no production dependencies** — the
+How the extension is put together and, more usefully, *why*. Roughly 22,000 lines of
+JavaScript across 73 modules in `app/`, with **no production dependencies** — the
`dependencies` field in `package.json` is empty and the packaged `.vsix` contains no
`node_modules` at all.
@@ -99,6 +99,7 @@ identical traffic regardless of tier. Swapping loops changes how an action is
| `modelDiscovery.js` | Normalises `/api/tags`; falls back to `/api/show` only for incomplete entries. Caches by name+digest. |
| `modelCapability.js` | Tier rule, budget matrix, `canPlanTodos`. Indexes budgets through a `Map` so a settings-supplied key cannot reach a prototype member. |
| `intentRouter.js` | Decides, before routing, whether a message is conversation or work. Patterns only — no model call. `task` is the default and `chat` needs positive evidence, because a misread greeting costs one loop while a misread request is silently dropped. Consulted in Agent mode only. |
+| `commonSense.js` | Reads a request against the workspace before the model sees it. One near-match to a filename that does not exist is repaired with both names stated; two to four are asked about; none is left alone, because that is what creating a file looks like. Patterns and edit distance — no model call, for the same reason as `intentRouter`. |
| `promptRouter.js` | Assembles the system prompt and the tool set for the current mode. Plan and Ask modes **omit** the mutating tools rather than refusing them later, and a conversational message in Agent mode gets the same treatment: no loop, no tools, one reply. |
| `completionCheck.js` | Consulted when a loop is told the work is finished. Sends the model back **once** if nothing changed on a request that asked for a change, or if a file it just wrote still has an unimplemented function body. |
| `contextBuilder.js` | Assembles the prompt under a token budget, by priority. Redacts on the way in. |
@@ -123,6 +124,25 @@ its own item, what the earlier steps actually wrote, the files it names — and
decides what a step has *demonstrably done*, by comparing the change set against the
step's own text. Neither consults the model's account of itself.
+`scriptDiagnosis.js`, `errorRecovery.js`, and `clarification.js` are what happens when a
+step does not work, and they are three modules rather than one because they answer three
+different questions. The diagnosis says *what this failure is* and is pure pattern
+matching over what a program printed. Recovery says *whether the model is learning from
+it*, by comparing failures across a turn on a normalised signature — line numbers and
+absolute paths removed, so the same problem is recognised across attempts and machines.
+Clarification says *what the user is asked* once the answer to the second question is no.
+
+The ladder is short on purpose: guidance on the first failure, the user on the second.
+Not a round number — `reactLoop.REPEAT_LIMIT` is 2, so the loop ends a run once the model
+has sent the same action twice, and a ladder that waited for a third would never reach
+its top rung. The second failure is the last moment at which asking can change anything.
+
+The rule that keeps this from becoming an agent that interviews its user: **the user is
+asked last, and only about something that has already failed with targeted guidance in
+between.** A session constructed without an `onClarify` handler — a benchmark, a detached
+tab — never asks at all and tops out at guidance, which is why no run can block on a
+question nobody can see.
+
### `app/security/` — the part that is allowed to say no
`permissionGate.js` (chokepoint) · `permissionModes.js` (two independent toggles) ·
@@ -191,8 +211,8 @@ because a session with nothing on disk yet still owns its number.
| Suite | Command | What it proves |
|---|---|---|
-| Unit (573) | `npm run test:unit` | Logic, guards, parsing, permission decisions — against a stubbed `vscode`. |
-| Integration (12) | `npm run test:integration` | Activation, command registration, webview protocol, and a full turn to disk **in a real VS Code**, against a stub Ollama on loopback. |
+| Unit (1323) | `npm run test:unit` | Logic, guards, parsing, permission decisions — against a stubbed `vscode`. |
+| Integration (16) | `npm run test:integration` | Activation, command registration, webview protocol, and a full turn to disk **in a real VS Code**, against a stub Ollama on loopback. |
| Live benchmark | `node tools/bench-agent.js ` | What a real model does to a real workspace. |
| Build benchmark | `node tools/bench-build.js --machine ` | Whether a model can build a project from an empty folder — add, read, run, and modify — in Java, JavaScript, and Python. Results land in `benchmarks/results//`. |
diff --git a/docs/images/capabilities.png b/docs/images/capabilities.png
index 697cc193b8aefe55d0b9be2db92e254a70f3a860..b283e574d6a7c84db6218837f00386ceb5bf4f95 100644
GIT binary patch
literal 478533
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