@dotdotgod/hermes 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,9 +29,9 @@ Do not enable a separate profile-wide dotdotgod MCP server for these tools.
29
29
  Optional semantic embeddings use the existing explicit installation workflow;
30
30
  map/README routing works without them. Do not automatically install dependencies.
31
31
 
32
- ## Gateway Repository Access
32
+ ## Project Routing
33
33
 
34
- The operator configures labels and principal-specific grants in config.yaml:
34
+ Optional project labels can be configured in config.yaml:
35
35
 
36
36
  ```yaml
37
37
  plugins:
@@ -42,27 +42,31 @@ plugins:
42
42
  roots:
43
43
  app: /absolute/path/to/app
44
44
  docs: /absolute/path/to/docs-project
45
- gateway_access:
46
- "telegram:123456": [app]
47
- "discord:987654": [docs]
48
45
  ```
49
46
 
50
- Use the trusted sender ID supplied by your Hermes gateway, not a display name.
51
- The model cannot grant itself a root or choose arbitrary filesystem paths.
52
- On an unbound gateway session the adapter requests dotdotgod_select_root with an
53
- authorized label before substantive work. Missing/changed grants deny access.
47
+ Host session IDs are optional bookkeeping, not permissions. Tools work before
48
+ pre-LLM registration. Without an ID, dotdotgod uses a disposable connection with
49
+ an explicit load root or cli_root/launch-directory default, never another chat's
50
+ selection. Ordinary host tools without known routing state pass through unchanged.
51
+ Missing parents and changed senders do not deny tools. Impact interception needs
52
+ known project state; nested host calls omitting it cannot be attributed reliably.
53
+
54
+ Any host session may select a configured label or an existing absolute directory
55
+ using dotdotgod_select_root. Legacy gateway_access settings are ignored.
56
+ Dotdotgod imposes no filesystem-area restrictions. Gateway operators must rely
57
+ on host permissions and OS isolation, not plugin labels, for access control.
54
58
  CLI starts at the launch directory; optional cli_root sets its repository.
55
59
 
56
- One bridge and MCP server are created lazily per authorized repository/host
60
+ One bridge and MCP server are created lazily per selected project/host
57
61
  session. Same-repository connections share the project SQLite DB, not mutable
58
62
  connection-session identity. Explicit context session_resume affects only the
59
63
  caller's connection; its last resumed ID survives transport reconnects.
60
- Selections persist in profile-owned plugin state and are reauthorized on restore.
64
+ Selections persist in profile-owned plugin state and are resolved on restore.
61
65
  Startup/restored sessions conservatively reassess project memory; abandoned
62
66
  sibling completion is not inherited.
63
67
 
64
68
  This is routing isolation, not multi-tenant OS isolation. Hermes and enabled
65
- plugins retain host permissions. Authorized users of the same repository share
69
+ plugins retain host permissions. Sessions using the same repository share
66
70
  project data; context scope/session filters are not access-control lists.
67
71
 
68
72
  ## Features
@@ -82,7 +86,7 @@ project data; context scope/session filters are not access-control lists.
82
86
  checked fingerprints. Successful checks clear only checked, unchanged paths.
83
87
  Source edits reopen pending state; reads and impact checks remain available.
84
88
  - Native Hermes delegate_task is the delegation mechanism. Child hooks inherit
85
- only an already authorized matching-principal parent repository, with a separate
89
+ an active matching-principal parent's project by default, with a separate
86
90
  context connection; impact covers their changes in the shared worktree.
87
91
 
88
92
  No dedicated planning state, wizard, source-edit approval UI, compaction feature,
@@ -95,8 +99,8 @@ Python/Node test commands are blocked while impact is pending. Execute batches
95
99
  are checked inside the adapter too. Opaque execute_file calls are blocked while
96
100
  pending because their effects cannot be inferred safely.
97
101
 
98
- Native file path arguments and terminal workdir are rooted to the selected local
99
- repository; reads may also access the installed bundled skill resources. This adapter targets local host worktrees, not remote/container path
102
+ Relative native file paths and terminal workdir default to the selected project;
103
+ explicit paths may refer anywhere accessible to the host. This adapter targets local host worktrees, not remote/container path
100
104
  translation. Shell paths, patch bodies, arbitrary programs and other plugins are
101
105
  not a filesystem sandbox; keep Hermes's own approvals and OS isolation.
102
106
  Shell mutations hidden inside one combined mutation/release command are not fully
package/__init__.py CHANGED
@@ -31,7 +31,7 @@ def register(ctx):
31
31
  }, handler=handle, description=tool["description"])
32
32
 
33
33
  for name, schema, description in [
34
- ("dotdotgod_select_root", {"type": "object", "properties": {"label": {"type": "string"}}, "required": ["label"], "additionalProperties": False}, "Select an operator-authorized repository for this host session."),
34
+ ("dotdotgod_select_root", {"type": "object", "properties": {"label": {"type": "string"}}, "required": ["label"], "additionalProperties": False}, "Select a project for this host session using a configured label or absolute directory path."),
35
35
  ("dotdotgod_impact_status", {"type": "object", "properties": {}, "additionalProperties": False}, "Read pending impact paths for the selected repository; does not clear them."),
36
36
  ]:
37
37
  def handle_local(args, session_id="", _name=name, **kwargs):
package/mcp/cli.mjs CHANGED
@@ -938,9 +938,6 @@ var DEFAULT_LOAD_POLICY = {
938
938
  exclude: ["docs/plan", "docs/archive"]
939
939
  }
940
940
  };
941
- var DEFAULT_PLAN_MODE_POLICY = {
942
- writablePaths: ["docs/plan/**", "docs/archive/**"]
943
- };
944
941
  var DEFAULT_IMPACT_RANKING_POLICY = {
945
942
  connectionCap: 80,
946
943
  memoryCap: 20,
@@ -1115,8 +1112,8 @@ function cloneLoadPolicy(policy = DEFAULT_LOAD_POLICY) {
1115
1112
  }
1116
1113
  };
1117
1114
  }
1118
- function clonePlanModePolicy(policy = DEFAULT_PLAN_MODE_POLICY) {
1119
- return { writablePaths: [...policy.writablePaths ?? DEFAULT_PLAN_MODE_POLICY.writablePaths] };
1115
+ function clonePlanModePolicy() {
1116
+ return {};
1120
1117
  }
1121
1118
  function normalizeDocumentationRoot(value = DEFAULT_DOCUMENTATION_ROOT) {
1122
1119
  return String(value).trim().replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/{2,}/g, "/").replace(/^\/+|\/+$/g, "");
@@ -1130,7 +1127,7 @@ function isValidDocumentationRoot(value) {
1130
1127
  }
1131
1128
  function defaultMemoryConfig({ documentationRoot = DEFAULT_DOCUMENTATION_ROOT } = {}) {
1132
1129
  const root = normalizeDocumentationRoot(documentationRoot);
1133
- return { source: "default", documentation: { root }, areas: memoryAreasForRoot(root).map(cloneArea), traceability: cloneTraceabilityPolicy({ ...DEFAULT_TRACEABILITY_POLICY, required: [`${root}/spec/**`] }), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), load: cloneLoadPolicy({ ...DEFAULT_LOAD_POLICY, documentationSummary: { exclude: [`${root}/plan`, `${root}/archive`] } }), planMode: clonePlanModePolicy({ writablePaths: [`${root}/plan/**`, `${root}/archive/**`] }) };
1130
+ return { source: "default", documentation: { root }, areas: memoryAreasForRoot(root).map(cloneArea), traceability: cloneTraceabilityPolicy({ ...DEFAULT_TRACEABILITY_POLICY, required: [`${root}/spec/**`] }), validation: cloneValidationPolicy(), impactRanking: cloneImpactRankingPolicy(), referenceExpansion: cloneReferenceExpansionPolicy(), load: cloneLoadPolicy({ ...DEFAULT_LOAD_POLICY, documentationSummary: { exclude: [`${root}/plan`, `${root}/archive`] } }), planMode: clonePlanModePolicy() };
1134
1131
  }
1135
1132
  function normalizePathPattern(value = "") {
1136
1133
  return value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/+$|^\/+/, "");
@@ -1191,10 +1188,8 @@ function isSecretLikePathPattern(value = "") {
1191
1188
  const normalized = normalizePathPattern(value);
1192
1189
  return /(^|\/)(\.env|\.npmrc|\.pypirc|id_rsa|id_dsa|id_ed25519|credentials?|secrets?)(\.|\/|$)/i.test(normalized);
1193
1190
  }
1194
- function normalizePlanModePolicy(raw) {
1195
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return clonePlanModePolicy();
1196
- const writablePaths = raw.writablePaths === void 0 ? DEFAULT_PLAN_MODE_POLICY.writablePaths : [...new Set((Array.isArray(raw.writablePaths) ? raw.writablePaths : []).map(normalizePathPattern))];
1197
- return clonePlanModePolicy({ writablePaths });
1191
+ function normalizePlanModePolicy(_raw) {
1192
+ return clonePlanModePolicy();
1198
1193
  }
1199
1194
  function normalizeLoadPolicy(raw) {
1200
1195
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return cloneLoadPolicy();
@@ -1339,10 +1334,6 @@ Fix: ${fix ?? `update ${field2 ?? "this config"} in ${file} to match the expecte
1339
1334
  if (planMode !== void 0) {
1340
1335
  if (!planMode || typeof planMode !== "object" || Array.isArray(planMode)) {
1341
1336
  add("PLAN_MODE_CONFIG_INVALID", "planMode", "Expected an object.");
1342
- } else if (planMode.writablePaths !== void 0) {
1343
- const paths = planMode.writablePaths;
1344
- if (!Array.isArray(paths)) add("PLAN_MODE_CONFIG_INVALID_WRITABLE_PATHS", "planMode.writablePaths", "Expected an array of documentation path strings.");
1345
- else if (paths.some((value) => !isValidPathPattern(value) || typeof value !== "string" || value.trim().startsWith("/") || !normalizePathPattern(value).startsWith(`${normalizeDocumentationRoot(data.documentation?.root ?? DEFAULT_DOCUMENTATION_ROOT)}/`) || normalizePathPattern(value).startsWith(`${normalizeDocumentationRoot(data.documentation?.root ?? DEFAULT_DOCUMENTATION_ROOT)}/.`) || isSecretLikePathPattern(value))) add("PLAN_MODE_CONFIG_INVALID_WRITABLE_PATHS", "planMode.writablePaths", "Expected safe repository-relative paths under documentation.root using exact paths or /** subtree patterns.");
1346
1337
  }
1347
1338
  }
1348
1339
  const referenceExpansion = data.referenceExpansion;
@@ -3804,7 +3795,6 @@ function formatConfigOutput(payload) {
3804
3795
  lines.push(`- load pinned paths: ${(payload.config.load?.pinnedPaths ?? []).join(", ") || "none"}`);
3805
3796
  lines.push(`- load pinned bodies: ${(payload.config.load?.pinnedBodies ?? []).join(", ") || "none"}`);
3806
3797
  lines.push(`- load documentation summary exclude: ${(payload.config.load?.documentationSummary?.exclude ?? []).join(", ") || "none"}`);
3807
- lines.push(`- plan mode writable paths: ${(payload.config.planMode?.writablePaths ?? []).join(", ") || "none"}`);
3808
3798
  const lowSignal = payload.config.referenceExpansion?.fuzzy?.lowSignal ?? { terms: [], add: [], remove: [] };
3809
3799
  lines.push(`- fuzzy low-signal terms: ${(lowSignal.terms ?? []).join(", ") || "none"} (add: ${(lowSignal.add ?? []).join(", ") || "none"}; remove: ${(lowSignal.remove ?? []).join(", ") || "none"})`);
3810
3800
  if (errors.length > 0) {
package/mcp/server.mjs CHANGED
@@ -3650,49 +3650,49 @@ var require_fast_uri = __commonJS({
3650
3650
  schemelessOptions.skipEscape = true;
3651
3651
  return serialize(resolved, schemelessOptions);
3652
3652
  }
3653
- function resolveComponent(base, relative4, options, skipNormalization) {
3653
+ function resolveComponent(base, relative3, options, skipNormalization) {
3654
3654
  const target = {};
3655
3655
  if (!skipNormalization) {
3656
3656
  base = parse3(serialize(base, options), options);
3657
- relative4 = parse3(serialize(relative4, options), options);
3657
+ relative3 = parse3(serialize(relative3, options), options);
3658
3658
  }
3659
3659
  options = options || {};
3660
- if (!options.tolerant && relative4.scheme) {
3661
- target.scheme = relative4.scheme;
3662
- target.userinfo = relative4.userinfo;
3663
- target.host = relative4.host;
3664
- target.port = relative4.port;
3665
- target.path = removeDotSegments(relative4.path || "");
3666
- target.query = relative4.query;
3660
+ if (!options.tolerant && relative3.scheme) {
3661
+ target.scheme = relative3.scheme;
3662
+ target.userinfo = relative3.userinfo;
3663
+ target.host = relative3.host;
3664
+ target.port = relative3.port;
3665
+ target.path = removeDotSegments(relative3.path || "");
3666
+ target.query = relative3.query;
3667
3667
  } else {
3668
- if (relative4.userinfo !== void 0 || relative4.host !== void 0 || relative4.port !== void 0) {
3669
- target.userinfo = relative4.userinfo;
3670
- target.host = relative4.host;
3671
- target.port = relative4.port;
3672
- target.path = removeDotSegments(relative4.path || "");
3673
- target.query = relative4.query;
3668
+ if (relative3.userinfo !== void 0 || relative3.host !== void 0 || relative3.port !== void 0) {
3669
+ target.userinfo = relative3.userinfo;
3670
+ target.host = relative3.host;
3671
+ target.port = relative3.port;
3672
+ target.path = removeDotSegments(relative3.path || "");
3673
+ target.query = relative3.query;
3674
3674
  } else {
3675
- if (!relative4.path) {
3675
+ if (!relative3.path) {
3676
3676
  target.path = base.path;
3677
- if (relative4.query !== void 0) {
3678
- target.query = relative4.query;
3677
+ if (relative3.query !== void 0) {
3678
+ target.query = relative3.query;
3679
3679
  } else {
3680
3680
  target.query = base.query;
3681
3681
  }
3682
3682
  } else {
3683
- if (relative4.path[0] === "/") {
3684
- target.path = removeDotSegments(relative4.path);
3683
+ if (relative3.path[0] === "/") {
3684
+ target.path = removeDotSegments(relative3.path);
3685
3685
  } else {
3686
3686
  if ((base.userinfo !== void 0 || base.host !== void 0 || base.port !== void 0) && !base.path) {
3687
- target.path = "/" + relative4.path;
3687
+ target.path = "/" + relative3.path;
3688
3688
  } else if (!base.path) {
3689
- target.path = relative4.path;
3689
+ target.path = relative3.path;
3690
3690
  } else {
3691
- target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative4.path;
3691
+ target.path = base.path.slice(0, base.path.lastIndexOf("/") + 1) + relative3.path;
3692
3692
  }
3693
3693
  target.path = removeDotSegments(target.path);
3694
3694
  }
3695
- target.query = relative4.query;
3695
+ target.query = relative3.query;
3696
3696
  }
3697
3697
  target.userinfo = base.userinfo;
3698
3698
  target.host = base.host;
@@ -3700,7 +3700,7 @@ var require_fast_uri = __commonJS({
3700
3700
  }
3701
3701
  target.scheme = base.scheme;
3702
3702
  }
3703
- target.fragment = relative4.fragment;
3703
+ target.fragment = relative3.fragment;
3704
3704
  return target;
3705
3705
  }
3706
3706
  function equal(uriA, uriB, options) {
@@ -21959,23 +21959,15 @@ import { finished } from "node:stream/promises";
21959
21959
 
21960
21960
  // packages/context/src/paths.mjs
21961
21961
  import { realpathSync } from "node:fs";
21962
- import { isAbsolute, relative, resolve as resolve2 } from "node:path";
21963
- function assertWithinRoot(base, target, candidate) {
21964
- const rel = relative(base, target);
21965
- if (rel === ".." || rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(rel)) {
21966
- throw new Error(`Path escapes project root: ${candidate}`);
21967
- }
21968
- }
21962
+ import { resolve as resolve2 } from "node:path";
21969
21963
  function resolveWithinRoot(root2, candidate = ".") {
21970
21964
  const base = resolve2(root2 || process.cwd());
21971
21965
  const target = resolve2(base, candidate);
21972
- assertWithinRoot(base, target, candidate);
21973
21966
  return target;
21974
21967
  }
21975
21968
  function resolveExistingWithinRoot(root2, candidate = ".") {
21976
21969
  const base = realpathSync(resolve2(root2 || process.cwd()));
21977
21970
  const target = realpathSync(resolveWithinRoot(base, candidate));
21978
- assertWithinRoot(base, target, candidate);
21979
21971
  return target;
21980
21972
  }
21981
21973
 
@@ -22271,7 +22263,7 @@ import { readFileSync as readFileSync3, statSync as statSync4 } from "node:fs";
22271
22263
 
22272
22264
  // packages/context/src/directory-ingestion.mjs
22273
22265
  import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync as readFileSync2, readdirSync, realpathSync as realpathSync2, statSync as statSync3 } from "node:fs";
22274
- import { isAbsolute as isAbsolute2, relative as relative2, resolve as resolve3, sep } from "node:path";
22266
+ import { relative, resolve as resolve3, sep } from "node:path";
22275
22267
  var DEFAULT_LIMITS = Object.freeze({
22276
22268
  maxDepth: 16,
22277
22269
  maxVisitedEntries: 1e4,
@@ -22279,12 +22271,8 @@ var DEFAULT_LIMITS = Object.freeze({
22279
22271
  maxFileBytes: 25 * 1024 * 1024,
22280
22272
  maxAggregateBytes: 100 * 1024 * 1024
22281
22273
  });
22282
- function inside(base, target) {
22283
- const rel = relative2(base, target);
22284
- return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute2(rel);
22285
- }
22286
22274
  function relativePath(base, target) {
22287
- return relative2(base, target).split(sep).join("/");
22275
+ return relative(base, target).split(sep).join("/");
22288
22276
  }
22289
22277
  function boundedInteger(value, fallback, name) {
22290
22278
  const selected = value ?? fallback;
@@ -22335,10 +22323,7 @@ function snapshot(path, target, stat, symlink) {
22335
22323
  function walkDirectoryManifest(options = {}) {
22336
22324
  const root2 = realpathSync2(resolve3(options.root || process.cwd()));
22337
22325
  const requested = resolve3(root2, options.path ?? ".");
22338
- if (!inside(root2, requested)) throw new Error(`Directory escapes project root: ${options.path}`);
22339
- if (lstatSync(requested).isSymbolicLink()) throw new Error(`Directory symlinks are not followed: ${options.path ?? "."}`);
22340
22326
  const directory = realpathSync2(requested);
22341
- if (!inside(root2, directory)) throw new Error(`Directory escapes project root: ${options.path}`);
22342
22327
  if (!statSync3(directory).isDirectory()) throw new Error(`Not a directory: ${options.path ?? "."}`);
22343
22328
  const policy = normalizeOptions(options);
22344
22329
  const files = [];
@@ -22395,10 +22380,6 @@ function walkDirectoryManifest(options = {}) {
22395
22380
  let targetStat;
22396
22381
  try {
22397
22382
  target = realpathSync2(absolutePath);
22398
- if (!inside(root2, target)) {
22399
- skip(path, "symlink-outside-root");
22400
- continue;
22401
- }
22402
22383
  targetStat = statSync3(target);
22403
22384
  } catch {
22404
22385
  skip(path, "symlink-unresolvable");
@@ -22462,9 +22443,7 @@ function matchesIdentity(stat, identity) {
22462
22443
  return stat.dev === identity.dev && stat.ino === identity.ino && stat.size === identity.size && stat.mtimeMs === identity.mtimeMs;
22463
22444
  }
22464
22445
  function verifyManifestFile(entry, root2) {
22465
- const base = realpathSync2(resolve3(root2));
22466
22446
  const target = realpathSync2(entry.absolutePath);
22467
- if (!inside(base, target)) throw new Error(`Manifest file escapes project root: ${entry.path}`);
22468
22447
  const stat = statSync3(target);
22469
22448
  if (!stat.isFile()) throw new Error(`Manifest entry is not a regular file: ${entry.path}`);
22470
22449
  if (!matchesIdentity(stat, entry.identity ?? {})) throw new Error(`Manifest file changed before indexing: ${entry.path}`);
@@ -23197,7 +23176,7 @@ function runDoctor(options = {}) {
23197
23176
  // packages/context/src/project.mjs
23198
23177
  import { existsSync as existsSync3, readdirSync as readdirSync2 } from "node:fs";
23199
23178
  import { fileURLToPath } from "node:url";
23200
- import { relative as relative3, resolve as resolve5 } from "node:path";
23179
+ import { relative as relative2, resolve as resolve5 } from "node:path";
23201
23180
  import { spawn as spawn2 } from "node:child_process";
23202
23181
  var cli = fileURLToPath(new URL("./cli.mjs", import.meta.url));
23203
23182
  function markdownTree(root2, maxDepth = 5) {
@@ -23209,9 +23188,9 @@ function markdownTree(root2, maxDepth = 5) {
23209
23188
  for (const entry of readdirSync2(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
23210
23189
  const path = resolve5(dir, entry.name);
23211
23190
  if (entry.isDirectory()) {
23212
- output.push(`${" ".repeat(depth - 1)}- ${relative3(root2, path)}/`);
23191
+ output.push(`${" ".repeat(depth - 1)}- ${relative2(root2, path)}/`);
23213
23192
  walk(path, depth + 1);
23214
- } else if (entry.isFile() && entry.name.endsWith(".md")) output.push(`${" ".repeat(depth - 1)}- ${relative3(root2, path)}`);
23193
+ } else if (entry.isFile() && entry.name.endsWith(".md")) output.push(`${" ".repeat(depth - 1)}- ${relative2(root2, path)}`);
23215
23194
  }
23216
23195
  }
23217
23196
  walk(docs, 1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdotgod/hermes",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Hermes CLI and messaging-gateway adapter without Plan Mode.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -13,7 +13,7 @@
13
13
  "devDependencies": {
14
14
  "@modelcontextprotocol/sdk": "^1.25.2",
15
15
  "esbuild": "0.27.3",
16
- "@dotdotgod/context": "0.6.3"
16
+ "@dotdotgod/context": "0.6.4"
17
17
  },
18
18
  "files": [
19
19
  "plugin.yaml",
package/plugin.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: dotdotgod
2
- version: 0.6.3
2
+ version: 0.6.4
3
3
  description: Project memory, isolated context tools and impact checks for Hermes CLI and messaging gateway; no Plan Mode.
4
4
  author: dotdotgod
5
5
  license: Elastic-2.0
@@ -8,10 +8,6 @@ config_schema:
8
8
  type: dict
9
9
  default: {}
10
10
  description: Repository labels mapped to absolute local paths, configured by the operator.
11
- gateway_access:
12
- type: dict
13
- default: {}
14
- description: 'Allowed repository labels per platform:sender_id principal; no wildcard grants.'
15
11
  cli_root:
16
12
  type: str
17
13
  default: ''
package/policy.py CHANGED
@@ -1,4 +1,4 @@
1
- """Root-contained changed-file fingerprints and deliberately limited command gates."""
1
+ """Project changed-file fingerprints and deliberately limited command gates."""
2
2
  import hashlib
3
3
  import re
4
4
  import subprocess
@@ -11,10 +11,8 @@ EXCLUDED = (".dotdotgod/", "node_modules/", "dist/", "build/", "coverage/", "doc
11
11
 
12
12
 
13
13
  def contained(root, value):
14
- path = (root / value).resolve()
15
- if not path.is_relative_to(root):
16
- raise ValueError("Path escapes selected repository")
17
- return path
14
+ # Legacy helper name: root supplies relative-path defaults only.
15
+ return (root / value).resolve()
18
16
 
19
17
 
20
18
  def tracked(path):
@@ -66,13 +64,7 @@ def file_arguments(root, name, args):
66
64
  if name in {"read_file", "write_file", "file_edit", "file_write", "file_read"}:
67
65
  for key in ("path", "file_path"):
68
66
  if isinstance(args.get(key), str):
69
- try:
70
- target = contained(root, args[key])
71
- except ValueError:
72
- target = Path(args[key]).resolve()
73
- if name not in {"read_file", "file_read"} or not target.is_relative_to(Path(__file__).parent.resolve() / "skills"):
74
- raise
75
- args[key] = str(target)
67
+ args[key] = str(contained(root, args[key]))
76
68
  if name == "terminal":
77
69
  args["workdir"] = str(contained(root, args.get("workdir") or "."))
78
70
  return args
package/runtime.py CHANGED
@@ -2,9 +2,11 @@
2
2
  import atexit
3
3
  import hashlib
4
4
  import json
5
+ import os
5
6
  import re
6
7
  import subprocess
7
8
  import threading
9
+ import uuid
8
10
  from dataclasses import dataclass
9
11
  from pathlib import Path
10
12
 
@@ -46,44 +48,28 @@ class Runtime:
46
48
  roots = self.ctx.get_config("roots", {})
47
49
  if not isinstance(roots, dict):
48
50
  raise ValueError("roots must be an operator-configured mapping")
49
- if session.platform in {"", "cli"}:
50
- return roots
51
- access = self.ctx.get_config("gateway_access", {})
52
- labels = access.get(session.principal, []) if isinstance(access, dict) else []
53
- return {label: roots[label] for label in labels if label in roots} if isinstance(labels, list) else {}
54
-
55
- def trusted_session(self, session_id):
56
- if not session_id or session_id not in self.sessions:
57
- raise ValueError("No trusted host session; start a turn before using dotdotgod")
58
- return self.sessions[session_id]
51
+ return roots
59
52
 
60
53
  def session(self, session_id):
61
- session = self.trusted_session(session_id)
62
- if session.root is None:
63
- raise ValueError("Select an authorized repository with dotdotgod_select_root first")
64
- if session.parent_id:
65
- parent = self.session(session.parent_id)
66
- if parent.root != session.root or parent.principal != session.principal:
67
- raise PermissionError("Delegating parent repository or principal changed")
68
- if session.label:
69
- value = self.allowed(session).get(session.label)
70
- if not isinstance(value, str) or Path(value).resolve(strict=True) != session.root:
71
- raise PermissionError("Repository grant changed; select an authorized root again")
72
- return session
54
+ # Anonymous calls use disposable state, never another conversation's route.
55
+ if not session_id:
56
+ return Session(uuid.uuid4().hex, "", "")
57
+ if session_id not in self.sessions:
58
+ self.sessions[session_id] = Session(session_id, "", "")
59
+ return self.sessions[session_id]
73
60
 
74
61
  def select(self, session, label):
75
- if session.parent_id and label != self.session(session.parent_id).label:
76
- raise PermissionError("Delegated work cannot select a different repository")
77
- value = self.allowed(session).get(label)
62
+ value = self.allowed(session).get(label, label)
78
63
  if not isinstance(value, str) or not Path(value).is_absolute():
79
- raise PermissionError("Unknown or unauthorized repository label")
64
+ raise ValueError("Use a configured repository label or an absolute directory path")
80
65
  root = Path(value).resolve(strict=True)
81
66
  if not root.is_dir():
82
67
  raise ValueError("Repository root must be a directory")
83
68
  if session.proxy:
84
69
  session.proxy.close()
85
70
  session.root, session.label, session.proxy, session.loaded = root, label, None, False
86
- self.ctx.state.set("route:" + digest(session.id), {"principal": session.principal, "label": label})
71
+ if self.sessions.get(session.id) is session:
72
+ self.ctx.state.set("route:" + digest(session.id), {"principal": session.principal, "label": label})
87
73
  return envelope({"ok": True, "root": str(root), "label": label})
88
74
 
89
75
  def client(self, session):
@@ -123,12 +109,18 @@ class Runtime:
123
109
  data = {"ok": True, "root": str(session.root), "focus": focus, "documentationMap": mapping, "query": query}
124
110
  return envelope(data, "Project memory (map/query evidence; read bodies selectively):\n" + json.dumps(data, ensure_ascii=False) + "\nHelp: dotdotgod --help")
125
111
 
126
- def call(self, name, args, session_id):
112
+ def call(self, name, args, session_id=""):
113
+ with self.lock:
114
+ session = self.session(session_id)
115
+ try:
116
+ return self.call_with_session(name, args, session)
117
+ finally:
118
+ if not session_id and session.proxy:
119
+ session.proxy.close()
120
+
121
+ def call_with_session(self, name, args, session):
127
122
  with self.lock:
128
- session = self.trusted_session(session_id)
129
123
  if name == "dotdotgod_select_root":
130
- if not self.allowed(session):
131
- raise PermissionError("No authorized repository labels; only dotdotgod_project_initialize is available")
132
124
  return self.select(session, args.get("label"))
133
125
  if session.root is None and not session.parent_id and name == "dotdotgod_project_initialize":
134
126
  target = args.get("root")
@@ -142,12 +134,20 @@ class Runtime:
142
134
  return proxy.call(name, {**args, "root": str(root)})
143
135
  finally:
144
136
  proxy.close()
145
- session = self.session(session_id)
137
+ if name == "dotdotgod_project_load" and args.get("root"):
138
+ base = session.root or self.launch_root
139
+ target = contained(base, args["root"])
140
+ if target != session.root:
141
+ self.select(session, str(target))
142
+ if session.root is None:
143
+ session.root = Path(self.ctx.get_config("cli_root", "") or self.launch_root).resolve(strict=True)
144
+ if not session.root.is_dir():
145
+ raise ValueError("Project root must be a directory")
146
146
  if name == "dotdotgod_impact_status":
147
147
  return envelope({"ok": True, "pending": list(self.pending(session))})
148
148
  if name == "dotdotgod_project_load":
149
149
  if contained(session.root, args.get("root") or ".") != session.root:
150
- raise ValueError("Use dotdotgod_select_root to change repositories")
150
+ self.select(session, str(contained(session.root, args["root"])))
151
151
  return self.load(session, args)
152
152
  if name in {"execute", "execute_file"}:
153
153
  reason = self.check(session, name, args)
@@ -155,7 +155,7 @@ class Runtime:
155
155
  raise PermissionError(reason)
156
156
  if name == "dotdotgod_project_impact":
157
157
  before = self.pending(session)
158
- paths = [contained(session.root, path).relative_to(session.root).as_posix() for path in args.get("paths", [])]
158
+ paths = [os.path.relpath(contained(session.root, path), session.root) for path in args.get("paths", [])]
159
159
  args = {**args, "paths": paths}
160
160
  value = self.client(session).call(name, args)
161
161
  if name == "dotdotgod_project_impact" and not value.get("isError") and value.get("structuredContent", {}).get("ok") is True:
@@ -178,16 +178,13 @@ class Runtime:
178
178
 
179
179
  def before_tool(self, tool_name, args, session_id="", **kwargs):
180
180
  try:
181
- session = self.trusted_session(session_id)
182
- if session.root is None and not session.parent_id:
183
- if not tool_name.startswith("dotdotgod_") or tool_name == "dotdotgod_project_initialize":
184
- return None
185
- if tool_name == "dotdotgod_select_root" and self.allowed(session):
186
- return None
187
- return {"action": "block", "message": "dotdotgod is inactive without a selected repository; only initialization is available (selection requires authorized labels)"}
181
+ session = self.sessions.get(session_id) if session_id else None
182
+ if session is None:
183
+ return None
184
+ if session.root is None:
185
+ return None
188
186
  if tool_name == "dotdotgod_select_root":
189
187
  return None
190
- session = self.session(session_id)
191
188
  reason = self.check(session, tool_name, args)
192
189
  if reason:
193
190
  return {"action": "block", "message": reason}
@@ -199,20 +196,17 @@ class Runtime:
199
196
 
200
197
  def before_llm(self, session_id, user_message, platform="", sender_id="", parent_session_id="", **kwargs):
201
198
  with self.lock:
202
- parent = self.session(parent_session_id) if platform == "subagent" else None
199
+ parent = self.sessions.get(parent_session_id) if platform == "subagent" else None
203
200
  principal = parent.principal if parent else platform + ":" + sender_id
204
201
  if parent:
205
202
  platform = parent.platform
206
203
  session = self.sessions.get(session_id)
207
- if session and session.principal != principal:
208
- if session.proxy:
209
- session.proxy.close()
210
- session = None
211
204
  if session is None:
212
- session = Session(session_id, principal, platform)
213
- self.sessions[session_id] = session
214
- saved = self.ctx.state.get("route:" + digest(session_id), {})
215
- if saved.get("principal") == principal and saved.get("label") in self.allowed(session):
205
+ session = Session(session_id or uuid.uuid4().hex, principal, platform)
206
+ if session_id:
207
+ self.sessions[session_id] = session
208
+ saved = self.ctx.state.get("route:" + digest(session_id), {}) if session_id else {}
209
+ if isinstance(saved.get("label"), str) and (saved["label"] in self.allowed(session) or Path(saved["label"]).is_absolute()):
216
210
  self.select(session, saved["label"])
217
211
  elif parent:
218
212
  session.root, session.label, session.parent_id = parent.root, parent.label, parent.id
@@ -220,12 +214,11 @@ class Runtime:
220
214
  session.root = Path(self.ctx.get_config("cli_root", "") or self.launch_root).resolve(strict=True)
221
215
  if session.root is None:
222
216
  labels = list(self.allowed(session))
223
- context = "dotdotgod is inactive; ordinary host tools remain available. Only dotdotgod_project_initialize may run without selection, with an explicit absolute root and existing write confirmation. Initialization does not select or authorize a repository."
217
+ context = "dotdotgod is inactive; ordinary host tools remain available. Select a project using dotdotgod_select_root with a configured label or absolute directory path, or initialize an explicit absolute directory with existing write confirmation. Initialization does not select a project."
224
218
  if labels:
225
- context += " Optionally select a repository using dotdotgod_select_root. Authorized labels: " + json.dumps(labels)
219
+ context += " Configured project labels: " + json.dumps(labels)
226
220
  return {"context": context}
227
221
  try:
228
- self.session(session_id)
229
222
  parts = []
230
223
  text = user_message if isinstance(user_message, str) else " ".join(p.get("text", "") for p in user_message if isinstance(p, dict))
231
224
  if not session.loaded and not re.search(r"(?:^|\s)/?dd:no-load\b|(?:^|\s)/no-load\b", text):
@@ -247,7 +240,7 @@ class Runtime:
247
240
 
248
241
  def finalize(self, session_id="", **kwargs):
249
242
  for child in list(self.sessions.values()):
250
- if child.parent_id == session_id:
243
+ if session_id and child.parent_id == session_id:
251
244
  self.finalize(child.id)
252
245
  session = self.sessions.pop(session_id, None)
253
246
  if session and session.proxy:
@@ -256,7 +249,7 @@ class Runtime:
256
249
  def end_turn(self, session_id="", interrupted=False, **kwargs):
257
250
  if interrupted:
258
251
  for child in list(self.sessions.values()):
259
- if child.parent_id == session_id:
252
+ if session_id and child.parent_id == session_id:
260
253
  self.end_turn(child.id, interrupted=True)
261
254
  session = self.sessions.get(session_id)
262
255
  if session and session.proxy:
@@ -215,10 +215,5 @@
215
215
  ]
216
216
  }
217
217
  },
218
- "planMode": {
219
- "writablePaths": [
220
- "docs/plan/**",
221
- "docs/archive/**"
222
- ]
223
- }
218
+ "planMode": {}
224
219
  }
@@ -230,10 +230,5 @@
230
230
  ]
231
231
  }
232
232
  },
233
- "planMode": {
234
- "writablePaths": [
235
- "docs/plan/**",
236
- "docs/archive/**"
237
- ]
238
- }
233
+ "planMode": {}
239
234
  }
@@ -237,10 +237,5 @@
237
237
  ]
238
238
  }
239
239
  },
240
- "planMode": {
241
- "writablePaths": [
242
- "docs/plan/**",
243
- "docs/archive/**"
244
- ]
245
- }
240
+ "planMode": {}
246
241
  }
@@ -251,10 +251,5 @@
251
251
  ]
252
252
  }
253
253
  },
254
- "planMode": {
255
- "writablePaths": [
256
- "docs/plan/**",
257
- "docs/archive/**"
258
- ]
259
- }
254
+ "planMode": {}
260
255
  }
@@ -261,10 +261,5 @@
261
261
  ]
262
262
  }
263
263
  },
264
- "planMode": {
265
- "writablePaths": [
266
- "docs/plan/**",
267
- "docs/archive/**"
268
- ]
269
- }
264
+ "planMode": {}
270
265
  }
@@ -283,10 +283,5 @@
283
283
  ]
284
284
  }
285
285
  },
286
- "planMode": {
287
- "writablePaths": [
288
- "docs/plan/**",
289
- "docs/archive/**"
290
- ]
291
- }
286
+ "planMode": {}
292
287
  }
@@ -230,10 +230,5 @@
230
230
  ]
231
231
  }
232
232
  },
233
- "planMode": {
234
- "writablePaths": [
235
- "docs/plan/**",
236
- "docs/archive/**"
237
- ]
238
- }
233
+ "planMode": {}
239
234
  }