nomarmy 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
Files changed (102) hide show
  1. package/README.md +86 -480
  2. package/bin/nomarmy.mjs +1081 -185
  3. package/docker/Dockerfile +2 -2
  4. package/docker/Dockerfile.go +6 -4
  5. package/docker/Dockerfile.rust +17 -2
  6. package/harnesses/_template/README.md +27 -0
  7. package/harnesses/_template/harness.yml +26 -0
  8. package/harnesses/browser-playwright/README.md +35 -0
  9. package/harnesses/browser-playwright/fixture/package.json +1 -0
  10. package/harnesses/browser-playwright/fixture/page.html +1 -0
  11. package/harnesses/browser-playwright/fixture/page.spec.js +5 -0
  12. package/harnesses/browser-playwright/fixture/playwright.config.js +8 -0
  13. package/harnesses/browser-playwright/harness.yml +18 -0
  14. package/harnesses/go/README.md +45 -0
  15. package/harnesses/go/harness.yml +14 -0
  16. package/harnesses/mock-oidc/README.md +31 -0
  17. package/harnesses/mock-oidc/fixture/.nomarmy.yml +4 -0
  18. package/harnesses/mock-oidc/fixture/discovery.test.mjs +16 -0
  19. package/harnesses/mock-oidc/harness.yml +19 -0
  20. package/harnesses/node/README.md +53 -0
  21. package/harnesses/node/harness.yml +18 -0
  22. package/harnesses/python/README.md +46 -0
  23. package/harnesses/python/harness.yml +16 -0
  24. package/harnesses/rust/README.md +45 -0
  25. package/harnesses/rust/harness.yml +13 -0
  26. package/install.sh +29 -9
  27. package/lib/admission.mjs +178 -30
  28. package/lib/agents.mjs +8 -6
  29. package/lib/army.mjs +25 -10
  30. package/lib/codex-link.mjs +37 -0
  31. package/lib/config.mjs +15 -0
  32. package/lib/connect.mjs +232 -19
  33. package/lib/continue-from.mjs +103 -0
  34. package/lib/coordinator-instructions.mjs +5 -1
  35. package/lib/diff-checks.mjs +114 -0
  36. package/lib/dispatch-schema.mjs +14 -12
  37. package/lib/doctor.mjs +98 -9
  38. package/lib/egress-proxy.mjs +116 -0
  39. package/lib/execute.mjs +241 -33
  40. package/lib/git-record.mjs +27 -3
  41. package/lib/harness-schema.mjs +61 -0
  42. package/lib/harnesses.mjs +99 -0
  43. package/lib/health.mjs +162 -18
  44. package/lib/install-freshness.mjs +114 -0
  45. package/lib/jev-checks.mjs +110 -0
  46. package/lib/job-format.mjs +54 -0
  47. package/lib/judge.mjs +130 -0
  48. package/lib/limits.mjs +77 -0
  49. package/lib/model-probe.mjs +61 -0
  50. package/lib/mutation.mjs +159 -0
  51. package/lib/notify.mjs +30 -3
  52. package/lib/openclaw-install.mjs +122 -0
  53. package/lib/openclaw-path.mjs +28 -0
  54. package/lib/openclaw-run.mjs +74 -12
  55. package/lib/openclaw-runtime-health.mjs +56 -0
  56. package/lib/outcome.mjs +21 -2
  57. package/lib/outcomes.mjs +6 -0
  58. package/lib/path-utils.mjs +4 -0
  59. package/lib/podman-health.mjs +41 -0
  60. package/lib/process.mjs +4 -1
  61. package/lib/propose.mjs +10 -11
  62. package/lib/refusal-retry.mjs +16 -0
  63. package/lib/registry-python.mjs +98 -0
  64. package/lib/registry-secrets.mjs +140 -0
  65. package/lib/repo-query.mjs +13 -7
  66. package/lib/runs.mjs +7 -1
  67. package/lib/same-path.mjs +14 -0
  68. package/lib/sandbox-images.mjs +499 -83
  69. package/lib/sandbox-vm.mjs +32 -0
  70. package/lib/scan.mjs +5 -1
  71. package/lib/schema.mjs +20 -11
  72. package/lib/scout.mjs +21 -3
  73. package/lib/server-context.mjs +21 -1
  74. package/lib/setup-steps.mjs +55 -0
  75. package/lib/share.mjs +82 -0
  76. package/lib/stale-sessions.mjs +60 -0
  77. package/lib/stats.mjs +315 -0
  78. package/lib/statusline.mjs +32 -6
  79. package/lib/subscription-setup.mjs +13 -0
  80. package/lib/suggestions.mjs +153 -0
  81. package/lib/thinking.mjs +23 -0
  82. package/lib/transcript.mjs +30 -5
  83. package/lib/usage-limits.mjs +329 -0
  84. package/lib/user-config.mjs +106 -0
  85. package/lib/validators.mjs +220 -0
  86. package/lib/verification-artifacts.mjs +46 -0
  87. package/lib/verification-flow.mjs +52 -7
  88. package/lib/verification-network.mjs +66 -0
  89. package/lib/verify.mjs +338 -85
  90. package/lib/worker-prompt.mjs +5 -2
  91. package/lib/wsl-cli.mjs +152 -0
  92. package/lib/wsl.mjs +230 -0
  93. package/lib/zod-issues.mjs +15 -0
  94. package/mcp/server.mjs +165 -34
  95. package/package.json +7 -5
  96. package/playbooks/feature.md +8 -5
  97. package/scripts/configure-openclaw.sh +4 -2
  98. package/scripts/generate-harness-docs.mjs +42 -0
  99. package/scripts/install-openclaw.mjs +23 -0
  100. package/scripts/lib.sh +9 -2
  101. package/scripts/select-model.mjs +12 -5
  102. package/scripts/start-inference.sh +2 -2
@@ -0,0 +1,98 @@
1
+ // Fail closed before any credentialed Python fetch; diagnostics never echo input.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { toPosix } from './path-utils.mjs';
6
+
7
+ const refuse = (line, kind) => { throw new Error(`registries: Python line ${line}: ${kind} is not allowed with credentials`); };
8
+ function requirement(value, line, cwd) {
9
+ if (/(?:^|\s)--?[^\s]/.test(value)) refuse(line, 'requirements option');
10
+ const dep = value.split(';')[0].trim();
11
+ if (/^(git|hg|svn|bzr)\+/i.test(dep)) refuse(line, 'VCS requirement');
12
+ if (dep.includes('@') || /^[a-z][a-z0-9+.-]*:/i.test(dep)) refuse(line, 'URL requirement');
13
+ if (fs.existsSync(path.join(cwd, dep)) || /[/\\]|^\.|\.(whl|zip|tar|gz|bz2|xz)$/i.test(dep)) refuse(line, 'path requirement');
14
+ // Accept only named index requirements. Unknown syntax cannot enable a build.
15
+ if (!/^[a-z0-9][a-z0-9._-]*(?:\[[a-z0-9_., -]+\])?\s*(?:\(?\s*(?:===|~=|==|!=|<=|>=|<|>)\s*[a-z0-9.*+!_-]+(?:\s*,\s*(?:===|~=|==|!=|<=|>=|<|>)\s*[a-z0-9.*+!_-]+)*\s*\)?)?$/i.test(dep)) refuse(line, 'unsupported requirement');
16
+ }
17
+
18
+ export function requirementsInputs(cwd, roots, validate) {
19
+ const files = [], seen = new Map();
20
+ function visit(source, line = 1) {
21
+ const absolute = path.resolve(cwd, source), root = fs.realpathSync(cwd);
22
+ try {
23
+ if (!fs.realpathSync(absolute).startsWith(root + path.sep) || !fs.statSync(absolute).isFile()) refuse(line, 'escaping requirements include');
24
+ } catch { refuse(line, 'escaping requirements include'); }
25
+ // Docker build-context paths must not contain Windows escape backslashes.
26
+ source = toPosix(path.relative(cwd, absolute));
27
+ if (seen.has(source)) return seen.get(source);
28
+ const index = files.length;
29
+ seen.set(source, index);
30
+ const file = { source, destination: `py/req-${index}.txt` };
31
+ files.push(file);
32
+ const input = fs.readFileSync(absolute, 'utf8');
33
+ const lines = input.split(/\r?\n/), output = [];
34
+ let rewritten = false;
35
+ for (let i = 0; i < lines.length; i++) {
36
+ const number = i + 1;
37
+ let text = lines[i];
38
+ while (/\\\s*$/.test(text) && i + 1 < lines.length) text = text.replace(/\\\s*$/, '') + lines[++i];
39
+ const clean = text.replace(/(^|\s)#.*$/, '').trim();
40
+ const include = clean.match(/^(?:-r\s*|--requirement(?:=|\s+)|-c\s*|--constraint(?:=|\s+))(.+)$/);
41
+ if (include) {
42
+ let target = include[1].trim().replace(/^(['"])(.*)\1$/, '$2');
43
+ if (path.isAbsolute(target) || /^[a-z]+:/i.test(target)) refuse(number, 'escaping requirements include');
44
+ const child = visit(path.join(path.dirname(source), target), number);
45
+ output.push(`${/^(-c|--constraint)/.test(clean) ? '-c' : '-r'} req-${child}.txt`);
46
+ rewritten = true;
47
+ continue;
48
+ }
49
+ if (validate && clean) {
50
+ if (/^(-e|--editable)(\s|=|\S)/.test(clean)) refuse(number, 'editable requirement');
51
+ if (clean.startsWith('-')) {
52
+ const allowed = /^(?:--index-url(?:=|\s+)|-i\s*|--extra-index-url(?:=|\s+)|--find-links(?:=|\s+))https:\/\/[^\s]+$/.test(clean)
53
+ || /^--trusted-host(?:=|\s+)[a-z0-9.-]+(?::\d+)?$/i.test(clean)
54
+ || /^--require-hashes$/.test(clean)
55
+ || /^--hash(?:=|\s+)[a-z0-9]+:[a-f0-9]+$/i.test(clean);
56
+ if (!allowed) refuse(number, 'requirements option');
57
+ } else {
58
+ const withoutHashes = clean.replace(/\s+--hash(?:=|\s+)[a-z0-9]+:[a-f0-9]+/gi, '');
59
+ requirement(withoutHashes, number, cwd);
60
+ }
61
+ }
62
+ output.push(text);
63
+ }
64
+ if (rewritten) file.content = output.join('\n');
65
+ return index;
66
+ }
67
+ const indexes = roots.map(source => visit(source));
68
+ return { files, indexes };
69
+ }
70
+
71
+ export function validatePythonProject(cwd, manager) {
72
+ // tomllib parses data only; never import or execute project code. Suppress
73
+ // parser diagnostics, which can contain dependency URLs or credentials.
74
+ function parse(name) {
75
+ try {
76
+ return JSON.parse(execFileSync('python3', ['-I', '-c', 'import json,sys,tomllib; print(json.dumps(tomllib.loads(sys.stdin.read())))'], {
77
+ input: fs.readFileSync(path.join(cwd, name), 'utf8'), encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'],
78
+ }));
79
+ } catch { throw new Error(`registries: cannot safely parse ${name} (Python 3.11+ required)`); }
80
+ }
81
+ const project = parse('pyproject.toml');
82
+ const lists = [project.project?.dependencies ?? [], ...Object.values(project.project?.['optional-dependencies'] ?? {}), ...Object.values(project['dependency-groups'] ?? {})];
83
+ for (const list of lists) for (const dep of list) {
84
+ if (typeof dep === 'string') requirement(dep, 'dependency list', cwd);
85
+ else if (!(dep && typeof dep['include-group'] === 'string' && Object.keys(dep).length === 1)) refuse('dependency list', 'unsupported requirement');
86
+ }
87
+ if (manager === 'uv') {
88
+ const walk = value => {
89
+ if (!value || typeof value !== 'object') return;
90
+ if (value.source && ['git', 'url', 'path', 'directory', 'editable'].some(key => Object.hasOwn(value.source, key))) {
91
+ // The root virtual project is not installed; all dependency sources are checked.
92
+ refuse('uv.lock', 'git/url/path source');
93
+ }
94
+ for (const child of Object.values(value)) walk(child);
95
+ };
96
+ walk(parse('uv.lock'));
97
+ }
98
+ }
@@ -0,0 +1,140 @@
1
+ // Operator-only registry declarations. Never merge these into job configuration.
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import crypto from "node:crypto";
6
+ import YAML from "yaml";
7
+
8
+ const fail = (message) => { throw new Error("registries: " + message); };
9
+ const mapping = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
10
+
11
+ // Inspect only the local YAML key; never resolve or inspect declared paths.
12
+ export function hasRegistryDeclarations(cwd) {
13
+ const local = path.join(cwd, ".nomarmy.local.yml");
14
+ if (!fs.existsSync(local)) return false;
15
+ let doc;
16
+ try { doc = YAML.parse(fs.readFileSync(local, "utf8")); }
17
+ catch { fail("cannot parse .nomarmy.local.yml"); }
18
+ return mapping(doc) && Object.hasOwn(doc, "registries");
19
+ }
20
+
21
+ export function readRegistrySecrets(cwd) {
22
+ // Check both spellings, even if the normal loader would select only one.
23
+ for (const name of [".nomarmy.yml", ".nomarmy.yaml"]) {
24
+ const file = path.join(cwd, name);
25
+ if (!fs.existsSync(file)) continue;
26
+ let doc;
27
+ try { doc = YAML.parse(fs.readFileSync(file, "utf8")); }
28
+ catch { fail("cannot parse repository configuration"); }
29
+ if (mapping(doc) && Object.hasOwn(doc, "registries")) {
30
+ fail("allowed only in .nomarmy.local.yml, never in committed repository configuration");
31
+ }
32
+ }
33
+ const local = path.join(cwd, ".nomarmy.local.yml");
34
+ if (!fs.existsSync(local)) return [];
35
+ let doc;
36
+ // Parser errors can echo input; do not include them in a public error.
37
+ try { doc = YAML.parse(fs.readFileSync(local, "utf8")); }
38
+ catch { fail("cannot parse .nomarmy.local.yml"); }
39
+ if (!mapping(doc) || !Object.hasOwn(doc, "registries")) return [];
40
+ if (!mapping(doc.registries)) fail("must be a mapping of credential file paths");
41
+ const declarations = doc.registries;
42
+ if (Object.keys(declarations).some((key) => !["npm", "pip", "go", "cargo"].includes(key))) {
43
+ fail("supported ecosystems are npm, pip, go and cargo");
44
+ }
45
+ return ["npm", "pip", "go", "cargo"].filter((key) => Object.hasOwn(declarations, key)).map((ecosystem) => {
46
+ const value = declarations[ecosystem];
47
+ let source = value, format = "config", privatePatterns = "";
48
+ if (ecosystem === "go" || (ecosystem === "pip" && mapping(value))) {
49
+ const keys = ecosystem === "go" ? ["netrc", "private"] : ["netrc"];
50
+ if (!mapping(value) || Object.keys(value).some((key) => !keys.includes(key)) || !Object.hasOwn(value, "netrc")) {
51
+ fail(ecosystem + " accepts only a netrc file path" + (ecosystem === "go" ? " and private patterns" : ""));
52
+ }
53
+ source = value.netrc;
54
+ format = "netrc";
55
+ if (ecosystem === "go" && value.private !== undefined) {
56
+ if (typeof value.private !== "string" || !/^[a-zA-Z0-9_.*?\/[\],!~+-]+$/.test(value.private)) {
57
+ fail("go.private must contain module patterns, not credentials or shell commands");
58
+ }
59
+ privatePatterns = value.private;
60
+ }
61
+ }
62
+ if (typeof source !== "string" || !source || /[\r\n\0,]/.test(source) ||
63
+ !(path.isAbsolute(source) || source.startsWith("~/") || source.startsWith("./") || source.startsWith("../"))) {
64
+ fail(ecosystem + " requires an absolute, ~/ or ./ credential file path; inline credentials are not accepted");
65
+ }
66
+ if (ecosystem === "pip" && [".netrc", "netrc"].includes(path.basename(source))) format = "netrc";
67
+ const resolved = path.resolve(cwd, source.startsWith("~/") ? path.join(os.homedir(), source.slice(2)) : source);
68
+ let stat, bytes;
69
+ try {
70
+ stat = fs.statSync(resolved, { bigint: true });
71
+ if (!stat.isFile()) fail(ecosystem + " credential must be a regular file");
72
+ bytes = fs.readFileSync(resolved);
73
+ } catch { fail(ecosystem + " credential must be an existing readable regular file"); }
74
+ return {
75
+ ecosystem, id: ecosystem === "go" ? "go-netrc" : ecosystem, source: resolved, format, privatePatterns,
76
+ digest: crypto.createHash("sha256").update(bytes).digest("hex"),
77
+ ...fileIdentity(resolved, stat),
78
+ };
79
+ });
80
+ }
81
+
82
+ // Same file, hardlinks included. Exact (bigint) IDs, as strings: Windows file
83
+ // IDs are 64-bit (128 on ReFS) and collide once rounded to a double, which
84
+ // flagged unrelated files as the credential on CI. A filesystem that reports
85
+ // no ID (inode 0) falls back to the real path.
86
+ function fileIdentity(file, stat = fs.statSync(file, { bigint: true })) {
87
+ const real = () => { const p = fs.realpathSync.native(file); return process.platform === "win32" ? p.toLowerCase() : p; };
88
+ return stat.ino === 0n ? { device: null, inode: null, realPath: real() }
89
+ : { device: String(stat.dev), inode: String(stat.ino), realPath: null };
90
+ }
91
+
92
+ const sameFile = (a, b) => a.inode !== null ? a.device === b.device && a.inode === b.inode : a.realPath === b.realPath;
93
+
94
+ // A selected credential must not also be a dependency input (including hardlinks).
95
+ export function assertNoRegistryCopies(cwd, files, secrets) {
96
+ for (const { source } of files) {
97
+ if (!source) continue;
98
+ const input = fileIdentity(path.resolve(cwd, source));
99
+ if (secrets.some((secret) => sameFile(secret, input))) {
100
+ fail("a credential file cannot also be a build-context input");
101
+ }
102
+ }
103
+ }
104
+
105
+ const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
106
+
107
+ // Only built-in install RUNs get mounts. Custom harness commands never do.
108
+ // /tmp and manager caches are ephemeral so diagnostics/auth caches cannot
109
+ // survive a successful install. No credentialed command output is retained.
110
+ export function registryInstallRun(command, ecosystem, secrets) {
111
+ const secret = secrets.find((entry) => entry.ecosystem === ecosystem);
112
+ if (!secret) return "RUN " + command;
113
+ const target = ecosystem === "npm" ? "/home/node/.npmrc"
114
+ : ecosystem === "pip" ? (secret.format === "netrc" ? "/root/.netrc" : "/etc/pip.conf")
115
+ : ecosystem === "go" ? "/home/node/.netrc" : "/home/node/.cargo/credentials.toml";
116
+ const uid = ecosystem === "pip" ? 0 : 1000;
117
+ const mounts = [
118
+ `--mount=type=secret,id=${secret.id},target=${target},uid=${uid},required=true`,
119
+ "--mount=type=tmpfs,target=/tmp",
120
+ "--mount=type=tmpfs,target=/root/.cache",
121
+ // Corepack lives in ~/.cache/node/corepack: do not hide its prepared binaries.
122
+ // npm-family archive caches are redirected to tmpfs by the environment below.
123
+ ...(ecosystem === "npm" ? [] : ["--mount=type=tmpfs,target=/home/node/.cache"]),
124
+ ...(ecosystem === "npm" ? ["--mount=type=tmpfs,target=/home/node/.npm"] : []),
125
+ ].join(" ");
126
+ const env = ecosystem === "go"
127
+ ? `export GOPRIVATE=${quote(secret.privatePatterns)} GONOSUMDB=${quote(secret.privatePatterns)}; `
128
+ : ecosystem === "pip" ? "export UV_CACHE_DIR=/tmp/uv-cache POETRY_CACHE_DIR=/tmp/poetry-cache; "
129
+ : ecosystem === "npm" ? "export npm_config_cache=/tmp/npm-cache COREPACK_ENABLE_NETWORK=0 COREPACK_ENABLE_DOWNLOAD_PROMPT=0 YARN_CACHE_FOLDER=/tmp/yarn-cache YARN_GLOBAL_FOLDER=/tmp/yarn-global BUN_INSTALL_CACHE_DIR=/tmp/bun-cache; " : "";
130
+ // Podman's layer cache does not include secret contents. A one-way salt in
131
+ // the RUN is necessary: a new image tag alone could reuse a failed install.
132
+ const { source, format, privatePatterns, digest } = secret;
133
+ const salt = crypto.createHash("sha256").update(JSON.stringify({ ecosystem, source, format, privatePatterns, digest })).digest("hex");
134
+ const run = `RUN ${mounts} { : ${salt}; ${env}${command}; } >/dev/null 2>&1`;
135
+ // Python has no rebuild step to check mount removal. Fail closed before
136
+ // subsequent layers can execute installed code. npm checks before rebuild.
137
+ return ecosystem === "pip"
138
+ ? run + `\nRUN (test ! -e ${target} && test ! -L ${target}) || { touch /deps/.nomarmy-pip-install-failed; exit 1; }`
139
+ : run;
140
+ }
@@ -31,18 +31,22 @@ const TEXT_EXT_HINT = /\.(mjs|cjs|js|jsx|ts|tsx|py|go|rs|java|kt|kts|cs|rb|php|s
31
31
  // ---------------------------------------------------------------------------
32
32
  // File walk
33
33
  // ---------------------------------------------------------------------------
34
- function globToRegex(glob) {
35
- // Minimal glob: ** any path, * within a segment, ? one char, {a,b} alternation.
34
+ // Minimal glob: ** any path, * within a segment, ? one char, {a,b}
35
+ // alternation. Everything else is literal, so the regex built here can only
36
+ // ever contain the fragments below, whatever the glob says.
37
+ function globBody(glob) {
36
38
  let re = "";
37
39
  for (let i = 0; i < glob.length; i++) {
38
40
  const ch = glob[i];
39
- if (ch === "*") { if (glob[i + 1] === "*") { re += "(?:.*\\/)?"; i++; if (glob[i + 1] === "/") { i++; } } else re += "[^/]*"; }
41
+ if (ch === "*") { if (glob[i + 1] === "*") { re += glob[i + 2] === "/" ? "(?:.*/)?" : ".*"; i++; if (glob[i + 1] === "/") { i++; } } else re += "[^/]*"; }
40
42
  else if (ch === "?") re += "[^/]";
41
- else if (ch === "{") { const j = glob.indexOf("}", i); if (j > i) { re += "(" + glob.slice(i + 1, j).split(",").map(s => s.replace(/[.+^$()|[\]\\]/g, "\\$&")).join("|") + ")"; i = j; } else re += "\\{"; }
42
- else if (/[.+^$()|[\]\\]/.test(ch)) re += "\\" + ch;
43
- else re += ch;
43
+ else if (ch === "{") { const j = glob.indexOf("}", i); if (j > i) { re += "(?:" + glob.slice(i + 1, j).split(",").map(globBody).join("|") + ")"; i = j; } else re += "\\{"; }
44
+ else re += ch.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
44
45
  }
45
- return new RegExp("^(?:.*/)?" + re + "$");
46
+ return re;
47
+ }
48
+ function globToRegex(glob) {
49
+ return new RegExp("^(?:.*/)?" + globBody(glob) + "$");
46
50
  }
47
51
  export function matchesGlob(rel, glob) {
48
52
  if (!glob) return true;
@@ -98,6 +102,8 @@ export function grepRepo(root, { pattern, regex = true, ignoreCase = false, whol
98
102
  let src = regex ? pattern : pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
99
103
  if (wholeWord) src = `\\b(?:${src})\\b`;
100
104
  let re;
105
+ // A regex from the caller is the point of grep (repo_evidence); the
106
+ // caller is the operator's own coordinator session.
101
107
  try { re = new RegExp(src, ignoreCase ? "i" : ""); } catch (e) { throw new Error(`invalid pattern: ${e.message}`); }
102
108
  const { files, truncated: filesTruncated } = listFiles(root, { glob, ignores });
103
109
  const hits = [];
package/lib/runs.mjs CHANGED
@@ -80,10 +80,16 @@ function writeRun(runsDir, run) {
80
80
  export function createRun(runsDir, { name, repo, limits, now = Date.now() }) {
81
81
  const slug = String(name ?? "feature").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "feature";
82
82
  const id = `run-${slug}-${crypto.randomBytes(3).toString("hex")}`;
83
- return writeRun(runsDir, {
83
+ const run = writeRun(runsDir, {
84
84
  id, name: name ?? slug, repo, status: "running", createdAt: new Date(now).toISOString(),
85
85
  limits, jobs: [], pausedAgents: {}, logPath: path.join(runsDir, `${id}.md`), finishedAt: null, summary: null,
86
86
  });
87
+ // The General keeps this log (playbooks/feature.md); start it with the
88
+ // sections it fills in, so the path it's handed already exists.
89
+ if (!fs.existsSync(run.logPath)) {
90
+ fs.writeFileSync(run.logPath, `# ${run.name} (${id})\n\nRepository: ${repo}\nStarted: ${run.createdAt}\n\n## Plan\n\n## Decisions\n\n## Jobs\n\n## Review\n\n## What's left\n`);
91
+ }
92
+ return run;
87
93
  }
88
94
 
89
95
  export function loadRun(runsDir, id) {
@@ -0,0 +1,14 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /** Compare canonical paths so Windows case, slash, and 8.3 spellings agree. */
5
+ export function samePath(a, b, platform = process.platform) {
6
+ const canonical = (value) => {
7
+ const resolved = path.resolve(value);
8
+ let real = resolved;
9
+ try { real = fs.realpathSync.native(resolved); } catch { /* Missing paths still compare resolved spellings. */ }
10
+ const normalized = platform === "win32" ? real.replaceAll("\\", "/").toLowerCase() : real;
11
+ return normalized.replace(/\/$/, "");
12
+ };
13
+ return canonical(a) === canonical(b);
14
+ }