Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,58 @@ manifests for drift, refuses unmanaged file conflicts unless `--force` is
passed, removes stale managed mirrors only when safe, and writes local snapshots
before mutating managed files.

### Managed project context

`instructions project-context plan|apply` is the sole writer for the strict
`hasna.projects.project_context_bundle.v1` contract emitted by Projects. It
accepts bounded structured JSON from a regular file or stdin and never invokes
Projects, Todos, Conversations, or Mementos while rendering:

```bash
projects context-bundle <project-id> --json > ./project-context.json
instructions project-context plan \
--runtime codewith \
--workspace-root /absolute/workspace \
--bundle ./project-context.json \
--json
instructions project-context apply \
--runtime codewith \
--workspace-root /absolute/workspace \
--bundle ./project-context.json \
--json
```

The renderer writes one canonical `.hasna/instructions/project-context.md`
fragment, then a managed import in `CLAUDE.md`, a managed inline block in
`AGENTS.md`, and a managed inline block in `.codewith/CODEWITH.md` by default.
Codewith uses an import only when its existing
`HASNA_CONFIGS_CODEWITH_NATIVE_IMPORTS=1` capability gate is active or
`--codewith-native-imports` explicitly selects that supported runtime mode.
Bytes outside the managed marker pair are preserved. Codewith's
`.codewith/CODEWITH.override.md` takes precedence and causes the stable
`PROJECT_CONTEXT_SHADOWED` failure instead of an ignored write.
Existing Instructions session manifests are updated additively, and later
`instructions session plan|apply` runs recompose the validated durable project
context so routine Claude, Codewith, or Codex rerenders cannot discard it.

Input is limited to 8 KiB, output to 4 KiB and six allowlisted argv commands.
The writer rejects unknown fields, hash/revision inconsistencies, credentials,
URLs, symlinks, malformed or conflicting markers, and older revisions. Applies
use a per-workspace lock, compare-and-swap hashes, same-directory fsynced temp
files and renames, and a metadata-only manifest written last. Existing-file
updates require an atomic exchange primitive (Linux `renameat2` or macOS
`renameatx_np`); unsupported platforms, including Windows, fail closed before
replacement rather than approximating an exchange. A same-project,
compatible last-known-good cache can be selected explicitly with
`--allow-stale-cache --expected-project-id <id>`; its bounded age/status is
visible in the rendered context.

Compatibility remains additive: project-context manifests keep
`hasna.configs.session-render/v1`, `Managed by @hasna/configs`, and
`ownedBy: open-configs`, while recording `canonicalOwner: instructions`. The
legacy `@hasna/configs` 0.2.45 `configs` executable remains available as a bin
alias; no separate Configs repository or flag-day manifest v2 is introduced.

`instructions init` and `bun run seed` also seed
`global-agent-rules-standard`, the managed global/system prompt source for
session renaming, task-scoped worktrees, PR-first landing, protected-branch
Expand Down
1 change: 1 addition & 0 deletions hasna.contract.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"description": "AI coding agent instruction & configuration manager (formerly @hasna/configs). CLI + MCP + HTTP API (instructions-serve) + generated SDK. Cloud mode (HASNA_INSTRUCTIONS_DATABASE_URL set) is PURE REMOTE per Amendment A1: the serve process reads/writes the shared cloud Postgres directly with API-key auth via @hasna/contracts. The MCP server keeps the legacy name 'configs' aliased for the existing fleet.",
"bins": [
"instructions",
"configs",
"instructions-mcp",
"instructions-serve",
"configs-mcp"
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"types": "dist/index.d.ts",
"bin": {
"instructions": "dist/cli/index.js",
"configs": "dist/cli/index.js",
"instructions-mcp": "dist/mcp/index.js",
"instructions-serve": "dist/server/index.js",
"configs-mcp": "dist/mcp/index.js"
Expand Down
166 changes: 165 additions & 1 deletion src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { registerEventsCommands } from "@hasna/events/commander";
import { program } from "commander";
import chalk from "chalk";
import { existsSync, readFileSync, writeSync } from "node:fs";
import { existsSync, lstatSync, readFileSync, readSync, writeSync } from "node:fs";
import { homedir } from "node:os";
import { basename, join, resolve } from "node:path";
import { applyConfig, applyConfigs, expandPath } from "../lib/apply.js";
Expand All @@ -19,6 +19,14 @@ import { ensurePlatformProfiles } from "../lib/platform-profiles.js";
import { ensureProjectDashboardStandardConfig } from "../lib/project-dashboard-standard.js";
import { ensureGlobalAgentRulesStandardConfig } from "../lib/global-agent-rules-standard.js";
import { ensureDangerousOperationGuardStandardConfig } from "../lib/dangerous-operation-guard-standard.js";
import {
ProjectContextError,
PROJECT_CONTEXT_MAX_INPUT_BYTES,
applyProjectContext,
parseProjectContextBundle,
planProjectContext,
type ProjectContextRuntime,
} from "../lib/project-context.js";
import { getConfigsStatus } from "../status.js";
import { resolveConfigStore, isCloudMode, type ConfigStore } from "../data/config-store.js";
import { DEFAULT_LIST_LIMIT, paginate, parseLimit, truncateMiddle, truncateText } from "../lib/compact-output.js";
Expand Down Expand Up @@ -197,6 +205,62 @@ function planJsonForOutput(plan: SessionRenderPlan) {
};
}

function parseProjectContextRuntime(value: string): ProjectContextRuntime {
if (value === "codex") return "agents";
if (value === "claude" || value === "codewith" || value === "agents") return value;
throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `unsupported runtime ${value}; expected claude|codewith|agents|codex`);
}

function readProjectContextBundleOption(value: string | undefined, allowMissing = false): { json?: string; sourcePath?: string } {
if (value === undefined) return {};
if (value === "-") return { json: readBoundedProjectContextStdin() };
const path = resolveSessionPath(value);
if (!existsSync(path)) {
if (allowMissing) return {};
throw new ProjectContextError("PROJECT_CONTEXT_INPUT_MISSING", `bundle file not found: ${path}`);
}
const stat = lstatSync(path);
if (stat.isSymbolicLink() || !stat.isFile()) {
throw new ProjectContextError("PROJECT_CONTEXT_SYMLINK_REJECTED", "bundle input must be a regular non-symlink file");
}
if (stat.size > PROJECT_CONTEXT_MAX_INPUT_BYTES) {
throw new ProjectContextError("PROJECT_CONTEXT_INPUT_TOO_LARGE", `bundle exceeds ${PROJECT_CONTEXT_MAX_INPUT_BYTES} bytes`);
}
return { json: readFileSync(path, "utf8"), sourcePath: path };
}

function readBoundedProjectContextStdin(): string {
const chunks: Buffer[] = [];
const chunk = Buffer.allocUnsafe(4_096);
let total = 0;
while (true) {
const bytesRead = readSync(0, chunk, 0, chunk.length, null);
if (bytesRead === 0) break;
total += bytesRead;
if (total > PROJECT_CONTEXT_MAX_INPUT_BYTES) {
throw new ProjectContextError("PROJECT_CONTEXT_INPUT_TOO_LARGE", `bundle exceeds ${PROJECT_CONTEXT_MAX_INPUT_BYTES} bytes`);
}
chunks.push(Buffer.from(chunk.subarray(0, bytesRead)));
}
return Buffer.concat(chunks, total).toString("utf8");
}

function parsePositiveInteger(value: string | undefined, label: string): number | undefined {
if (value === undefined) return undefined;
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1) throw new ProjectContextError("PROJECT_CONTEXT_INVALID", `${label} must be a positive integer`);
return parsed;
}

function printProjectContextFailure(error: unknown, json: boolean): void {
const normalized = error instanceof ProjectContextError
? error
: new ProjectContextError("PROJECT_CONTEXT_FAILED", error instanceof Error ? error.message : String(error));
if (json) printJson({ ok: false, error: { code: normalized.code, message: normalized.message } });
else console.error(chalk.red(normalized.message));
process.exitCode = 1;
}

function parseVarArgs(values?: string[]): ProfileVariables | undefined {
if (!values || values.length === 0) return undefined;
const vars: ProfileVariables = {};
Expand Down Expand Up @@ -714,6 +778,102 @@ profileCmd.command("delete <id>").description("Delete a profile").action(async (
} catch (e) { console.error(chalk.red(e instanceof Error ? e.message : String(e))); process.exit(1); }
});

// ── project-context ──────────────────────────────────────────────────────────
const projectContextCmd = program.command("project-context")
.description("Validate and atomically render a strict Projects context bundle");

projectContextCmd.command("plan")
.description("Validate a bundle and preview its bounded provider adapter without writing")
.requiredOption("--runtime <runtime>", "selected consumer (claude|codewith|agents|codex)")
.requiredOption("--workspace-root <path>", "absolute project or coordination workspace root")
.requiredOption("--bundle <path|->", "durable v1 JSON file, or - for stdin")
.option("--codewith-native-imports", "declare that the selected Codewith runtime consumes native @ imports")
.option("--json", "output plan JSON")
.action((opts) => {
try {
const runtime = parseProjectContextRuntime(opts.runtime);
const input = readProjectContextBundleOption(opts.bundle);
const bundle = parseProjectContextBundle(input.json!);
const plan = planProjectContext({
workspace_root: resolveSessionPath(opts.workspaceRoot),
runtime,
bundle,
source_path: input.sourcePath,
codewith_native_imports: opts.codewithNativeImports,
});
const output = {
ok: true,
dry_run: true,
runtime: plan.runtime,
workspace_root: plan.workspace_root,
project_id: plan.bundle.project.id,
revision: plan.bundle.revision,
hash: plan.bundle.hash,
status: plan.status,
age_seconds: plan.age_seconds,
target_path: plan.target_path,
fragment_path: plan.fragment_path,
manifest_path: plan.manifest_path,
rendered_bytes: Buffer.byteLength(plan.fragment, "utf8"),
included_commands: plan.included_commands,
warnings: plan.warnings,
};
if (opts.json) printJson(output);
else {
console.log(chalk.bold("project context render plan"));
console.log(`${chalk.cyan("runtime:")} ${output.runtime}`);
console.log(`${chalk.cyan("project:")} ${output.project_id} @ ${output.revision}`);
console.log(`${chalk.cyan("target:")} ${output.target_path}`);
console.log(`${chalk.cyan("fragment:")} ${output.fragment_path} (${output.rendered_bytes} bytes)`);
console.log(chalk.dim("Dry run only. No files were written."));
}
} catch (error) {
printProjectContextFailure(error, opts.json === true);
}
});

projectContextCmd.command("apply")
.description("Atomically write project context with cache, CAS, and manifest-last semantics")
.requiredOption("--runtime <runtime>", "selected consumer (claude|codewith|agents|codex)")
.requiredOption("--workspace-root <path>", "absolute project or coordination workspace root")
.option("--bundle <path|->", "durable v1 JSON file, or - for stdin")
.option("--expected-project-id <id>", "required same-ID guard for stale-cache fallback")
.option("--allow-stale-cache", "use a compatible same-ID last-known-good cache when input is unavailable or a newer major")
.option("--max-stale-age-seconds <seconds>", "bounded cache age (default 3600, maximum 604800)")
.option("--codewith-native-imports", "declare that the selected Codewith runtime consumes native @ imports")
.option("--force", "repair malformed or mismatched managed markers while preserving bytes outside the forced range")
.option("--dry-run", "validate and preview without writing")
.option("--json", "output apply JSON")
.action((opts) => {
try {
const runtime = parseProjectContextRuntime(opts.runtime);
const input = readProjectContextBundleOption(opts.bundle, opts.allowStaleCache === true);
const result = applyProjectContext({
workspace_root: resolveSessionPath(opts.workspaceRoot),
runtime,
bundle_json: input.json,
source_path: input.sourcePath,
expected_project_id: opts.expectedProjectId,
allow_stale_cache: opts.allowStaleCache,
max_stale_age_seconds: parsePositiveInteger(opts.maxStaleAgeSeconds, "max stale age"),
codewith_native_imports: opts.codewithNativeImports,
force: opts.force,
dry_run: opts.dryRun,
});
if (opts.json) printJson({ ok: true, ...result });
else {
const prefix = result.dry_run ? chalk.yellow("[dry-run]") : chalk.green("OK");
console.log(`${prefix} project context ${result.runtime}`);
console.log(`${chalk.cyan("project:")} ${result.project_id} @ ${result.revision}`);
console.log(`${chalk.cyan("status:")} ${result.status} (${result.age_seconds}s)`);
console.log(`${chalk.cyan("target:")} ${result.target_path}`);
console.log(`${chalk.cyan("manifest:")} ${result.manifest_path}`);
}
} catch (error) {
printProjectContextFailure(error, opts.json === true);
}
});

// ── session ──────────────────────────────────────────────────────────────────
const sessionCmd = program.command("session").description("Plan and apply session-scoped agent instruction files");

Expand All @@ -728,6 +888,7 @@ sessionCmd.command("plan")
.option("--config <layer:id-or-slug>", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, [])
.option("--identity-export <path>", "OpenIdentities configs instruction export JSON; repeatable", collectOption, [])
.option("--replace-source <id>", "source id that replaces earlier layers instead of appending", collectOption, [])
.option("--codewith-native-imports", "select the gated Codewith native @ import adapter")
.option("--allow-empty-sources", "allow an explicit empty render plan")
.option("--json", "output dry-run JSON")
.action(async (opts) => {
Expand All @@ -744,6 +905,7 @@ sessionCmd.command("plan")
targetHome: opts.targetHome,
projectRoot: opts.projectRoot,
sessionId: opts.sessionId,
codewithNativeImports: opts.codewithNativeImports,
allowEmptySources: opts.allowEmptySources,
sources,
});
Expand Down Expand Up @@ -784,6 +946,7 @@ sessionCmd.command("apply")
.option("--config <layer:id-or-slug>", "stored config source by id/slug; repeatable; layer aliases match --source", collectOption, [])
.option("--identity-export <path>", "OpenIdentities configs instruction export JSON; repeatable", collectOption, [])
.option("--replace-source <id>", "source id that replaces earlier layers instead of appending", collectOption, [])
.option("--codewith-native-imports", "select the gated Codewith native @ import adapter")
.option("--allow-empty-sources", "allow an explicit empty render")
.option("--dry-run", "preview writes and conflicts without writing")
.option("--force", "overwrite existing unmanaged files")
Expand All @@ -802,6 +965,7 @@ sessionCmd.command("apply")
targetHome: opts.targetHome,
projectRoot: opts.projectRoot,
sessionId: opts.sessionId,
codewithNativeImports: opts.codewithNativeImports,
allowEmptySources: opts.allowEmptySources,
sources,
});
Expand Down
Loading
Loading