@damiangilgonzalez/harness 1.3.17 → 1.3.19

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 (37) hide show
  1. package/README.md +117 -657
  2. package/manifest.json +1 -1
  3. package/package.json +1 -1
  4. package/scripts/doctor.js +1 -1
  5. package/scripts/install-apply.js +9 -0
  6. package/scripts/rsc.js +90 -2
  7. package/skills/building-agents/SKILL.md +1 -1
  8. package/skills/grill-with-docs/SKILL.md +0 -1
  9. package/skills/harness/references/daily-curation-automation.md +2 -1
  10. package/skills/harness/references/wiki-protocol.md +3 -1
  11. package/skills/implement/SKILL.md +0 -1
  12. package/skills/implement-spec/SKILL.md +1 -2
  13. package/skills/init/SKILL.md +7 -5
  14. package/skills/postgresdb/SKILL.md +1 -1
  15. package/skills/suggest/SKILL.md +17 -13
  16. package/skills/suggest/evals/cases.yaml +6 -0
  17. package/skills/to-spec/SKILL.md +0 -1
  18. package/skills/to-tickets/SKILL.md +0 -1
  19. package/skills/wayfinder/SKILL.md +0 -1
  20. package/targets/_md-block.js +1 -1
  21. package/targets/claude.js +2 -1
  22. package/targets/clone-bootstrap.mjs +1 -1
  23. package/targets/danger-guard.mjs +114 -52
  24. package/targets/hook-once.mjs +44 -13
  25. package/targets/knowledge-sync.mjs +759 -0
  26. package/targets/knowledge-wiring.js +196 -0
  27. package/targets/memory.js +7 -1
  28. package/targets/opencode-memory-plugin.js +67 -22
  29. package/targets/opt-outs.js +4 -0
  30. package/targets/session-memory-adapter.mjs +51 -6
  31. package/targets/session-memory-core.mjs +284 -37
  32. package/targets/session-start.mjs +2 -1
  33. package/targets/ship-guard.mjs +26 -9
  34. package/targets/trunk-policy.mjs +86 -0
  35. package/targets/userprompt-gate.mjs +7 -2
  36. package/targets/worklog-checkpoint.mjs +16 -1
  37. package/targets/worktree-reaper.mjs +144 -4
@@ -0,0 +1,86 @@
1
+ // Is the default branch closed for the agent in this project? (team-safe-default, part A)
2
+ //
3
+ // The spec leaves the call to the agent, change by change: complex or in production → branches;
4
+ // simple (scripts, research, personal) → straight on the default branch. That is judgement, and
5
+ // constitution P1 sends judgement to the skills. This module is only the FLOOR: the signals anyone
6
+ // can count — there is CI, there is a deployment, there is a team — which are the spec's own examples
7
+ // of "complejo o con producción". No signal: the floor stays open and the skill decides. A signal: the
8
+ // floor closes, and if the project is in fact simple the way out is the person asking to unlock it.
9
+ //
10
+ // `.rsc/.no-trunk-guard` is that unlock: a PROJECT switch (it travels in `.rsc.json` optOuts), set
11
+ // when somebody asks the agent «desbloquea main».
12
+ //
13
+ // Standalone on purpose: copied next to `branch-guard.mjs` and `knowledge-sync.mjs` under `.rsc/`.
14
+ import { execFileSync } from 'node:child_process';
15
+ import { existsSync, readdirSync } from 'node:fs';
16
+ import { join } from 'node:path';
17
+
18
+ export const UNLOCK = '.no-trunk-guard';
19
+ /** «ramas y PR», chosen at install or with `rsc main lock`: closed whatever the signals say. */
20
+ export const LOCK = '.no-trunk-open';
21
+
22
+ const CI = ['.github/workflows', '.gitlab-ci.yml', '.circleci', 'azure-pipelines.yml', 'Jenkinsfile',
23
+ 'bitbucket-pipelines.yml', '.buildkite', '.travis.yml', '.woodpecker.yml', '.drone.yml'];
24
+ const DEPLOY = ['Dockerfile', 'docker-compose.yml', 'docker-compose.yaml', 'compose.yml', 'compose.yaml',
25
+ 'vercel.json', 'netlify.toml', 'fly.toml', 'render.yaml', 'railway.json', 'railway.toml', 'Procfile',
26
+ 'app.yaml', 'serverless.yml', 'wrangler.toml', 'k8s', 'helm', 'Chart.yaml'];
27
+ const TEAM_COMMITS = 50;
28
+
29
+ function git(root, args) {
30
+ try {
31
+ return execFileSync('git', args, { windowsHide: true, cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
32
+ } catch { return null; }
33
+ }
34
+
35
+ const present = (root, rel) => {
36
+ const path = join(root, rel);
37
+ if (!existsSync(path)) return false;
38
+ try { return readdirSync(path).length > 0; } catch { return true; } // a file, not a folder
39
+ };
40
+
41
+ /** The countable reasons this project looks complex or in production. Empty = looks simple. */
42
+ export function trunkSignals(root) {
43
+ const out = [];
44
+ const ci = CI.find((rel) => present(root, rel));
45
+ if (ci) out.push(`CI (${ci})`);
46
+ const deploy = DEPLOY.find((rel) => present(root, rel));
47
+ if (deploy) out.push(`despliegue (${deploy})`);
48
+ const authors = new Set((git(root, ['log', `-${TEAM_COMMITS}`, '--format=%ae']) || '').split('\n').filter(Boolean));
49
+ if (authors.size >= 2) out.push(`equipo (${authors.size} personas en los últimos ${TEAM_COMMITS} commits)`);
50
+ return out;
51
+ }
52
+
53
+ /** The default branch's name: the remote's HEAD, else main/master, else null. */
54
+ export function defaultBranchName(root) {
55
+ const head = git(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
56
+ if (head) return head.replace(/^refs\/remotes\/origin\//, '');
57
+ for (const b of ['main', 'master']) {
58
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${b}`])) return b;
59
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${b}`])) return b;
60
+ }
61
+ return null;
62
+ }
63
+
64
+ /** `{ closed, reason, signals }` — reason: 'unlocked' | 'locked' | 'simple' | 'signals'. */
65
+ export function trunkPolicy(root) {
66
+ if (existsSync(join(root, '.rsc', UNLOCK))) return { closed: false, reason: 'unlocked', signals: [] };
67
+ if (existsSync(join(root, '.rsc', LOCK))) return { closed: true, reason: 'locked', signals: ['ramas y PR, decidido para este proyecto'] };
68
+ const signals = trunkSignals(root);
69
+ return signals.length ? { closed: true, reason: 'signals', signals } : { closed: false, reason: 'simple', signals };
70
+ }
71
+
72
+ export const trunkClosed = (root) => trunkPolicy(root).closed;
73
+
74
+ /**
75
+ * The branch rule for THIS project, said every turn (2026-10-06, Eric): where the default branch is
76
+ * open the agent works on it and never asks; where it is closed it asks before each code change —
77
+ * this branch, a new one, or unlocking — and never branches on its own.
78
+ */
79
+ export function branchRuleLine(root) {
80
+ const trunk = defaultBranchName(root) || 'main';
81
+ const policy = trunkPolicy(root);
82
+ if (!policy.closed) return `- Here "${trunk}" is OPEN: code changes go straight on it. Do not ask about branches.\n`;
83
+ const here = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
84
+ const options = here && here !== trunk ? `this branch ("${here}"), a new branch, or unlock "${trunk}"` : `a new branch, or unlock "${trunk}"`;
85
+ return `- Here "${trunk}" is CLOSED (${policy.signals.join(', ')}): before EACH code change ask: ${options}? Never choose alone.\n`;
86
+ }
@@ -14,7 +14,7 @@
14
14
  import { existsSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { homedir } from 'node:os';
17
- import { claimOnce, readHookInput, WORKFLOW_GATE_TEXT } from './hook-once.mjs';
17
+ import { claimOnce, readHookInput, renderGate, WORKFLOW_GATE_TEXT } from './hook-once.mjs';
18
18
 
19
19
  const root = process.argv[2] || process.cwd();
20
20
 
@@ -33,4 +33,9 @@ if (hook.session_id && turnKey && !claimOnce(`up:${hook.session_id}:${turnKey}`)
33
33
  // that is not installed sends the agent looking for something that does not exist, so the line only
34
34
  // ships when the skill is present in this project or in the user scope.
35
35
  const hasFlujo = [root, homedir()].some((r) => existsSync(join(r, '.claude', 'skills', 'flujo-interfaz', 'SKILL.md')));
36
- process.stdout.write(hasFlujo ? WORKFLOW_GATE_TEXT : WORKFLOW_GATE_TEXT.split('\n').filter((l) => !l.includes('flujo-interfaz')).join('\n'));
36
+ const gate = hasFlujo ? WORKFLOW_GATE_TEXT : WORKFLOW_GATE_TEXT.split('\n').filter((l) => !l.includes('flujo-interfaz')).join('\n');
37
+ // Skills marked `disable-model-invocation: true` are rendered as the slash command the user types,
38
+ // read from the installed SKILL.md so the gate cannot contradict the skill (see renderGate).
39
+ let out = gate;
40
+ try { out = renderGate(gate, [root, homedir()]); } catch { /* fail-safe: keep the plain gate */ }
41
+ process.stdout.write(out);
@@ -5,7 +5,7 @@
5
5
  // Reminds the agent to run a Worklog Sweep (capture what we did this session into
6
6
  // docs/raw/worklog/). The hook only reminds; the agent writes the worklog.
7
7
  // Silent when this workspace has no harness wiki yet.
8
- import { existsSync } from 'node:fs';
8
+ import { existsSync, readdirSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
 
11
11
  const root = process.argv[2] || process.cwd();
@@ -13,10 +13,25 @@ const root = process.argv[2] || process.cwd();
13
13
  // No harness wiki here → nothing to document into. Stay silent.
14
14
  if (!existsSync(join(root, 'docs', 'wiki'))) process.exit(0);
15
15
 
16
+ // The sweep used to open with the agent running `ls docs/raw/worklog` to see what was there, and in
17
+ // a wiki scaffolded before that folder existed (or whose .gitkeep never got committed) that printed
18
+ // an error into the session on every compaction. The hook already knows the answer, so it says it:
19
+ // what is there, or that the folder has to be created first. Nothing is left to list blindly.
20
+ // It does not create the folder itself: an untracked docs/raw/worklog/ is where the session memory
21
+ // prefers to keep its journal, and moving that store mid-session would orphan the session's anchor.
22
+ let state;
23
+ try {
24
+ const entries = readdirSync(join(root, 'docs', 'raw', 'worklog')).filter((name) => name.endsWith('.md'));
25
+ state = `docs/raw/worklog/ exists with ${entries.length} worklog file(s); no need to list it.`;
26
+ } catch {
27
+ state = 'docs/raw/worklog/ does not exist yet: create it first (mkdir -p docs/raw/worklog); do not list it.';
28
+ }
29
+
16
30
  process.stdout.write(`
17
31
  ===== rsc worklog checkpoint =====
18
32
  If this session did meaningful work (files changed, a decision made, a commit),
19
33
  run a WORKLOG SWEEP before context is lost:
34
+ ${state}
20
35
  1. Write docs/raw/worklog/<YYYY-MM-DD>-<slug>.md using the harness
21
36
  wiki-worklog-template.md (what we did · why · files touched · outcome · next).
22
37
  2. Compile it into wiki/ (update existing articles first; wikilinks + Related);
@@ -115,9 +115,127 @@ export function isCleanupEnabled(root) {
115
115
 
116
116
  /** The first trunk ref that actually exists, or null when there is nothing to compare against. */
117
117
  export function resolveTrunk(root) {
118
+ // What the remote itself calls its default branch comes first: a trunk called `develop` or
119
+ // `trunk` is invisible to the table below, and guessing `main` there compares against nothing.
120
+ const announced = remoteDefault(root);
121
+ if (announced) return announced.ref;
118
122
  return TRUNKS.find((ref) => git(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).ok) || null;
119
123
  }
120
124
 
125
+ // `origin/HEAD` as the remote announced it, or null. A plain `git init` + `remote add` never writes
126
+ // that ref (only `clone` and `remote set-head` do), so null is common and is not an error.
127
+ function remoteDefault(root) {
128
+ const sym = git(root, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
129
+ if (!sym.ok || !sym.out.startsWith('refs/remotes/origin/')) return null;
130
+ const name = sym.out.slice('refs/remotes/origin/'.length);
131
+ if (!git(root, ['rev-parse', '--verify', '--quiet', `origin/${name}^{commit}`]).ok) return null;
132
+ return { ref: `origin/${name}`, name };
133
+ }
134
+
135
+ /**
136
+ * The branch a new worktree starts from: origin's default, never the local one.
137
+ *
138
+ * Measured in a user's project: the local `main` was 400 commits behind origin, and a worktree cut
139
+ * from it starts 400 commits in the past — every review and every merge after that pays for it.
140
+ * The remote tip is the only base that is current by construction. Null when there is no origin.
141
+ */
142
+ export function defaultBranch(root) {
143
+ const announced = remoteDefault(root);
144
+ if (announced) return announced;
145
+ for (const name of ['main', 'master']) {
146
+ if (git(root, ['rev-parse', '--verify', '--quiet', `origin/${name}^{commit}`]).ok) return { ref: `origin/${name}`, name };
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** `git fetch origin`, reported rather than thrown: offline is a warning, not a crash. */
152
+ export function fetchOrigin(root) {
153
+ const r = git(root, ['fetch', '--quiet', 'origin']);
154
+ return { ok: r.ok, err: r.err };
155
+ }
156
+
157
+ // A worktree name becomes a directory under `.claude/worktrees/` and, by default, a branch. One
158
+ // segment of plain characters, so `../../elsewhere` or an absolute path can never be where it lands.
159
+ const WORKTREE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
160
+
161
+ /** Why this name cannot be used, or null when it can. */
162
+ export function invalidWorktreeName(name) {
163
+ if (typeof name !== 'string' || !name) return 'a name is required';
164
+ if (!WORKTREE_NAME.test(name) || name.includes('..') || name.endsWith('.lock') || name.endsWith('.')) {
165
+ return `"${name}" is not a valid worktree name: use letters, digits, '.', '_' or '-' (no '/', no '..')`;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * The preparation hook a project defines for a new worktree, or null.
172
+ *
173
+ * A worktree is born with what git tracks and nothing else: no `.env`, no dependencies. What each
174
+ * project needs to be runnable is the project's knowledge, not ours, so rsc does not guess it — it
175
+ * runs the project's own script if there is one. The new worktree's copy wins (it is the code of
176
+ * the branch that will run), the main checkout's is the fallback (a hook not yet committed).
177
+ */
178
+ export const PREPARE_HOOKS = ['scripts/dev/preparar-worktree.sh', 'scripts/dev/preparar-worktree.mjs'];
179
+ export function findPrepareHook(...dirs) {
180
+ for (const dir of dirs) {
181
+ for (const rel of PREPARE_HOOKS) if (existsSync(join(dir, rel))) return join(dir, rel);
182
+ }
183
+ return null;
184
+ }
185
+
186
+ /**
187
+ * Create a worktree at `.claude/worktrees/<name>` on a NEW branch cut from origin's default branch.
188
+ *
189
+ * `--no-track` because a branch cut from `origin/main` would otherwise track `origin/main`, and
190
+ * the first `git pull` from the worktree would merge the trunk into the feature behind your back.
191
+ * The branch gets its own upstream on its first `git push -u`.
192
+ */
193
+ export function createWorktree(root, name, { branch } = {}) {
194
+ const bad = invalidWorktreeName(name);
195
+ if (bad) return { created: false, reason: bad };
196
+ const ref = branch || name;
197
+ if (!git(root, ['check-ref-format', '--branch', ref]).ok) return { created: false, reason: `"${ref}" is not a valid branch name` };
198
+ if (git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${ref}`]).ok) {
199
+ return { created: false, reason: `branch ${ref} already exists; pick another with --branch <rama>` };
200
+ }
201
+ const path = join(real(root), '.claude', 'worktrees', name);
202
+ if (existsSync(path)) return { created: false, reason: `${path} already exists` };
203
+
204
+ const fetched = fetchOrigin(root);
205
+ const base = defaultBranch(root);
206
+ if (!base) {
207
+ return { created: false, reason: 'no origin default branch to start from (no `origin` remote, or neither origin/main nor origin/master exists). Add the remote and push the trunk first.' };
208
+ }
209
+ mkdirSync(dirname(path), { recursive: true });
210
+ const add = git(root, ['worktree', 'add', '--quiet', '--no-track', '-b', ref, path, base.ref]);
211
+ if (!add.ok) return { created: false, reason: `git refused to create it: ${add.err || add.out}` };
212
+ return { created: true, path: real(path), branch: ref, base: base.ref, fetched: fetched.ok, fetchError: fetched.ok ? null : fetched.err };
213
+ }
214
+
215
+ /** Run the preparation hook inside the new worktree. Its output goes straight to the person. */
216
+ export function runPrepareHook(hook, worktreePath, mainRoot) {
217
+ const runner = hook.endsWith('.mjs') ? process.execPath : 'bash';
218
+ const r = spawnSync(runner, [hook], {
219
+ cwd: worktreePath,
220
+ stdio: 'inherit',
221
+ windowsHide: true,
222
+ env: { ...process.env, RSC_MAIN_WORKTREE: real(mainRoot), RSC_WORKTREE: worktreePath },
223
+ });
224
+ if (r.error) return { ok: false, reason: `could not start ${runner}: ${r.error.message}` };
225
+ return { ok: r.status === 0, reason: r.status === 0 ? null : `exited with code ${r.status}` };
226
+ }
227
+
228
+ /**
229
+ * The candidates a bulk `reap --merged` may take: landed and empty of anything unsaved, where the
230
+ * only thing missing is certainty about provenance. That doubt is what `--confirm` accepts, once,
231
+ * for the whole list the person was just shown. Nothing else is: a dirty worktree, or one holding a
232
+ * file in no commit, is never part of a bulk yes (test 14's rule, unchanged).
233
+ */
234
+ export function mergedCandidates(classified) {
235
+ return classified.filter((c) => c.verdict === 'safe'
236
+ || (c.verdict === 'ask' && c.reasons.length && c.reasons.every((r) => r === 'provenance-ambiguous')));
237
+ }
238
+
121
239
  /** Every working tree git knows about. The first entry is always the main one. */
122
240
  export function listWorktrees(root) {
123
241
  const { ok, out } = git(root, ['worktree', 'list', '--porcelain']);
@@ -166,8 +284,12 @@ export function provenanceOf(root, wt) {
166
284
  const p = wt.path;
167
285
  const home = real(root);
168
286
  const inside = (dir) => p === dir || p.startsWith(dir + sep);
287
+ // `.claude/worktrees/` is where Claude Code puts the worktrees it opens, and where
288
+ // `rsc worktrees new` puts them too. Left out, every one of them read as foreign: up to fourteen
289
+ // piled up in one user's project, invisible to the cleanup, until they were removed by hand.
169
290
  const location = inside(join(home, '.worktrees'))
170
291
  || inside(join(home, 'worktrees'))
292
+ || inside(join(home, '.claude', 'worktrees'))
171
293
  || (dirname(p) === dirname(home) && basename(p).startsWith(`${basename(home)}-`));
172
294
  const branch = Boolean(wt.branch && RSC_BRANCH.test(wt.branch));
173
295
  if (location && branch) return 'rsc';
@@ -236,7 +358,7 @@ export function integrationOf(root, wt, trunk) {
236
358
  * with nothing pending can still hold the only copy of an .env, a local database or a page of notes,
237
359
  * and removing the directory takes them with it. `--ignored` is the only way to be told they exist.
238
360
  */
239
- export function contentOutsideHistory(wtPath) {
361
+ export function contentOutsideHistory(wtPath, mainRoot) {
240
362
  const { ok, raw } = git(wtPath, ['status', '--porcelain', '-z', '-uall', '--ignored=matching']);
241
363
  const dirty = [];
242
364
  const outside = [];
@@ -252,12 +374,27 @@ export function contentOutsideHistory(wtPath) {
252
374
  // does not exist — the exact failure the `raw` comment above exists to prevent (P6).
253
375
  if (code[0] === 'R' || code[0] === 'C') { i++; dirty.push(path); continue; }
254
376
  if (code === '??' || code === '!!') {
255
- if (!isRegenerable(path)) outside.push(path);
377
+ if (!isRegenerable(path) && !copiedFromMain(wtPath, mainRoot, path)) outside.push(path);
256
378
  } else dirty.push(path);
257
379
  }
258
380
  return { dirty, outside, readable: true };
259
381
  }
260
382
 
383
+ // A file byte-identical to the same path in the main checkout is a copy, not the only copy: the
384
+ // preparation hook of `rsc worktrees new` puts the `.env` there exactly that way. Without this, every
385
+ // prepared worktree would hold "a file in no commit" for ever and no cleanup could take it. Compared
386
+ // by content, never by name: an `.env` edited inside the worktree differs and still blocks.
387
+ function copiedFromMain(wtPath, mainRoot, path) {
388
+ if (!mainRoot || path.endsWith('/')) return false;
389
+ const original = join(mainRoot, path);
390
+ if (real(original) === real(join(wtPath, path))) return false;
391
+ try {
392
+ return readFileSync(original).equals(readFileSync(join(wtPath, path)));
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
261
398
  // A directory name counts anywhere it is used as a DIRECTORY; a filename counts only as a filename.
262
399
  // Matching the table against the last segment made `config/env` — a dotenv full of live credentials —
263
400
  // read as a virtualenv and get deleted. Matching it only at position 0 fixed that and broke every
@@ -307,7 +444,7 @@ export function classifyWorktrees(root) {
307
444
  if (integration !== 'integrated') return { ...base, reasons: [integration] };
308
445
  if (!hasLandedWork(root, wt)) return { ...base, reasons: ['nothing-landed'] };
309
446
 
310
- const { dirty, outside, readable } = contentOutsideHistory(wt.path);
447
+ const { dirty, outside, readable } = contentOutsideHistory(wt.path, real(root));
311
448
  if (!readable) return { ...base, reasons: ['unreadable'] };
312
449
 
313
450
  const reasons = [];
@@ -371,7 +508,10 @@ export function reapWorktree(root, targetPath, { confirmed = false } = {}) {
371
508
  // `-d` refuses an unmerged branch, which is the recovery net — but it happily deletes the TRUNK,
372
509
  // which is an ancestor of everything. A sibling checkout of `main` classified as ambiguous, the user
373
510
  // confirmed removing a stray directory, and the trunk ref went with it.
374
- const branchDeleted = candidate.branch && !isTrunkName(candidate.branch)
511
+ // The default branch the remote announces is a trunk too, whatever it is called.
512
+ const announced = remoteDefault(root);
513
+ const isTrunk = isTrunkName(candidate.branch || '') || Boolean(announced && candidate.branch === announced.name);
514
+ const branchDeleted = candidate.branch && !isTrunk
375
515
  ? git(root, ['branch', '-d', candidate.branch]).ok
376
516
  : false;
377
517
  // No `git worktree prune` here. It has no expiry of its own, so running it while another worktree's