diff --git a/.vscodeignore b/.vscodeignore
index 0a76297..7bc3fc3 100644
--- a/.vscodeignore
+++ b/.vscodeignore
@@ -67,3 +67,8 @@ docs/assets/**
# The activity bar glyph. Referenced by `contributes.viewsContainers`, so leaving it out
# ships an extension whose sidebar icon is a blank square.
!docs/assets/activity-bar.svg
+
+# The marketing images and the HTML they are rendered from. README references them by
+# absolute raw.githubusercontent URL, exactly as it does the icon, so the Marketplace
+# listing renders them without a megabyte of PNG riding along in every install.
+docs/images/**
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02319ed..5e9ba66 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,114 @@ 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.6.1] — unreleased
+
+A macOS run of the same React TODO spec on the 0.6.0 build, `ornith:9b`, high thinking
+capacity, step sessions on. Eight steps, four of them refused, and the run stopped at
+item one of six reporting *"nothing was written"* — for a step whose two commands had
+both exited 0 with the scaffolded project sitting on disk. Four separate faults stacked
+up to produce that, and none of them were the model being small.
+
+### Added — the agent is told what machine it is on
+
+Three of the four refusals were the model reaching for shell syntax it had no way to know
+was unavailable: `mkdir -p todo-glass-app && cd todo-glass-app && npm create vite@latest .`,
+then `mkdir -p todo-glass-app`, then `cd todo-glass-app && npm install`. `mkdir -p` is
+POSIX; the same model on Windows proposes `md`. Both are wrong here for the same reason —
+there is no shell, and `mkdir` is not on the allow-list — but nothing in the prompt said
+which OS this was, and nothing said the shell was absent either. The model was guessing at
+the platform *and* at the execution model, and a guess wrong on both is refused three
+times before the step dies.
+
+`core/environmentProfile` detects the machine — OS, release, architecture, Node version —
+and states it in the system prompt as fact, alongside the sentence that matters more:
+there is no shell on any platform, `cwd` is how you run somewhere else, and `create_folder`
+/ `list_files` / `read_file` / `write_file` are what replace the shell utilities. Detected
+per session from `os.platform()`, never read back from disk, so a workspace synced between
+two machines never reports the other one's OS.
+
+- Carried on the tool-using routes only. Every line of it is about performing an action,
+ and Ask mode has no tools; a Plan prompt gets the platform facts without the lines
+ naming tools Plan mode structurally does not have.
+- Positioned by a `{environment}` placeholder in both prompt files, and appended when the
+ placeholder is absent — a customised prompt file cannot leave the model guessing at the
+ platform by not knowing about it.
+- Written to `.hirayacoder/environment.json` for the user to read. Nothing in the prompt
+ path reads it back; it is there so a session's records say which machine produced them.
+ Every benchmark folder in this repo had its OS reconstructed from the shape of the
+ commands the model tried.
+
+### Added — `.hirayacoder/` is ignored by git from the first session
+
+`.hirayacoder/` holds the audit log, the outcome ledger, full session transcripts, and
+per-session memory. It has never been added to a workspace's `.gitignore`, and in every
+benchmark workspace it sits untracked beside a scaffolded project whose `.gitignore`
+lists `node_modules` and nothing else. A user who stages everything commits their whole
+conversation history.
+
+`core/workspaceBootstrap` now runs at activation: an existing `.gitignore` is **appended
+to**, never rewritten; nothing is appended if any line already covers `.hirayacoder` in
+any form, including a `!.hirayacoder` negation, which is a decision to respect rather than
+overrule; a missing one is created holding that single entry and nothing else, since
+guessing at `node_modules` and `dist` for a project whose language nobody has looked at is
+how a helpful default becomes a wrong one. Idempotent, and every failure is logged and
+swallowed — a session must still start on a read-only checkout.
+
+### Fixed — a scaffolder was refused for the absence of the project it creates
+
+`npm create vite@latest todo-glass-app -- --template react` was refused with
+`NO_PACKAGE_JSON`: *"there is no package.json in the workspace root or any folder above
+it."* That guard is right about `npm install`, which climbs out of the workspace and
+installs into whatever manifest it finds above — that is how a dependency once landed in
+this extension's own `package.json`. It is exactly wrong about `npm create`, `npm init`,
+and `npm exec`, where the missing manifest is the point.
+
+This was the first failure of the run and it cascaded: step one was "scaffold the
+project", the only command that does it was refused, and the model spent its remaining
+attempts on `mkdir -p` and `cd … &&` trying to satisfy a precondition that was never
+real. `npm create` / `init` / `exec` / `x`, `yarn create` / `init` / `dlx`, and `pnpm
+create` / `init` / `dlx` are now exempt. Everything that acts on an existing project is
+guarded exactly as before.
+
+### Fixed — `cd folder && command` is now taken, not refused for a fourth time
+
+0.6.0 added `cwd`, documented it in both system prompts, and made the refusal name it
+outright: *"If you were chaining `cd folder && …`, drop the `cd` and pass the folder as
+'cwd' instead."* The model sent `cd todo-glass-app && npm install`, was told exactly
+that, and sent the identical line again. Two of the eight steps in the run went on it.
+
+That is the third wording of the same instruction, and a small model reaching for `cd x
+&& y` is not failing to understand the rule — it is producing the only phrasing it has
+for "run this over there". The folder and the command are both in the string,
+unambiguously, and the extension was throwing them away to ask for them back. `run_script`
+now rewrites that one shape into the two arguments it already takes.
+
+Nothing is relaxed. Exactly one shape is accepted — a single leading `cd` into one
+relative folder, one `&&`, and a remainder with no operator left in it — and a second
+`&&`, a pipe, a redirect, `cd ..`, an absolute path, or a quoted path is left untouched
+and refused as before. The rewritten command goes through pre-flight, the permission
+gate, the allow-list, and the tokenizer; the folder goes through `pathGuard` like every
+other `cwd`; the confirmation prompt and the audit log show what will actually run. The
+model is told about the rewrite in the observation every time, against its own input,
+which teaches better than the sentence in the system prompt did.
+
+### Fixed — a step that ran a command was scored as having done nothing
+
+The step that ended the run had already succeeded. `npx create-vite` exit 0, `npm
+install` exit 0, twenty seconds of it, eleven files and a `node_modules` on disk. The
+guard failed it anyway, stopped the run, and skipped the five remaining items, because a
+scaffolder's output never passes through `write_file`: `ChangeSet.recordCommand` appends
+to `commands`, `ChangeSet.since` reads `files`, and the two never met. A step whose entire
+job is "scaffold the project" could not pass.
+
+`ChangeSet` now stamps commands with the same revision counter it stamps file changes
+with, `commandsSince` is its counterpart to `since`, and `stepGuard.verify` accepts a
+command that succeeded as evidence the step did work — reported as such, so a later step
+knows the agent wrote nothing itself before it assumes a file is there. The principle is
+unchanged: judged from evidence the extension holds, never from the model's account. A
+process it spawned and whose exit code it read is exactly that; it was being read from
+the wrong half of the change set.
+
## [0.6.0] — unreleased
Machine B, a full model round on the 0.5.3 build: every model was given the same
diff --git a/README.md b/README.md
index 137cafe..6f9cc61 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,10 @@
+
+
+
+
*A local Filipino-inspired AI coder that brings imagination and speed to your VS Code workflow.*
**HirayaCoder is a free AI coding assistant that runs entirely on your own computer.**
@@ -129,6 +133,10 @@ There is a longer, friendlier walkthrough in
## Using it day to day
+
+
+
+
### The three modes
There is a row of buttons at the top of the chat. You can ignore them at first —
diff --git a/app/agent/agentSession.js b/app/agent/agentSession.js
index 41dafd7..52e7a49 100644
--- a/app/agent/agentSession.js
+++ b/app/agent/agentSession.js
@@ -31,6 +31,7 @@ const promptRouter = require('../core/promptRouter');
const intentRouter = require('../core/intentRouter');
const toolRegistry = require('./toolRegistry');
const contextBuilder = require('../core/contextBuilder');
+const environmentProfile = require('../core/environmentProfile');
const projectOverview = require('../core/projectOverview');
const reactLoop = require('./reactLoop');
const nativeToolLoop = require('./nativeToolLoop');
@@ -70,7 +71,7 @@ class ChangeSet {
constructor() {
/** @type {Map} */
this.files = new Map();
- /** @type {Array<{command: string, exitCode: number | null, ok: boolean}>} */
+ /** @type {Array<{command: string, exitCode: number | null, ok: boolean, revision?: number}>} */
this.commands = [];
/**
* How many records this set has taken, ever.
@@ -118,7 +119,23 @@ class ChangeSet {
*/
recordCommand(entry) {
this.revision += 1;
- this.commands.push(entry);
+ // Stamped like a file change, and for the same question: `stepGuard` asks what *this
+ // step* did, and until 0.6.1 the commands half of the set could not answer it.
+ this.commands.push({ ...entry, revision: this.revision });
+ }
+
+ /**
+ * The commands run since a given revision.
+ *
+ * The counterpart to `since`, which reads the files half only. A step that scaffolds a
+ * project writes no files through the agent at all, so this is the only record that it
+ * did anything — see `stepGuard.ranSomething`.
+ *
+ * @param {number} revision
+ * @returns {Array<{command: string, ok: boolean}>}
+ */
+ commandsSince(revision) {
+ return this.commands.filter((entry) => (entry.revision || 0) > revision);
}
/** @returns {FileChange[]} */
@@ -459,6 +476,18 @@ class AgentSession {
*/
this.stepSessions = options.stepSessions === true;
+ /**
+ * The machine this session is running on, detected once.
+ *
+ * Held on the session rather than detected inside `promptRouter` per call: a
+ * six-item TODO run in step mode routes once per step, and the answer cannot change
+ * between them. Injected in tests to assert the Windows and POSIX prompts without
+ * needing the matching machine.
+ *
+ * @type {import('../core/environmentProfile').EnvironmentProfile}
+ */
+ this.environment = options.environment || environmentProfile.detect();
+
/**
* Earlier turns of this chat, set per `run` call.
*
@@ -519,6 +548,7 @@ class AgentSession {
thinkingCapacity: this.thinkingCapacity,
memory: await this._renderMemory(),
earnedHints: await this._earnedHints(mode),
+ environment: this.environment,
intent: intent.intent,
// "Read the README" should not be able to reach `run_script`, whatever the
// planner decides in between. See `intentRouter.isReadOnlyRequest`.
@@ -857,6 +887,9 @@ class AgentSession {
item: item.text,
stopReason: outcome.stopReason,
changed: changeSet.since(itemRevisionBefore),
+ // A scaffold step's whole output is a command's side effects, which never pass
+ // through `write_file` and so never appear above.
+ commands: changeSet.commandsSince(itemRevisionBefore),
steps: outcome.steps,
});
@@ -969,6 +1002,7 @@ class AgentSession {
thinkingCapacity: this.thinkingCapacity,
memory: await this._renderMemory({ about }),
earnedHints: await this._earnedHints(activeRoute.mode),
+ environment: this.environment,
intent: 'task',
});
} catch (err) {
diff --git a/app/agent/scriptPreflight.js b/app/agent/scriptPreflight.js
index 4de2df5..a33e1de 100644
--- a/app/agent/scriptPreflight.js
+++ b/app/agent/scriptPreflight.js
@@ -35,6 +35,46 @@ const NODE_RUNNERS = new Set(['npm', 'yarn', 'pnpm']);
*/
const IMPLICIT_SCRIPTS = new Set(['test', 'start', 'install', 'stop', 'restart']);
+/**
+ * Subcommands that exist to *create* a project rather than to act on one.
+ *
+ * The manifest check below exists because `npm install` with no `package.json` climbs
+ * out of the workspace and installs into whatever it finds above. These do not: `npm
+ * create vite@latest todo-glass-app` downloads `create-vite` and runs it against a
+ * folder it makes itself, `npm init` writes a manifest into the current directory, and
+ * `npm exec` / `npm x` are `npx` under another name — which was never guarded at all,
+ * since `npx` is not a package manager by `packageManagerOf`.
+ *
+ * Refusing them was the first failure of the macOS 0.6.0 benchmark and it cascaded
+ * through the whole run: step one was "scaffold the project", the only command that
+ * does it was refused for the absence of the very thing it was about to produce, and
+ * the model spent its remaining attempts on `mkdir -p` and `cd … &&` trying to satisfy
+ * a precondition that was never real.
+ *
+ * The rule this keeps: refuse only when being wrong is impossible. "npm cannot install
+ * into a project that does not exist" is certain. "npm cannot create a project where
+ * there is no project" is the opposite of certain — it is the one case where the
+ * missing manifest is the point.
+ */
+const PROJECT_CREATING_SUBCOMMANDS = new Map([
+ ['npm', new Set(['create', 'init', 'exec', 'x'])],
+ ['yarn', new Set(['create', 'init', 'dlx'])],
+ ['pnpm', new Set(['create', 'init', 'dlx'])],
+]);
+
+/**
+ * Is this command about to create a project rather than act on an existing one?
+ *
+ * @param {string} runner
+ * @param {string} command
+ * @returns {boolean}
+ */
+function createsProject(runner, command) {
+ const subcommand = (String(command || '').trim().split(/\s+/)[1] || '').toLowerCase();
+ const known = PROJECT_CREATING_SUBCOMMANDS.get(runner);
+ return Boolean(known && known.has(subcommand));
+}
+
/**
* @typedef {object} PreflightRefusal
* @property {string} code Machine-readable, matching the runner's refusal codes.
@@ -146,6 +186,10 @@ function preflight({ command, cwd, workspaceRoot }) {
const runner = packageManagerOf(command);
if (!runner || !workspaceRoot) return null;
+ // A scaffolder makes the project this module would otherwise insist already exists.
+ // Nothing below has anything useful to say about one, so it is left to run.
+ if (createsProject(runner, command)) return null;
+
/** @type {string} */
let directory;
try {
@@ -219,6 +263,8 @@ module.exports = {
parseRun,
packageManagerOf,
manifestDirectoryWithin,
+ createsProject,
NODE_RUNNERS,
IMPLICIT_SCRIPTS,
+ PROJECT_CREATING_SUBCOMMANDS,
};
diff --git a/app/agent/stepGuard.js b/app/agent/stepGuard.js
index 5101cdf..761fe82 100644
--- a/app/agent/stepGuard.js
+++ b/app/agent/stepGuard.js
@@ -46,8 +46,41 @@ const { namedFiles } = require('./stepBrief');
* @property {Array<{kind: string, path: string}>} changed
* Files this step created, edited, or deleted — this step's, not the session's.
* @property {import('./agentSession').AgentStep[]} steps
+ * @property {Array<{command: string, ok: boolean}>} [commands]
+ * Commands this step ran. See `ranSomething` for why a step that only ran commands is
+ * not a step that did nothing.
*/
+/**
+ * Did this step do its work through a command rather than through `write_file`?
+ *
+ * ## The step this was missing
+ *
+ * A scaffold step writes no files *through the agent*. `npx create-vite todo-glass-app`
+ * writes eleven of them and `npm install` writes several thousand, and the extension
+ * sees none of it: `ChangeSet.recordCommand` appends to `commands`, `ChangeSet.since`
+ * reads `files`, and the two never meet. So a step whose entire job is "scaffold the
+ * project" arrived here with an empty `changed` array and was judged to have produced
+ * "no work at all".
+ *
+ * That is what ended the macOS 0.6.0 benchmark run. Both commands the step needed had
+ * already succeeded — `npx create-vite` exit 0, `npm install` exit 0, twenty seconds of
+ * it, the project sitting on disk — and the run stopped with *"nothing was written"*
+ * and skipped the five remaining items. The user's report of the session was a wall of
+ * text about a scaffold that had in fact worked.
+ *
+ * The guard's principle is unchanged: judge from evidence the extension holds itself,
+ * never from the model's account. A process the extension spawned, whose exit code it
+ * read, is exactly that kind of evidence — it was simply being read from the wrong half
+ * of the change set.
+ *
+ * @param {StepEvidence} evidence
+ * @returns {boolean}
+ */
+function ranSomething(evidence) {
+ return (evidence.commands || []).some((entry) => entry && entry.ok);
+}
+
/**
* @typedef {object} Verdict
* @property {boolean} ok
@@ -115,6 +148,19 @@ function verify(evidence) {
const failedSteps = (evidence.steps || []).filter((step) => step.result && step.result.ok === false);
if (changed.length === 0) {
+ // A command that succeeded is work, and a scaffolder's output never passes through
+ // `write_file`. Reported rather than passed silently: the step is not failed, but
+ // "the agent wrote nothing itself" is the one thing a later step needs to know
+ // before it assumes a file is there.
+ if (ranSomething(evidence)) {
+ const ran = evidence.commands.filter((entry) => entry.ok).map((entry) => `\`${entry.command}\``);
+ return {
+ ok: true,
+ reason: 'ran',
+ detail: `no files were written directly, but ${ran.join(' and ')} ran and succeeded`,
+ };
+ }
+
// The distinction the user sees: a step that tried and was refused reads differently
// from one that never attempted anything, and only the first is worth naming a cause
// for.
diff --git a/app/agent/tools/runScript.js b/app/agent/tools/runScript.js
index c97e31f..ac2bbb0 100644
--- a/app/agent/tools/runScript.js
+++ b/app/agent/tools/runScript.js
@@ -207,6 +207,71 @@ function nextStepAfterRefusal(code, command) {
}
}
+/**
+ * Rewrite `cd folder && command` into the `cwd` this tool already takes.
+ *
+ * ## Why this is a rewrite and not another refusal message
+ *
+ * 0.6.0 added `cwd`, documented it in both system prompts, and made the refusal name it
+ * explicitly: *"If you were chaining `cd folder && …`, drop the `cd` and pass the folder
+ * as 'cwd' instead."* On the macOS benchmark run the model sent `cd todo-glass-app &&
+ * npm install` anyway, was told that, and sent `cd todo-glass-app && npm install` again.
+ * Two of the eight steps in the run went on it, and the step that was scaffolding the
+ * project died there.
+ *
+ * That is the third iteration of the same instruction, and the conclusion is that a
+ * small model reaching for `cd x && y` is not failing to understand the rule — it is
+ * producing the only phrasing it has for "run this over there", under a decoding
+ * distribution that a sentence in the system prompt does not move. The information the
+ * refusal wanted is *already in the command*: the folder and the command are both right
+ * there, unambiguously, and the extension is throwing them away to ask for them again.
+ *
+ * ## What this deliberately does not do
+ *
+ * It is not a shell, and it is not the beginning of one. Exactly one shape is accepted —
+ * a single leading `cd` into one relative folder, joined by one `&&`, followed by a
+ * remainder with no operator of any kind left in it. Anything else (a second `&&`, a
+ * pipe, a redirect, `cd ..`, an absolute path, a quoted path with a space) is left
+ * untouched and refused downstream exactly as before.
+ *
+ * Nothing here relaxes a guard. The rewritten command goes through `preflight`, the
+ * permission gate, the binary allow-list, and `tokenize` — which would still refuse it
+ * if any operator survived — and the folder goes through `pathGuard` like every other
+ * `cwd`. The user's confirmation prompt shows the rewritten form, which is what will
+ * actually run.
+ *
+ * @param {string} command
+ * @param {string} cwd The `cwd` already supplied, if any.
+ * @returns {{command: string, cwd: string} | null} null when the shape does not match.
+ */
+function unchainCd(command, cwd) {
+ // One `cd`, one relative folder, one `&&`, and a remainder. The folder is restricted
+ // to the characters a project directory actually uses, which is also what keeps a
+ // quoted path, a variable, or a second command out of it. A leading `./` is the one
+ // decoration allowed, because models write it constantly.
+ const match = /^\s*cd\s+(?:\.\/)?([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*)\/?\s*&&\s*(.+)$/.exec(String(command || ''));
+ if (!match) return null;
+
+ const folder = match[1];
+ const rest = match[2].trim();
+
+ // `cd ..`, `cd .`, and anything climbing through a parent is refused rather than
+ // rewritten: `pathGuard` would reject the resulting `cwd` anyway, and a refusal that
+ // names the real problem beats a rewrite that produces one.
+ if (!rest || folder.split('/').some((segment) => segment === '.' || segment === '..')) return null;
+ // A remainder that still contains an operator was never the simple case. Chaining two
+ // commands after one `cd` is a genuine ambiguity — which of them the user is approving
+ // is not answerable — so it keeps the refusal it has always had.
+ if (/[;&|<>`$(){}\n\r]/.test(rest)) return null;
+
+ return {
+ command: rest,
+ // A `cwd` the model also supplied is the folder the `cd` is relative to, which is
+ // how `cd` behaves and how the two would compose in a real shell.
+ cwd: cwd ? `${cwd.replace(/\/+$/, '')}/${folder}` : folder,
+ };
+}
+
/**
* Turn a completed run into an observation.
*
@@ -287,12 +352,35 @@ function describeServerProbe(command, result, budget, cwd) {
* @returns {Promise}
*/
module.exports = async function runScript(args, context) {
- const command = String(args.command || '').trim();
+ let command = String(args.command || '').trim();
if (!command) {
return { ok: false, observation: 'run_script needs a "command" to run.' };
}
- const cwd = String(args.cwd || '').trim();
+ let cwd = String(args.cwd || '').trim();
+
+ // `cd folder && command` says the same thing this tool's two arguments say. Taken
+ // rather than refused — see `unchainCd` for why a fourth wording of the refusal was
+ // not the answer.
+ const unchained = unchainCd(command, cwd);
+ /**
+ * Told to the model on whatever the outcome turns out to be.
+ *
+ * Stated every time rather than silently absorbed: the command that ran is not the
+ * command the model sent, and a tool that quietly substitutes one for the other makes
+ * a failure impossible to read. It doubles as the correction — the shape the model
+ * should have sent, shown against its own input, which teaches better than the same
+ * sentence sitting in the system prompt did.
+ */
+ let rewriteNote = '';
+ if (unchained) {
+ const original = command;
+ ({ command, cwd } = unchained);
+ logger.info(`run_script rewrote "${original}" as "${command}" in "${cwd}".`);
+ rewriteNote =
+ `\n(You sent \`${original}\`. There is no shell, so \`cd\` and \`&&\` cannot work — ` +
+ `it was run as {"command": "${command}", "cwd": "${cwd}"}. Send that shape yourself next time.)`;
+ }
// Before the user is asked anything: a command that provably cannot work is answered
// from the filesystem instead of costing a click, a subprocess, and a page of npm
@@ -302,7 +390,7 @@ module.exports = async function runScript(args, context) {
logger.info(`run_script pre-flight refused "${command}": ${blocked.code}`);
return {
ok: false,
- observation: `\`${command}\` was not run: ${blocked.reason}`,
+ observation: `\`${command}\` was not run: ${blocked.reason}${rewriteNote}`,
error: blocked.code,
detail: { command, cwd: cwd || undefined, reason: blocked.code, attempts: 0 },
};
@@ -322,7 +410,9 @@ module.exports = async function runScript(args, context) {
if (!decision.allowed) {
return {
ok: false,
- observation: `\`${command}\` was not run: ${decision.reason}${nextStepAfterRefusal(decision.code, command)}`,
+ observation:
+ `\`${command}\` was not run: ${decision.reason}` +
+ `${nextStepAfterRefusal(decision.code, command)}${rewriteNote}`,
error: decision.code,
};
}
@@ -352,7 +442,9 @@ module.exports = async function runScript(args, context) {
logger.warn(`run_script could not start "${command}": ${code || 'unknown'}`);
return {
ok: false,
- observation: `\`${command}\` could not be started: ${/** @type {Error} */ (err).message}${nextStepAfterRefusal(code, command)}`,
+ observation:
+ `\`${command}\` could not be started: ${/** @type {Error} */ (err).message}` +
+ `${nextStepAfterRefusal(code, command)}${rewriteNote}`,
error: code,
};
}
@@ -392,9 +484,10 @@ module.exports = async function runScript(args, context) {
// just how often — "this model hits MISSING_DEPENDENCY eleven times a week" is a
// fixable observation, "37 failed steps" is not.
error: startedCleanly || result.ok || !diagnosis ? undefined : diagnosis.reason,
- observation: startedCleanly
- ? describeServerProbe(command, result, budget, cwd)
- : describeRun(command, result, budget, cwd, { diagnosis, attempts }),
+ observation:
+ (startedCleanly
+ ? describeServerProbe(command, result, budget, cwd)
+ : describeRun(command, result, budget, cwd, { diagnosis, attempts })) + rewriteNote,
detail: {
command,
cwd: cwd || undefined,
@@ -417,4 +510,5 @@ module.exports.looksLikeStartupFailure = looksLikeStartupFailure;
module.exports.PROBE_MS = PROBE_MS;
module.exports.nextStepAfterRefusal = nextStepAfterRefusal;
module.exports.toolForRefusedCommand = toolForRefusedCommand;
+module.exports.unchainCd = unchainCd;
module.exports.TOOL_INSTEAD_OF = TOOL_INSTEAD_OF;
diff --git a/app/core/environmentProfile.js b/app/core/environmentProfile.js
new file mode 100644
index 0000000..7010c51
--- /dev/null
+++ b/app/core/environmentProfile.js
@@ -0,0 +1,212 @@
+'use strict';
+
+/* eslint-disable security/detect-non-literal-fs-filename --
+ * Every fs call here targets a path built from the VS Code workspace root and a fixed
+ * filename. No component derives from model output. */
+
+/**
+ * What machine this is, told to the model instead of left for it to guess.
+ *
+ * ## The failure this exists for
+ *
+ * A live macOS run of the React TODO benchmark on 0.6.0, `ornith:9b`, eight steps, four
+ * of them refused. Three of the four were the model reaching for shell syntax it had no
+ * way to know was unavailable:
+ *
+ * mkdir -p todo-glass-app && cd todo-glass-app && npm create vite@latest .
+ * mkdir -p todo-glass-app
+ * cd todo-glass-app && npm install
+ *
+ * `mkdir -p` is POSIX; the same model on Windows proposes `mkdir` with no flag, or `md`.
+ * Both are wrong here for the same reason — there is no shell and `mkdir` is not on the
+ * allow-list — but the model had nothing in its prompt saying so, and nothing saying
+ * which OS it was on either. It was guessing at the platform *and* at the execution
+ * model, and a guess that is wrong on both is refused three times before the step dies.
+ *
+ * The fix is not a better refusal message. It is that the model should never have been
+ * guessing: the extension knows the platform exactly, at no cost, before the first token
+ * is generated. So it says so, once, in the system prompt.
+ *
+ * ## Why this is also written to disk
+ *
+ * `.hirayacoder/environment.json` is not read back by the prompt path — the profile is
+ * recomputed each session, because a workspace opened on a different machine must not
+ * inherit the last machine's answer. The file is there so the *user* can see what the
+ * agent was told, the same way `audit.log` shows what it did, and so a benchmark run
+ * carries its platform with it. Every one of the eleven benchmark folders in this repo
+ * had to have its OS reconstructed from the shape of the commands the model tried.
+ *
+ * @module core/environmentProfile
+ */
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const logger = require('../utils/logger');
+const { platformName } = require('../utils/platform');
+
+/** Where the profile is persisted, relative to the workspace root. */
+const PROFILE_PATH = '.hirayacoder/environment.json';
+
+/**
+ * @typedef {object} EnvironmentProfile
+ * @property {'win32' | 'darwin' | 'linux' | 'other'} platform
+ * @property {string} osName Human-readable, e.g. 'macOS'.
+ * @property {string} osVersion `os.release()`, as reported.
+ * @property {string} arch
+ * @property {'cmd' | 'sh'} commandStyle
+ * Which family a *user* would type these commands in. Not what HirayaCoder spawns —
+ * it spawns no shell at all — but the right answer to "what would this command look
+ * like here", which is what a model needs when it tells the user to run something.
+ * @property {string} pathSeparator
+ * @property {string} nodeVersion
+ * @property {string} generatedAt ISO timestamp of this detection.
+ */
+
+/**
+ * The display name for a platform.
+ *
+ * @param {ReturnType} name
+ * @returns {string}
+ */
+function osNameFor(name) {
+ if (name === 'win32') return 'Windows';
+ if (name === 'darwin') return 'macOS';
+ if (name === 'linux') return 'Linux';
+ return 'an unrecognised Unix-like system';
+}
+
+/**
+ * Detect the machine this session is running on.
+ *
+ * Pure inspection of `os` and `process` — no spawning, no filesystem probing, nothing
+ * that could fail or take a measurable amount of time. This runs on every session start
+ * and must stay free.
+ *
+ * @param {object} [overrides] For tests.
+ * @param {string} [overrides.platform]
+ * @param {string} [overrides.release]
+ * @param {string} [overrides.arch]
+ * @returns {EnvironmentProfile}
+ */
+function detect(overrides = {}) {
+ const name = platformName(overrides.platform || os.platform());
+
+ return {
+ platform: name,
+ osName: osNameFor(name),
+ osVersion: overrides.release || os.release(),
+ arch: overrides.arch || os.arch(),
+ commandStyle: name === 'win32' ? 'cmd' : 'sh',
+ pathSeparator: name === 'win32' ? '\\' : '/',
+ nodeVersion: process.versions.node,
+ generatedAt: new Date().toISOString(),
+ };
+}
+
+/**
+ * The platform-specific half of the prompt block.
+ *
+ * Kept to the differences that actually changed a model's behaviour in the benchmark
+ * runs. A longer list of trivia costs Tier B budget it does not have — at Medium
+ * capacity a 1B model has roughly 1800 tokens for everything.
+ *
+ * @param {EnvironmentProfile} profile
+ * @returns {string[]}
+ */
+function platformNotes(profile) {
+ if (profile.platform === 'win32') {
+ return [
+ 'This is Windows: paths a user types use backslashes, and there is no `mkdir -p`, ' +
+ '`rm -rf`, `ls`, `cat`, `touch`, or `&&` available to you.',
+ 'When you tell the user a command to run themselves, write it for cmd/PowerShell, not bash.',
+ ];
+ }
+
+ return [
+ `This is ${profile.osName}: a user's own shell is POSIX, but you are not running in one — ` +
+ '`mkdir -p`, `rm -rf`, `ls`, `cat`, `touch`, and `&&` are all unavailable to you here.',
+ 'When you tell the user a command to run themselves, write it for bash/zsh.',
+ ];
+}
+
+/**
+ * The block that goes into the system prompt.
+ *
+ * Two things at once, and both halves are load-bearing. The platform facts stop the
+ * model guessing at the OS. The execution-model sentence stops it assuming that knowing
+ * the OS means it has that OS's shell — which is the more expensive mistake, because a
+ * model told "this is macOS" and nothing else proposes `mkdir -p` with *more*
+ * confidence, not less.
+ *
+ * Written as statements of fact rather than rules. This is not a policy the model is
+ * being asked to follow; it is the machine it is standing on.
+ *
+ * ## Why the mutating half is optional
+ *
+ * Plan mode does not have `write_file`, `create_folder`, or `run_script` — they are
+ * absent from the schema, not merely refused, and that structural guarantee is what
+ * Plan mode *is*. A block naming them would be the one place in a Plan prompt that
+ * tells the model it can run commands, which is exactly the confusion the mode exists
+ * to prevent. So the platform facts are carried everywhere the block is used, and the
+ * lines about acting are carried only where acting is possible.
+ *
+ * @param {EnvironmentProfile} profile
+ * @param {object} [opts]
+ * @param {boolean} [opts.mutating] Does this route actually offer the write tools?
+ * Defaults to true; `promptRouter` passes the route's own answer.
+ * @returns {string}
+ */
+function render(profile, opts = {}) {
+ const mutating = opts.mutating !== false;
+
+ const lines = [
+ 'Environment — this is the machine you are running on right now. It is detected, not ' +
+ 'guessed, so never speculate about the operating system or ask the user what it is:',
+ `- Operating system: ${profile.osName} (${profile.platform} ${profile.osVersion}, ${profile.arch})`,
+ `- Node.js: v${profile.nodeVersion}`,
+ ...platformNotes(profile).map((note) => `- ${note}`),
+ ];
+
+ if (mutating) {
+ lines.push(
+ '- run_script does not use a shell on any platform. One plain command per call, no ' +
+ 'operators, no chaining, no redirects. To run inside a subfolder pass it as "cwd" ' +
+ '(`{"command": "npm install", "cwd": "todo-glass-app"}`) — never `cd`.',
+ '- To make a folder use the create_folder tool, to list one use list_files, to read a ' +
+ 'file use read_file, and to write one use write_file. Those replace the shell ' +
+ 'utilities on every platform.'
+ );
+ } else {
+ lines.push('- To list a folder use list_files, and to read a file use read_file.');
+ }
+
+ return lines.join('\n');
+}
+
+/**
+ * Persist the profile for the user to read, best-effort.
+ *
+ * Failure is logged and swallowed. Nothing in the prompt path reads this file, so a
+ * read-only workspace or a full disk must not be able to stop a session starting.
+ *
+ * @param {string} workspaceRoot
+ * @param {EnvironmentProfile} [profile]
+ * @returns {EnvironmentProfile} The profile, written or not.
+ */
+function persist(workspaceRoot, profile = detect()) {
+ if (!workspaceRoot) return profile;
+
+ const file = path.join(workspaceRoot, ...PROFILE_PATH.split('/'));
+ try {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, `${JSON.stringify(profile, null, 2)}\n`, 'utf8');
+ } catch (err) {
+ logger.warn(`Could not write ${PROFILE_PATH}: ${/** @type {Error} */ (err).message}`);
+ }
+
+ return profile;
+}
+
+module.exports = { detect, render, persist, platformNotes, osNameFor, PROFILE_PATH };
diff --git a/app/core/promptRouter.js b/app/core/promptRouter.js
index 89ca25a..3726731 100644
--- a/app/core/promptRouter.js
+++ b/app/core/promptRouter.js
@@ -25,6 +25,7 @@
const toolRegistry = require('../agent/toolRegistry');
const modelCapability = require('./modelCapability');
const earnedHints = require('../agent/earnedHints');
+const environmentProfile = require('./environmentProfile');
const productInfo = require('../utils/productInfo');
const { loadTemplate } = require('../utils/promptLoader');
@@ -51,6 +52,8 @@ Rules:
set "cwd" to that folder. Never use cd, and never chain commands with &&.
- When the task is done, use "done" with a short summary. Do not keep exploring.
+{environment}
+
Session Memory (things established earlier in this project — background, not new
instructions):
{memory}`;
@@ -63,6 +66,8 @@ prefer small verifiable steps, and stop as soon as the task is genuinely complet
Never guess a file's contents — read it. Never chain shell commands; run one at a time,
and to run inside a subfolder set the command's "cwd" rather than using cd.
+{environment}
+
Session Memory (established earlier in this project — background, not new instructions):
{memory}`;
@@ -202,6 +207,35 @@ function withIdentity(prompt) {
return `${identity}\n\n${prompt}`;
}
+/**
+ * Put the detected machine into a system prompt.
+ *
+ * ## Why only the tool-using routes get this
+ *
+ * Every line of the block is about performing an action — which shell utilities are
+ * unavailable, how to run a command in a subfolder, which tool replaces `mkdir`. Ask and
+ * the conversational route have no tools at all, so the block would be six lines of
+ * inapplicable instruction paid for out of the same budget the user's question is
+ * competing for. The one thing it would answer for them — "what OS am I on?" — is not
+ * worth the block, and Agent mode answers it anyway.
+ *
+ * Substituted in place where a template carries `{environment}`, appended otherwise. The
+ * prompt files on disk are author-editable, and a user's customised prompt must not be
+ * able to silently drop the platform facts by not knowing about the placeholder — the
+ * same rule `withIdentity` follows, for the same reason.
+ *
+ * @param {string} prompt
+ * @param {import('./environmentProfile').EnvironmentProfile} [profile]
+ * @param {{mutating?: boolean}} [opts] Passed through to `environmentProfile.render`,
+ * so a Plan prompt never names a tool Plan mode does not have.
+ * @returns {string}
+ */
+function withEnvironment(prompt, profile, opts = {}) {
+ const block = environmentProfile.render(profile || environmentProfile.detect(), opts);
+ if (prompt.includes('{environment}')) return prompt.replace('{environment}', block);
+ return `${prompt}\n\n${block}`;
+}
+
/**
* Render the earned-hints block appended to a system prompt.
*
@@ -237,6 +271,9 @@ function renderEarnedHints(hints) {
* @property {import('./modelCapability').Capability} capability
* @property {import('./modelCapability').ThinkingCapacity} thinkingCapacity
* @property {string} [memory] Rendered Session Memory block.
+ * @property {import('./environmentProfile').EnvironmentProfile} [environment]
+ * The detected machine. Detected here when omitted; passed in by `agentSession` so a
+ * six-item TODO run does not re-detect it per step.
* @property {string[]} [earnedHints] Sentences from `agent/earnedHints`, already selected.
* @property {'chat' | 'task'} [intent] From `core/intentRouter`. Only consulted in Agent mode.
* @property {boolean} [readOnlyTurn] From `intentRouter.isReadOnlyRequest`. Drops the
@@ -333,7 +370,9 @@ function route(request) {
.replace('{session_memory}', memory);
}
- systemPrompt = withIdentity(systemPrompt);
+ systemPrompt = withIdentity(
+ withEnvironment(systemPrompt, request.environment, { mutating: toolMode !== 'plan' })
+ );
if (mode === 'plan') systemPrompt += PLAN_SUFFIX;
if (readOnlyTurn) systemPrompt += READ_ONLY_SUFFIX;
@@ -378,6 +417,7 @@ module.exports = {
canMutate,
renderEarnedHints,
withIdentity,
+ withEnvironment,
READ_ONLY_SUFFIX,
ASK_SYSTEM,
CHAT_SYSTEM,
diff --git a/app/core/workspaceBootstrap.js b/app/core/workspaceBootstrap.js
new file mode 100644
index 0000000..6b5fc47
--- /dev/null
+++ b/app/core/workspaceBootstrap.js
@@ -0,0 +1,139 @@
+'use strict';
+
+/* eslint-disable security/detect-non-literal-fs-filename --
+ * Both files written here are fixed names under the VS Code workspace root. No
+ * component of either path derives from model output. */
+
+/**
+ * The two things a workspace needs before the first turn runs.
+ *
+ * Both are small, both are idempotent, and both were previously left to chance:
+ *
+ * 1. `.hirayacoder/environment.json` — what machine this is, so the model is told
+ * rather than guessing. See `core/environmentProfile` for the run that motivated it.
+ * 2. `.gitignore` carries `.hirayacoder/` — so a session's audit log, transcripts, and
+ * memory do not turn up in the user's next commit.
+ *
+ * ## Why the .gitignore entry is not optional
+ *
+ * `.hirayacoder/` holds `audit.log`, `outcomes.jsonl`, `transcripts/*.json`, and
+ * `memory/session*.txt`. Transcripts contain the full text of every message in a
+ * session, and memory contains file paths and command lines from the project. None of
+ * that is secret exactly, and all of it is noise in a diff — but the failure mode is a
+ * user committing their whole conversation history to a public repository without
+ * noticing, which is a privacy claim this extension makes and should therefore keep.
+ *
+ * Every benchmark workspace in `.ignore/` shows the same thing: `.hirayacoder/` sitting
+ * beside the scaffolded project, untracked and unmentioned, next to a `.gitignore` the
+ * scaffolder wrote that lists `node_modules` and nothing else.
+ *
+ * ## The rules this follows
+ *
+ * - An existing `.gitignore` is **appended to**, never rewritten. It is the user's file.
+ * - Nothing is appended if any existing line already covers `.hirayacoder`, whatever
+ * form it is written in — a second entry would be noise, and a `!.hirayacoder`
+ * negation is a decision to respect rather than to overrule.
+ * - A missing `.gitignore` is created with the one entry and nothing else. Guessing at
+ * `node_modules`, `dist`, and `.env` for a project whose language is unknown is how a
+ * helpful default becomes a wrong one.
+ * - Every failure is logged and swallowed. A session must start on a read-only checkout.
+ *
+ * @module core/workspaceBootstrap
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+const logger = require('../utils/logger');
+const environmentProfile = require('./environmentProfile');
+
+/** The entry written into `.gitignore`. Trailing slash: it is always a directory. */
+const IGNORE_ENTRY = '.hirayacoder/';
+
+/** The comment written above the entry, so a reader knows what put it there. */
+const IGNORE_COMMENT = '# HirayaCoder session data (audit log, transcripts, memory)';
+
+/**
+ * Does this `.gitignore` already have an opinion about `.hirayacoder`?
+ *
+ * Deliberately broad. `.hirayacoder`, `.hirayacoder/`, `/.hirayacoder`, `**\/.hirayacoder`,
+ * and `!.hirayacoder` all count — the question is whether the user has already dealt
+ * with this path, not whether they spelled it the way this module would.
+ *
+ * @param {string} contents
+ * @returns {boolean}
+ */
+function alreadyIgnored(contents) {
+ return String(contents || '')
+ .split(/\r?\n/)
+ .some((line) => {
+ const trimmed = line.trim();
+ if (!trimmed || trimmed.startsWith('#')) return false;
+ // Strip the negation marker and any leading/trailing path decoration, then compare
+ // on the bare name.
+ const bare = trimmed
+ .replace(/^!/, '')
+ .replace(/^\*\*\//, '')
+ .replace(/^\//, '')
+ .replace(/\/$/, '');
+ return bare === '.hirayacoder';
+ });
+}
+
+/**
+ * Add `.hirayacoder/` to the workspace's `.gitignore`, creating it if there is none.
+ *
+ * @param {string} workspaceRoot
+ * @returns {'created' | 'appended' | 'present' | 'failed'} What happened, for the log
+ * and for tests. Never throws.
+ */
+function ensureGitignore(workspaceRoot) {
+ if (!workspaceRoot) return 'failed';
+ const file = path.join(workspaceRoot, '.gitignore');
+
+ try {
+ if (!fs.existsSync(file)) {
+ fs.writeFileSync(file, `${IGNORE_COMMENT}\n${IGNORE_ENTRY}\n`, 'utf8');
+ logger.info('Created .gitignore with .hirayacoder/ ignored.');
+ return 'created';
+ }
+
+ const contents = fs.readFileSync(file, 'utf8');
+ if (alreadyIgnored(contents)) return 'present';
+
+ // Respect the file's own line ending, and never join the entry onto a last line
+ // that has no newline of its own.
+ const eol = contents.includes('\r\n') ? '\r\n' : '\n';
+ const separator = contents.length === 0 || contents.endsWith('\n') ? '' : eol;
+ fs.appendFileSync(file, `${separator}${eol}${IGNORE_COMMENT}${eol}${IGNORE_ENTRY}${eol}`, 'utf8');
+ logger.info('Added .hirayacoder/ to the existing .gitignore.');
+ return 'appended';
+ } catch (err) {
+ logger.warn(`Could not update .gitignore: ${/** @type {Error} */ (err).message}`);
+ return 'failed';
+ }
+}
+
+/**
+ * Prepare a workspace for agent sessions.
+ *
+ * Safe to call repeatedly — on activation, and again whenever a chat tab opens. Both
+ * halves are idempotent, and the environment profile is deliberately rewritten each
+ * time rather than reused: a workspace synced between two machines must not report the
+ * other one's operating system.
+ *
+ * @param {string | null} workspaceRoot
+ * @returns {{profile: import('./environmentProfile').EnvironmentProfile, gitignore: string} | null}
+ * null when no workspace is open.
+ */
+function bootstrap(workspaceRoot) {
+ if (!workspaceRoot) return null;
+
+ const profile = environmentProfile.persist(workspaceRoot);
+ const gitignore = ensureGitignore(workspaceRoot);
+
+ logger.debug(`Workspace ready: ${profile.osName} (${profile.platform}), .gitignore ${gitignore}.`);
+ return { profile, gitignore };
+}
+
+module.exports = { bootstrap, ensureGitignore, alreadyIgnored, IGNORE_ENTRY, IGNORE_COMMENT };
diff --git a/app/extension.js b/app/extension.js
index 4f783de..97f3f63 100644
--- a/app/extension.js
+++ b/app/extension.js
@@ -43,6 +43,8 @@ const diffApply = require('./features/diffApply');
const modelManager = require('./features/modelManager');
const { SessionsProvider } = require('./features/sessionsView');
const { TranscriptStore } = require('./core/transcriptStore');
+const workspaceBootstrap = require('./core/workspaceBootstrap');
+const environmentProfile = require('./core/environmentProfile');
/** Workspace-state key holding models the user has waved off the ">7B" nudge for. */
const DISMISSED_RECOMMENDATIONS_KEY = 'hirayacoder.dismissedRecommendations';
@@ -183,10 +185,24 @@ class HirayaCoder {
/** @type {FileHistory | null} */
this.fileHistory = null;
+ /**
+ * What machine this is, and a workspace prepared to hold a session's records.
+ *
+ * Done here rather than at the first turn so the profile exists before anything can
+ * read it, and so `.gitignore` covers `.hirayacoder/` before the first audit line is
+ * written rather than after the user has already staged it.
+ *
+ * @type {import('./core/environmentProfile').EnvironmentProfile}
+ */
+ this.environment = environmentProfile.detect();
+
this.buildClient();
this.buildSecurityLayer();
this.buildLedger();
this.buildSession();
+
+ const ready = workspaceBootstrap.bootstrap(workspaceRoot());
+ if (ready) this.environment = ready.profile;
}
/**
diff --git a/app/features/chatTab.js b/app/features/chatTab.js
index e86f965..b3615bc 100644
--- a/app/features/chatTab.js
+++ b/app/features/chatTab.js
@@ -600,6 +600,9 @@ class ChatTab {
// about this conversation.
ledger: this.app.ledger,
adaptation: this.app.settings.adaptation,
+ // Detected once at activation and shared, like the ledger and for the same reason:
+ // the operating system is a fact about the machine, not about this conversation.
+ environment: this.app.environment,
stepSessions: this.stepSessions,
images: images.map((image) => image.base64),
});
diff --git a/doc/FEATURES.md b/doc/FEATURES.md
index ca89fff..2b743f1 100644
--- a/doc/FEATURES.md
+++ b/doc/FEATURES.md
@@ -303,6 +303,16 @@ The parts that decide what the agent is *allowed* to do.
explains itself so the model can correct and retry.
- **An append-only audit log** of every action, decision, and permission mode, at
`.hirayacoder/audit.log` (**Show Audit Log**).
+- **A stated environment.** The operating system, its release, the architecture, and the
+ Node version are detected at session start and put in the system prompt as fact, along
+ with the note that no shell exists on any platform. A model that has to guess whether
+ `mkdir -p` or `md` is the right spelling has already lost the step; both are wrong here
+ for the same reason. Written to `.hirayacoder/environment.json` so a session's records
+ say which machine produced them.
+- **A workspace prepared before the first turn.** `.hirayacoder/` holds transcripts,
+ memory, and the audit log, so it is added to the workspace's `.gitignore` at startup —
+ appended to an existing file, never rewritten, and skipped entirely if any line already
+ covers it.
- **Honest summaries.** What actually failed is appended to the model's own account from
the step record, so a claim that a declined delete succeeded is contradicted in place.
diff --git a/doc/SAST-0.6.1.md b/doc/SAST-0.6.1.md
new file mode 100644
index 0000000..2f72a6b
--- /dev/null
+++ b/doc/SAST-0.6.1.md
@@ -0,0 +1,107 @@
+# SAST report — 0.6.1
+
+Run against the `fix/0.6.1-os-aware-scripts` branch. **Nothing here blocks the 0.6.1
+release**: no finding is exploitable by a remote party, and the two that matter are
+pre-existing behaviours rather than anything 0.6.1 introduced. They are recorded here to
+be fixed in **0.7.0**.
+
+## What was run
+
+| Pass | Tool | Result |
+|---|---|---|
+| Dependency audit, runtime | `npm audit --omit=dev` | **0 vulnerabilities** — the extension ships no runtime dependencies at all |
+| Dependency audit, full | `npm audit` | 3, all dev-only and all reached through `mocha` |
+| Static analysis | `eslint-plugin-security`, `eslint-plugin-no-unsanitized` | 0 errors, 18 warnings, all reviewed below |
+| Manual review | the 0.6.1 diff, the script path, the new filesystem writes | 3 findings |
+
+---
+
+## To fix in 0.7.0
+
+### 1. `npx` reaches the network and never asks — HIGH, pre-existing
+
+`scriptRunner.ALWAYS_CONFIRM` exists so that *"a handful of allow-listed subcommands
+publish code or reach the network"* always cost a click, even under auto-approve. It
+covers `git push/clone/fetch/pull`, `npm publish/login`, and `ollama pull`. It does not
+cover `npx`, which is on the default allow-list and whose entire purpose is to download a
+package from the npm registry and execute it.
+
+`NON_INTERACTIVE_ENV` sets `npm_config_yes: 'true'`, which is what suppresses npx's own
+*"Ok to proceed?"* prompt. Together that is: arbitrary remote code, fetched and run, with
+no confirmation from anyone, in an extension whose headline claim is that it works fully
+offline.
+
+This is not theoretical. `.ignore/3.todo-app-0.6.0/.hirayacoder/audit.log` line 7:
+
+```json
+{"action":"run_script","decision":"auto-approved","command":"npx create-vite@latest todo-glass-app -- --template react"}
+```
+
+A package was downloaded and executed on a live run, auto-approved, and the log records it
+as routine. The command was the right one and the package was the real one — the problem is
+that nothing checked, and the same path takes any package name the model emits.
+
+0.6.1 makes `npm exec` and `npm x` behave consistently with `npx` (see the pre-flight
+change), which does not widen the hole — `npx` was always there — but it does mean the fix
+must cover all four spellings.
+
+**Suggested fix:** add `npx`, `npm exec`, `npm x`, `yarn dlx`, `pnpm dlx`, and `npm create`
+to `ALWAYS_CONFIRM` with the reason *"this downloads and runs a package from the internet"*.
+Scaffolding is a once-per-project action, so the cost is one click on the run where it
+matters and none on the rest.
+
+### 2. `workspaceBootstrap` writes two files without going through `pathGuard` — LOW, new in 0.6.1
+
+`ensureGitignore` and `environmentProfile.persist` do `fs.appendFileSync` /
+`fs.writeFileSync` on `path.join(workspaceRoot, …)` directly. Every other write in the
+extension goes through `pathGuard`, which has an `assertRealPath` stage precisely because
+*"a lexically-clean path like `docs/notes` can still be a symlink pointing at
+`/etc`"* — and neither of these paths gets that check.
+
+Reaching it requires the user to have opened a workspace whose `.gitignore` is already a
+symlink pointing somewhere they did not intend, which is a strange thing to arrange for
+yourself. It is a real inconsistency with the module that exists to make this decision in
+one place, and it costs two lines to close.
+
+**Suggested fix:** route both through `pathGuard.assertRealPath`, and skip the write with a
+logged warning when it fails.
+
+### 3. Dev-only advisories in the `mocha` tree — MODERATE, dependency
+
+`serialize-javascript` (high, RCE), `diff` (low, DoS), both reached only through `mocha`,
+which is a `devDependency`. `npm audit --omit=dev` reports zero, and `.vscodeignore`
+excludes `test/**` and `node_modules`, so none of it is in the `.vsix` or in anything a
+user runs. The exposure is to whoever runs `npm test` on a checkout.
+
+`npm audit fix --force` moves to `mocha@11.3.0` and calls it a breaking change. Worth doing
+deliberately in 0.7.0 with the suite green, not as a drive-by.
+
+---
+
+## Reviewed and dismissed
+
+- **`security/detect-unsafe-regex` (11 warnings).** Includes `runScript.js:252`, the new
+ `unchainCd` pattern. Its nested quantifier is unambiguous — the inner alternative
+ requires a literal `/`, which the character class excludes — so there is no backtracking
+ explosion. Measured at 50,000 characters of adversarial input: **1 ms**. The other ten
+ are pre-existing and were checked the same way.
+- **`security/detect-object-injection` (3).** All three index into a fixed, code-owned map
+ or array; none takes its key from model output or from a file. The new
+ `PROJECT_CREATING_SUBCOMMANDS` lookup was written as a `Map` specifically so it does not
+ add a fourth.
+- **`security/detect-non-literal-regexp` (4).** `projectOverview` and `ignoreRules` build
+ patterns from workspace filenames. Worth keeping an eye on, but both are bounded by
+ filename length and neither pattern is ambiguous.
+- **`no-unsanitized` — no findings.** The webview still builds every node with
+ `createElement` + `textContent`. No `innerHTML` anywhere in `app/`.
+- **The `unchainCd` rewrite path.** The rewritten command reaches the gate, so the
+ confirmation prompt, the audit log, the allow-list, and `ALWAYS_CONFIRM` all see what
+ will actually run rather than what the model typed. The folder is composed only from a
+ character class that cannot express `..` and is rejected outright if a segment is `.` or
+ `..`, then confined by `pathGuard` like every other `cwd`.
+- **The pre-flight exemption.** It removes a check on `npm create`/`init`/`exec`/`x` only.
+ `npm install` — the command that actually climbs out of the workspace, and the one that
+ once installed a dependency into this extension's own `package.json` — is guarded
+ exactly as before, and there is a test pinning that.
+- **Loopback enforcement.** `assertLoopbackEndpoint` still rejects any non-loopback host,
+ covering the whole `127.0.0.0/8` range rather than just `127.0.0.1`.
diff --git a/docs/images/capabilities.png b/docs/images/capabilities.png
new file mode 100644
index 0000000..697cc19
Binary files /dev/null and b/docs/images/capabilities.png differ
diff --git a/docs/images/hero-offline-agent.png b/docs/images/hero-offline-agent.png
new file mode 100644
index 0000000..2010398
Binary files /dev/null and b/docs/images/hero-offline-agent.png differ
diff --git a/docs/images/src/README.md b/docs/images/src/README.md
new file mode 100644
index 0000000..d42f10a
--- /dev/null
+++ b/docs/images/src/README.md
@@ -0,0 +1,40 @@
+# Marketing image sources
+
+The two PNGs one folder up are rendered from the HTML beside this file. They are the
+source of truth — regenerate rather than editing the PNGs, so the next version bump is a
+one-line change instead of a redesign.
+
+| Source | Output | Used in |
+|---|---|---|
+| `hero-offline-agent.html` | `../hero-offline-agent.png` | README hero, Marketplace listing |
+| `capabilities.html` | `../capabilities.png` | README feature section |
+
+Both are self-contained: no fonts, scripts, or images are fetched, and the app icon is
+inlined as SVG. Everything renders from system fonts, so they look the same on any
+machine with a Chromium build on it.
+
+## Regenerating
+
+Any Chromium works — Chrome or Edge, whichever is installed. `--force-device-scale-factor=2`
+is what makes the output 2560×1440, which is what keeps the text sharp on a HiDPI screen:
+
+```bash
+chrome --headless=new --disable-gpu --hide-scrollbars \
+ --window-size=1280,720 --force-device-scale-factor=2 \
+ --virtual-time-budget=6000 \
+ --screenshot=docs/images/hero-offline-agent.png \
+ docs/images/src/hero-offline-agent.html
+```
+
+On Windows, `msedge.exe` takes the same flags and needs `--user-data-dir` pointed at a
+scratch folder — without it the launch is handed to the browser the user already has open
+and no screenshot is written.
+
+## When these need updating
+
+- **The version badge**, on every release. It appears once per file, as `v0.6.1`.
+- **The "New in …" line** on the *Knows your machine* card, whenever a release changes
+ what that card is claiming.
+- **The mock chat transcript** in the hero, if the panel's real layout changes enough that
+ the picture stops being an honest one. It is a mock, not a screenshot — but it should
+ never show something the extension does not do.
diff --git a/docs/images/src/capabilities.html b/docs/images/src/capabilities.html
new file mode 100644
index 0000000..c374249
--- /dev/null
+++ b/docs/images/src/capabilities.html
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
HirayaCoder
+
v0.6.1
+
for Visual Studio Code · powered by Ollama
+
+
+
An agent that does the work — and shows you every step.
+
+ Not autocomplete. HirayaCoder plans, reads, edits, and runs your project across many files,
+ then reports what actually happened — checked against what changed on disk, never against its own account of itself.
+
+
+
+
+
+
Nothing leaves your machine
+
Loopback-only by design — a non-local Ollama endpoint is rejected outright. No telemetry, no account, no cloud fallback.
+
+
+
+
+
You approve every change
+
Writes and deletes arrive as one reviewable diff. Commands run only from an allow-list you control, confined to your workspace.
+
+
+
+
+
Agentic on every model
+
Native tool-calling on 7B+, and a constrained one-action-per-turn loop that keeps even a 1B model doing real multi-file work.
+
+
+
+
+
Big requests become a checklist
+
Multi-part work is split into steps, run one at a time, and each step is verified against what it actually wrote before it can close.
+
+
+
+
+
Knows your machine
+
New in 0.6.1. OS, toolchain, and shell rules are detected and stated up front — so the agent never guesses mkdir -p vs md.
+
+
+
+
+
It learns your project
+
Session memory, an audit log, and per-model adaptation — a model that trips the same guard twice gets the correction, locally.
+
+
+
+
+
+
Agent
+
Plan
+
Ask
+
+
+ Three modes. In Plan and Ask the write tools are
+ structurally absent — not merely refused.
+
+
Free and open source · MIT Search “HirayaCoder” in the Extensions view
+ A real coding agent in VS Code — reads, writes, and runs your project — powered by
+ your own Ollama models. No account, no subscription, no internet.
+ Nothing you write ever leaves your laptop.
+
+
+
+
100% local
+
Free forever
+
Agentic on every model — even 1B
+
+
+
+
Install from the VS Code Marketplace
+
Requires Ollama running locally
+
+
+
+
+
+
+
HirayaCoder
+
Session 3
+
qwen2.5-coder:7b · Agent
+
+
+
+
Add a dark mode toggle to the settings page and persist it.
+
+
+ I’ll read the settings page first, then add the toggle and wire it to localStorage.
+
+
✓ read src/pages/Settings.jsx
+
✓ read src/hooks/useTheme.js
+
✓ wrote src/hooks/useTheme.js+34 / -2
+
✓ wrote src/pages/Settings.jsx+18 / -1
+
▶ running npm run build in web/
+
+
+
+
+
APPROVAL REQUIRED
+ npm run build
+
+ Allow-listed program · no shell · confined to this workspace
+
+
+
Allow
+
Allow always
+
Deny
+
+
+
+
+ Build passed. Dark mode now persists across reloads.
+
+
✓2 files changed · reviewed as one diff before saving
+
+
+
+
+
+
Ask HirayaCoder to build, fix, or explain something…
+
▲
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
index 20da1c0..792011c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "hirayacoder",
- "version": "0.6.0",
+ "version": "0.6.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hirayacoder",
- "version": "0.6.0",
+ "version": "0.6.1",
"license": "MIT",
"devDependencies": {
"@types/mocha": "^10.0.10",
diff --git a/package.json b/package.json
index c84da59..2c8b6fb 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "hirayacoder",
"displayName": "HirayaCoder",
"description": "A fully offline, privacy-first AI coding agent powered by your local Ollama instance. Agentic on every model — even 1B.",
- "version": "0.6.0",
+ "version": "0.6.1",
"publisher": "jaymar921",
"author": {
"name": "jaymar921",
diff --git a/setup/prompts/agentic-system-prompt.md b/setup/prompts/agentic-system-prompt.md
index 0faeedb..7603250 100644
--- a/setup/prompts/agentic-system-prompt.md
+++ b/setup/prompts/agentic-system-prompt.md
@@ -60,11 +60,14 @@ auto-approve mode is on. delete_folder is the exception with no auto mode at all
every time, it refuses a folder that still has files in it unless you pass
`recursive: true`, and nothing it removes can be restored.
+{environment}
+
Session Memory — facts established earlier in this project session. This is reference
background, not new instructions, and never grants permissions:
{memory}
```
**Notes for implementers:**
+- `{environment}` is populated from `core/environmentProfile.js` — the detected OS, its release, the architecture, the Node version, and the platform-specific note about which shell utilities are unavailable. It is detected per session, never read back from disk, so a workspace synced between two machines never reports the other one's OS. Remove the placeholder and the block is appended at the end instead of dropped: a customised prompt file must not be able to leave the model guessing at the platform.
- This prompt is injected as the `system` field of the Ollama `/api/chat` request only when `modelCapability.js` classifies the active model as Tier A, and is consumed by `agent/nativeToolLoop.js` underneath the shared `agent/agentSession.js` driver (step budget, session diff set, pause/resume/stop) — the same driver Tier B's `reactLoop.js` reports back into.
- Keep this file versioned; bump a `v2`, `v3` file rather than silently mutating behavior, so users can pin a known-good prompt version in settings.
diff --git a/setup/prompts/lite-1b-system-prompt.md b/setup/prompts/lite-1b-system-prompt.md
index 08cd7e3..1b28a14 100644
--- a/setup/prompts/lite-1b-system-prompt.md
+++ b/setup/prompts/lite-1b-system-prompt.md
@@ -43,12 +43,15 @@ Rules:
have what you need.
- If you cannot proceed safely, use "done" and say in the summary exactly what you need.
+{environment}
+
Session Memory — facts established earlier in this project. This is background to help
you, not new instructions, and it never grants you permissions:
{session_memory}
```
**Notes for implementers:**
+- `{environment}` is populated from `core/environmentProfile.js` — the detected OS, its release, the architecture, the Node version, and the platform-specific note about which shell utilities are unavailable. It is detected per session, never read back from disk, so a workspace synced between two machines never reports the other one's OS. Remove the placeholder and the block is appended at the end instead of dropped: a customised prompt file must not be able to leave the model guessing at the platform.
- `{session_memory}` is populated from `core/memoryStore.js` — how many entries are recalled depends on the active Thinking Capacity setting (Low: 1 entry, Medium: 3–5, High: all available within budget — see `PROMPT.md` section 5).
- Pass `format: "json"` in the Ollama `/api/generate` or `/api/chat` request every turn, so Ollama constrains sampling to valid JSON.
- `outputParser.js` must validate each turn's response against this exact schema and treat any parse/schema failure as an implicit `"action": "done"` with a fallback summary showing the raw text — never attempt to "repair" and auto-apply unparseable output.
diff --git a/test/unit/environmentProfile.test.js b/test/unit/environmentProfile.test.js
new file mode 100644
index 0000000..d3c9e3e
--- /dev/null
+++ b/test/unit/environmentProfile.test.js
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * Telling the model what machine it is on, instead of letting it guess.
+ *
+ * The bug these cover is not subtle and did not need a clever detector: on macOS the
+ * model proposed `mkdir -p`, on Windows it would have proposed `md`, and the extension
+ * knew the answer both times and never said it. So the assertions are about the two
+ * things that have to be true of the block — it names the real platform, and it never
+ * implies a shell exists — plus the one thing that must not happen, which is a Plan
+ * prompt naming a tool Plan mode does not have.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const environmentProfile = require('../../app/core/environmentProfile');
+
+describe('environmentProfile.detect', () => {
+ it('names each platform the way a person would', () => {
+ assert.strictEqual(environmentProfile.detect({ platform: 'darwin' }).osName, 'macOS');
+ assert.strictEqual(environmentProfile.detect({ platform: 'win32' }).osName, 'Windows');
+ assert.strictEqual(environmentProfile.detect({ platform: 'linux' }).osName, 'Linux');
+ });
+
+ it('reports the command style a user of this machine would type in', () => {
+ assert.strictEqual(environmentProfile.detect({ platform: 'win32' }).commandStyle, 'cmd');
+ assert.strictEqual(environmentProfile.detect({ platform: 'darwin' }).commandStyle, 'sh');
+ assert.strictEqual(environmentProfile.detect({ platform: 'linux' }).commandStyle, 'sh');
+ });
+
+ it('describes this machine without being told what it is', () => {
+ const profile = environmentProfile.detect();
+ assert.strictEqual(profile.platform === 'other' ? 'other' : profile.platform, profile.platform);
+ assert.ok(profile.osVersion.length > 0);
+ assert.strictEqual(profile.nodeVersion, process.versions.node);
+ assert.ok(!Number.isNaN(Date.parse(profile.generatedAt)));
+ });
+});
+
+describe('environmentProfile.render', () => {
+ it('states the operating system as a fact, not a question to ask the user', () => {
+ const block = environmentProfile.render(environmentProfile.detect({ platform: 'darwin', release: '24.1.0' }));
+ assert.ok(block.includes('macOS'));
+ assert.ok(block.includes('24.1.0'));
+ assert.ok(/never speculate/i.test(block));
+ });
+
+ it('says the POSIX utilities are unavailable even on a POSIX machine', () => {
+ // The mistake worth designing against: a model told "this is macOS" and nothing else
+ // proposes `mkdir -p` with more confidence, not less.
+ const block = environmentProfile.render(environmentProfile.detect({ platform: 'darwin' }));
+ assert.ok(block.includes('mkdir -p'));
+ assert.ok(/unavailable/i.test(block));
+ assert.ok(block.includes('"cwd"'));
+ });
+
+ it('writes the Windows note for a Windows machine', () => {
+ const block = environmentProfile.render(environmentProfile.detect({ platform: 'win32' }));
+ assert.ok(block.includes('Windows'));
+ assert.ok(/cmd\/PowerShell/.test(block));
+ assert.ok(!/bash\/zsh/.test(block));
+ });
+
+ it('names no mutating tool when the route has none', () => {
+ const block = environmentProfile.render(environmentProfile.detect({ platform: 'linux' }), { mutating: false });
+ for (const name of ['write_file', 'create_folder', 'run_script', 'delete_file']) {
+ assert.ok(!block.includes(name), `${name} was named in a read-only environment block`);
+ }
+ // The platform facts still travel — they are true whatever the mode is.
+ assert.ok(block.includes('Linux'));
+ });
+});
+
+describe('environmentProfile.persist', () => {
+ /** @type {string} */
+ let root;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-env-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ it('leaves the profile where a user can read what the agent was told', () => {
+ const written = environmentProfile.persist(root, environmentProfile.detect({ platform: 'darwin' }));
+ const onDisk = JSON.parse(fs.readFileSync(path.join(root, '.hirayacoder', 'environment.json'), 'utf8'));
+
+ assert.strictEqual(onDisk.osName, 'macOS');
+ assert.strictEqual(onDisk.platform, 'darwin');
+ assert.strictEqual(onDisk.nodeVersion, written.nodeVersion);
+ });
+
+ it('returns the profile rather than throwing when the file cannot be written', () => {
+ // No workspace is the ordinary case, not an error: the extension runs without one.
+ const profile = environmentProfile.persist('', environmentProfile.detect({ platform: 'linux' }));
+ assert.strictEqual(profile.osName, 'Linux');
+ });
+});
diff --git a/test/unit/promptRouter.test.js b/test/unit/promptRouter.test.js
index 768b09f..51b68fa 100644
--- a/test/unit/promptRouter.test.js
+++ b/test/unit/promptRouter.test.js
@@ -276,3 +276,77 @@ describe('toolRegistry mode filtering', () => {
assert.strictEqual(toolRegistry.get('rm_rf', 'agent'), null);
});
});
+
+/**
+ * The machine, stated rather than left for the model to infer.
+ *
+ * On the macOS 0.6.0 run the model was guessing at the platform *and* at the execution
+ * model, and a guess that is wrong on both is refused three times before the step dies.
+ * The extension knew the answer to the first before the first token was generated.
+ */
+describe('promptRouter — environment', () => {
+ const environmentProfile = require('../../app/core/environmentProfile');
+
+ /**
+ * @param {'agent'|'plan'|'ask'} mode
+ * @param {string} platform
+ * @param {object} capability
+ */
+ const routeOn = (mode, platform, capability = TIER_B) =>
+ promptRouter.route({
+ mode,
+ capability,
+ thinkingCapacity: 'medium',
+ memory: '- earlier fact',
+ environment: environmentProfile.detect({ platform }),
+ });
+
+ it('tells both tiers which operating system this is', () => {
+ for (const capability of [TIER_A, TIER_B]) {
+ assert.match(routeOn('agent', 'darwin', capability).systemPrompt, /macOS/);
+ assert.match(routeOn('agent', 'win32', capability).systemPrompt, /Windows/);
+ }
+ });
+
+ it('says the shell utilities are unavailable, whichever platform it is', () => {
+ for (const platform of ['darwin', 'linux', 'win32']) {
+ const prompt = routeOn('agent', platform).systemPrompt;
+ assert.match(prompt, /mkdir -p/, platform);
+ assert.match(prompt, /"cwd"/, platform);
+ }
+ });
+
+ it('lands where the template asks for it rather than at the end', () => {
+ // Both prompt files carry `{environment}` above the memory block. Substituted in
+ // place, so a customised file controls the position; appended when the placeholder
+ // is gone, so a customised file cannot drop the platform facts by omission.
+ const prompt = routeOn('agent', 'darwin').systemPrompt;
+ assert.ok(!prompt.includes('{environment}'), 'the placeholder was left unsubstituted');
+ assert.ok(prompt.indexOf('Operating system:') < prompt.indexOf('earlier fact'));
+
+ const appended = promptRouter.withEnvironment('a prompt with no placeholder', environmentProfile.detect());
+ assert.match(appended, /^a prompt with no placeholder\n\nEnvironment/);
+ });
+
+ it('names no mutating tool in a Plan prompt', () => {
+ // Plan mode's guarantee is that the write tools are absent, not refused. A block
+ // telling the model how to run a command would be the one place in the prompt
+ // saying otherwise.
+ const prompt = routeOn('plan').systemPrompt;
+ for (const name of MUTATING) {
+ assert.ok(!prompt.includes(name), `Plan prompt mentions ${name}`);
+ }
+ assert.match(prompt, /macOS|Windows|Linux/);
+ });
+
+ it('leaves the loopless routes alone', () => {
+ // Every line of the block is about performing an action, and Ask has no tools to
+ // perform one with.
+ assert.ok(!routeOn('ask').systemPrompt.includes('Operating system:'));
+ });
+
+ it('detects the machine itself when no profile is passed', () => {
+ const prompt = routeFor('agent').systemPrompt;
+ assert.match(prompt, /Operating system:/);
+ });
+});
diff --git a/test/unit/runScriptTool.test.js b/test/unit/runScriptTool.test.js
index 81e7d7d..c1a90fb 100644
--- a/test/unit/runScriptTool.test.js
+++ b/test/unit/runScriptTool.test.js
@@ -453,3 +453,88 @@ describe('the allow-list', () => {
}
});
});
+
+/**
+ * `cd folder && command` taken as the two arguments it already is.
+ *
+ * 0.6.0 added `cwd`, documented it in both prompts, and made the refusal name it. The
+ * macOS benchmark model sent `cd todo-glass-app && npm install` anyway, was told to use
+ * `cwd`, and sent the identical line again. The folder and the command are both in the
+ * string; the extension was throwing them away to ask for them back.
+ */
+describe('runScript.unchainCd', () => {
+ const { unchainCd } = runScript;
+
+ it('splits the folder off the command', () => {
+ assert.deepStrictEqual(unchainCd('cd todo-glass-app && npm install', ''), {
+ command: 'npm install',
+ cwd: 'todo-glass-app',
+ });
+ });
+
+ it('handles the shapes a model actually writes', () => {
+ assert.deepStrictEqual(unchainCd('cd ./app && npm run build', ''), { command: 'npm run build', cwd: 'app' });
+ assert.deepStrictEqual(unchainCd('cd packages/web/ && npm test', ''), {
+ command: 'npm test',
+ cwd: 'packages/web',
+ });
+ });
+
+ it('composes with a cwd the model also supplied, the way cd would', () => {
+ assert.deepStrictEqual(unchainCd('cd src && npm test', 'app'), { command: 'npm test', cwd: 'app/src' });
+ });
+
+ it('refuses rather than rewrites anything that is not the one simple shape', () => {
+ // Two commands after one `cd`: which of them the user is approving is not
+ // answerable, so it keeps the refusal it has always had.
+ assert.strictEqual(unchainCd('cd app && npm install && npm run build', ''), null);
+ assert.strictEqual(unchainCd('cd app && npm test | tee out.txt', ''), null);
+ assert.strictEqual(unchainCd('cd app && npm test > out.txt', ''), null);
+ assert.strictEqual(unchainCd('cd .. && npm install', ''), null);
+ assert.strictEqual(unchainCd('cd ../sibling && npm install', ''), null);
+ assert.strictEqual(unchainCd('cd /etc && npm install', ''), null);
+ assert.strictEqual(unchainCd('cd app &&', ''), null);
+ assert.strictEqual(unchainCd('cd app', ''), null);
+ // Not a rewrite target at all — no `cd` to unchain.
+ assert.strictEqual(unchainCd('npm install', ''), null);
+ assert.strictEqual(unchainCd('npm install && npm test', ''), null);
+ });
+
+ it('does not let a quoted path smuggle a second command through', () => {
+ assert.strictEqual(unchainCd('cd "my app" && npm install', ''), null);
+ assert.strictEqual(unchainCd('cd $(whoami) && npm install', ''), null);
+ assert.strictEqual(unchainCd('cd `pwd` && npm install', ''), null);
+ });
+});
+
+describe('runScript with a chained cd', () => {
+ it('runs the rewritten command and says so', async () => {
+ /** @type {any} */
+ let requested = null;
+ const context = {
+ sessionId: '1',
+ mode: 'agent',
+ gate: {
+ async requestScript(request) {
+ requested = request;
+ return { allowed: true, decision: 'approved' };
+ },
+ async runScript() {
+ return { ok: true, code: 0, stdout: 'added 42 packages', stderr: '', timedOut: false, durationMs: 12 };
+ },
+ },
+ };
+
+ const result = await runScript({ command: 'cd todo-glass-app && npm install' }, context);
+
+ // The gate — and therefore the confirmation prompt and the audit log — sees the
+ // command that will actually run, not the one the model typed.
+ assert.strictEqual(requested.command, 'npm install');
+ assert.strictEqual(requested.cwd, 'todo-glass-app');
+ assert.strictEqual(result.ok, true);
+ // And the model is told, every time. A tool that quietly substitutes one command
+ // for another makes a failure impossible to read.
+ assert.match(result.observation, /cd todo-glass-app && npm install/);
+ assert.match(result.observation, /"cwd": "todo-glass-app"/);
+ });
+});
diff --git a/test/unit/scriptPreflight.test.js b/test/unit/scriptPreflight.test.js
index d856393..c144f7b 100644
--- a/test/unit/scriptPreflight.test.js
+++ b/test/unit/scriptPreflight.test.js
@@ -175,4 +175,29 @@ describe('scriptPreflight — npm climbing out of the workspace', () => {
assert.strictEqual(preflight({ command: 'node src/main.js', workspaceRoot: root }), null);
assert.strictEqual(preflight({ command: 'git status', workspaceRoot: root }), null);
});
+
+ // The macOS 0.6.0 run died on the first step of the first item: the only command that
+ // creates a project was refused for the absence of the project it was about to create.
+ it('lets a scaffolder run in the empty workspace it is there to fill', () => {
+ for (const command of [
+ 'npm create vite@latest todo-glass-app -- --template react',
+ 'npm init -y',
+ 'npm exec create-vite todo-glass-app',
+ 'yarn create vite todo-glass-app',
+ 'pnpm dlx create-vite todo-glass-app',
+ ]) {
+ assert.strictEqual(preflight({ command, workspaceRoot: root }), null, command);
+ }
+ });
+
+ it('still refuses the commands that would climb out of the workspace', () => {
+ // The guard this preserves: `npm install` with no manifest walks upwards and
+ // installs into whatever it finds, which is how a dependency once landed in the
+ // extension's own package.json. Creating a project is the exception; acting on one
+ // that is not there is not.
+ for (const command of ['npm install lucide-react', 'npm run build', 'yarn add react']) {
+ const refusal = preflight({ command, workspaceRoot: root });
+ assert.strictEqual(refusal && refusal.code, 'NO_PACKAGE_JSON', command);
+ }
+ });
});
diff --git a/test/unit/stepGuard.test.js b/test/unit/stepGuard.test.js
index ba44542..23da92f 100644
--- a/test/unit/stepGuard.test.js
+++ b/test/unit/stepGuard.test.js
@@ -52,6 +52,42 @@ describe('stepGuard', () => {
assert.match(verdict.detail, /no work at all/);
});
+ // The macOS 0.6.0 run: `npx create-vite` exit 0, `npm install` exit 0, twenty
+ // seconds of it, the project on disk — and the step was written off as "nothing was
+ // written", which stopped the run and skipped the five remaining items. A
+ // scaffolder's output never passes through write_file, so the files half of the
+ // change set is empty and the commands half is the only record that anything
+ // happened.
+ it('passes a step whose work was done by a command that succeeded', () => {
+ const verdict = stepGuard.verify(
+ evidence({
+ item: 'Scaffold the Vite React project and install dependencies',
+ commands: [
+ { command: 'npx create-vite@latest todo-glass-app -- --template react', ok: true },
+ { command: 'npm install', ok: true },
+ ],
+ })
+ );
+
+ assert.strictEqual(verdict.ok, true);
+ assert.strictEqual(verdict.reason, 'ran');
+ assert.match(verdict.detail, /npm install/);
+ // Said out loud rather than passed silently: a later step needs to know the agent
+ // wrote nothing itself before it assumes a file is there.
+ assert.match(verdict.detail, /no files were written directly/);
+ });
+
+ it('still fails a step whose commands all failed', () => {
+ const verdict = stepGuard.verify(
+ evidence({
+ item: 'Scaffold the Vite React project',
+ commands: [{ command: 'npm install', ok: false }],
+ })
+ );
+ assert.strictEqual(verdict.ok, false);
+ assert.strictEqual(verdict.reason, 'no-change');
+ });
+
it('names the blocking error when the step tried and was refused', () => {
const verdict = stepGuard.verify(
evidence({
diff --git a/test/unit/stepSessions.test.js b/test/unit/stepSessions.test.js
index c1fa60e..fa2f617 100644
--- a/test/unit/stepSessions.test.js
+++ b/test/unit/stepSessions.test.js
@@ -299,4 +299,51 @@ describe('step sessions', function () {
const firstStep = client.prompts.slice(1, 3).join('\n');
assert.ok(firstStep.includes('useTodos.js'), 'the step was not given the note about the file it had to import');
});
+
+ // The macOS 0.6.0 run, reduced. Item one was "scaffold the project": `npx create-vite`
+ // exited 0, `npm install` exited 0, the project was on disk — and because a
+ // scaffolder's output never passes through `write_file`, the guard saw an empty change
+ // set, failed the step, and skipped the five items after it. The user's report was a
+ // page of text about a scaffold that had worked.
+ it('does not write off a step whose work was done by a command', async () => {
+ const modes = new PermissionModes({ initial: { autoEdit: true, autoApproveScripts: true } });
+ const client = scriptedClient([
+ '1. Set up the project toolchain\n2. Write src/App.jsx',
+ // Allow-listed, present wherever these tests run, and fast — this stands in for
+ // the scaffolder. What matters is that it succeeds and writes nothing itself.
+ json({ action: 'run_script', command: 'node --version' }),
+ json({ action: 'done', summary: 'toolchain ready' }),
+ json({
+ action: 'write_file',
+ path: 'src/App.jsx',
+ code: 'export default function App() {\n return
Todo
;\n}\n',
+ }),
+ json({ action: 'done', summary: 'app written' }),
+ ]);
+
+ const agent = new AgentSession({
+ client,
+ model: 'qwen3.5:4b',
+ capability: TIER_TODO,
+ gate: new PermissionGate({
+ workspaceRoot: root,
+ modes,
+ auditLog: new AuditLog(root),
+ confirm: async () => true,
+ }),
+ workspaceRoot: root,
+ thinkingCapacity: 'medium',
+ sessionId: '1',
+ stepSessions: true,
+ });
+
+ const result = await agent.run('Set up the toolchain, then write App.jsx', { mode: 'agent' });
+
+ assert.deepStrictEqual(
+ result.todos.map((item) => item.status),
+ ['done', 'done'],
+ 'the command-only step was written off, which stopped the run'
+ );
+ assert.ok(!/Stopped at step 1/.test(result.summary), result.summary);
+ });
});
diff --git a/test/unit/workspaceBootstrap.test.js b/test/unit/workspaceBootstrap.test.js
new file mode 100644
index 0000000..4667068
--- /dev/null
+++ b/test/unit/workspaceBootstrap.test.js
@@ -0,0 +1,119 @@
+'use strict';
+
+/**
+ * Getting a workspace ready before the first turn runs.
+ *
+ * The `.gitignore` half is the one with rules worth pinning down: it is the user's file,
+ * it may already say something about `.hirayacoder`, and it may not exist at all. The
+ * failure to avoid is not a missing entry — it is a rewritten file.
+ */
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const workspaceBootstrap = require('../../app/core/workspaceBootstrap');
+
+describe('workspaceBootstrap.alreadyIgnored', () => {
+ it('recognises every form the entry is written in', () => {
+ for (const line of ['.hirayacoder', '.hirayacoder/', '/.hirayacoder', '**/.hirayacoder', ' .hirayacoder/ ']) {
+ assert.ok(workspaceBootstrap.alreadyIgnored(`node_modules\n${line}\ndist\n`), `missed "${line}"`);
+ }
+ });
+
+ it('treats a negation as a decision already made', () => {
+ // `!.hirayacoder` is someone deliberately tracking it. Appending the positive entry
+ // underneath would silently overrule them.
+ assert.ok(workspaceBootstrap.alreadyIgnored('!.hirayacoder\n'));
+ });
+
+ it('is not fooled by a comment or by a longer name', () => {
+ assert.ok(!workspaceBootstrap.alreadyIgnored('# .hirayacoder\n'));
+ assert.ok(!workspaceBootstrap.alreadyIgnored('.hirayacoder-backup\n'));
+ assert.ok(!workspaceBootstrap.alreadyIgnored(''));
+ });
+});
+
+describe('workspaceBootstrap.ensureGitignore', () => {
+ /** @type {string} */
+ let root;
+
+ beforeEach(() => {
+ root = fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-bootstrap-'));
+ });
+
+ afterEach(() => {
+ fs.rmSync(root, { recursive: true, force: true });
+ });
+
+ /** @returns {string} */
+ const read = () => fs.readFileSync(path.join(root, '.gitignore'), 'utf8');
+
+ it('creates a .gitignore with the one entry when there is none', () => {
+ assert.strictEqual(workspaceBootstrap.ensureGitignore(root), 'created');
+ assert.ok(read().includes('.hirayacoder/'));
+ // Deliberately not guessing at node_modules or dist for a project whose language
+ // nobody has looked at yet.
+ assert.ok(!read().includes('node_modules'));
+ });
+
+ it('appends to an existing file without touching what is already in it', () => {
+ fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules\ndist\n', 'utf8');
+ assert.strictEqual(workspaceBootstrap.ensureGitignore(root), 'appended');
+
+ const contents = read();
+ assert.ok(contents.startsWith('node_modules\ndist\n'));
+ assert.ok(contents.includes('.hirayacoder/'));
+ });
+
+ it('does not join the entry onto a last line with no newline of its own', () => {
+ fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules', 'utf8');
+ workspaceBootstrap.ensureGitignore(root);
+
+ const lines = read().split('\n').map((line) => line.trim()).filter(Boolean);
+ assert.ok(lines.includes('node_modules'), 'the existing entry was corrupted');
+ assert.ok(lines.includes('.hirayacoder/'));
+ });
+
+ it('keeps a CRLF file on CRLF', () => {
+ fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules\r\n', 'utf8');
+ workspaceBootstrap.ensureGitignore(root);
+ assert.ok(!/[^\r]\n/.test(read()), 'a bare LF was introduced into a CRLF file');
+ });
+
+ it('adds nothing when the entry is already there', () => {
+ fs.writeFileSync(path.join(root, '.gitignore'), 'node_modules\n.hirayacoder\n', 'utf8');
+ assert.strictEqual(workspaceBootstrap.ensureGitignore(root), 'present');
+ assert.strictEqual(read(), 'node_modules\n.hirayacoder\n');
+ });
+
+ it('is idempotent across repeated sessions', () => {
+ workspaceBootstrap.ensureGitignore(root);
+ workspaceBootstrap.ensureGitignore(root);
+ workspaceBootstrap.ensureGitignore(root);
+
+ const occurrences = read().split('\n').filter((line) => line.trim() === '.hirayacoder/').length;
+ assert.strictEqual(occurrences, 1);
+ });
+});
+
+describe('workspaceBootstrap.bootstrap', () => {
+ it('does nothing at all without a workspace', () => {
+ assert.strictEqual(workspaceBootstrap.bootstrap(null), null);
+ });
+
+ it('leaves both the profile and the ignore entry behind', () => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'hiraya-bootstrap-'));
+ try {
+ const ready = workspaceBootstrap.bootstrap(root);
+
+ assert.ok(ready.profile.osName.length > 0);
+ assert.strictEqual(ready.gitignore, 'created');
+ assert.ok(fs.existsSync(path.join(root, '.hirayacoder', 'environment.json')));
+ assert.ok(fs.readFileSync(path.join(root, '.gitignore'), 'utf8').includes('.hirayacoder/'));
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+ });
+});