@dombaras/agent-harness 0.1.5 → 0.1.7

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
@@ -123,6 +123,19 @@ npx @dombaras/agent-harness update --target /path/to/project
123
123
  modified harness files. Your unrelated uncommitted work is never staged.
124
124
  - `--no-commit` to skip commit+push, `--no-push` to commit but not push.
125
125
  - `init` never commits.
126
+ - Harness-owned paths are **force-added**, so a project that gitignores deployment
127
+ artifacts (an inherited `.agents/` `.opencode/` `AGENTS.md` pattern) still gets the
128
+ harness commit instead of a raw `git add` failure that silently skips it — force-added
129
+ files are called out in the summary.
130
+ - **`[Nothing-to-Update]` confirmation**: if the target is already at this CLI's version and no
131
+ harness-owned file changed, `update` says so explicitly, writes nothing, and makes no commit —
132
+ a no-op is never mistaken for a sync and never churns `.harness.json`.
133
+ - **Downgrade guard**: `update` refuses to run on a target whose deployed harness is NEWER than
134
+ this CLI (e.g. deployed from a local checkout that was never published) and tells you to publish
135
+ that version first.
136
+ - **Published-version check**: if this CLI's version is not the latest `dist-tags.latest` on npm,
137
+ `update` warns you — catches the "bumped the version and updated a project from a local
138
+ checkout, but never published" gap before it silently forks the projects.
126
139
  - **Non-destructive**: if a harness-owned file was modified locally since the
127
140
  last deploy (tracked by checksum in `.harness.json`), it is backed up to
128
141
  `.harness-backup/<timestamp>/` before overwrite.
@@ -210,9 +210,12 @@ function isGitRepo(target) {
210
210
  // project's next session never sees "unexplained" modified harness files. Scoped
211
211
  // to this run's changed harness files (never `git add -A`) so unrelated uncommitted
212
212
  // work is NEVER swept in. Memory/backup files are excluded (project data / rollback).
213
- // Returns { committed:boolean, staged:string[], skipped:string[], pushed:boolean|null }.
213
+ // Harness-owned paths are force-added (`-f`): target projects often gitignore
214
+ // .agents/ .opencode/ AGENTS.md (inherited from the harness's own .gitignore), and
215
+ // a plain `git add` would fail on the first ignored path and commit NOTHING.
216
+ // Returns { committed:boolean, staged:string[], skipped:string[], pushed:boolean|null, forceAdded:string[] }.
214
217
  function commitHarnessChanges(target, changedRels, opts) {
215
- const out = { committed: false, staged: [], skipped: [], pushed: null };
218
+ const out = { committed: false, staged: [], skipped: [], pushed: null, forceAdded: [] };
216
219
  if (!opts.commit) return out;
217
220
  if (!isGitRepo(target)) {
218
221
  out.skipped.push("not a git repo");
@@ -222,9 +225,13 @@ function commitHarnessChanges(target, changedRels, opts) {
222
225
  out.skipped.push("no harness files changed");
223
226
  return out;
224
227
  }
225
- const addR = runGit(target, ["add", "--", ...changedRels]);
228
+ const ignored = runGit(target, ["check-ignore", ...changedRels]);
229
+ if (ignored.ok && ignored.out) {
230
+ out.forceAdded = ignored.out.split(/\r?\n/).filter(Boolean).map(relKey);
231
+ }
232
+ const addR = runGit(target, ["add", "-f", "--", ...changedRels]);
226
233
  if (!addR.ok) {
227
- out.skipped.push("git add failed: " + addR.err);
234
+ out.skipped.push("git add failed: " + firstGitErrorLine(addR.err));
228
235
  return out;
229
236
  }
230
237
  const diffCached = runGit(target, ["diff", "--cached", "--name-only"]);
@@ -238,7 +245,7 @@ function commitHarnessChanges(target, changedRels, opts) {
238
245
  const message = `chore(harness): @dombaras/agent-harness ${prior}${PKG.version}`;
239
246
  const commitR = runGit(target, ["commit", "-m", message]);
240
247
  if (!commitR.ok) {
241
- out.skipped.push("commit failed: " + commitR.err);
248
+ out.skipped.push("commit failed: " + firstGitErrorLine(commitR.err));
242
249
  return out;
243
250
  }
244
251
  out.committed = true;
@@ -247,7 +254,7 @@ function commitHarnessChanges(target, changedRels, opts) {
247
254
  if (hasRemote) {
248
255
  const pushR = runGit(target, ["push", "origin", "HEAD"]);
249
256
  out.pushed = pushR.ok;
250
- if (!pushR.ok) out.skipped.push("push failed: " + pushR.err);
257
+ if (!pushR.ok) out.skipped.push("push failed: " + firstGitErrorLine(pushR.err));
251
258
  } else {
252
259
  out.skipped.push("no git remote to push");
253
260
  }
@@ -255,6 +262,16 @@ function commitHarnessChanges(target, changedRels, opts) {
255
262
  return out;
256
263
  }
257
264
 
265
+ /* Pull the first real error line out of git's stderr, dropping the LF/CRLF
266
+ * warnings and `hint:` noise that bury the actual message. */
267
+ function firstGitErrorLine(err) {
268
+ const line = String(err || "")
269
+ .split(/\r?\n/)
270
+ .map((l) => l.trim())
271
+ .find((l) => l && !/^warning:/i.test(l) && !/^hint:/i.test(l));
272
+ return line || "git command failed";
273
+ }
274
+
258
275
  // ---------------------------------------------------------------- deploy
259
276
 
260
277
  function backup(target, rel, content) {
@@ -274,6 +291,17 @@ async function deploy(target, opts) {
274
291
  }
275
292
  const VARIABLES = { PROJECT_NAME: projectName, PROJECT_DOMAIN: projectDomain };
276
293
 
294
+ // Refuse to downgrade: the target's deployed harness is newer than this CLI.
295
+ // This is how the "I updated from a local checkout that I never published"
296
+ // mistake surfaces instead of silently writing older templates over a newer
297
+ // deployment (and auto-committing that regression).
298
+ if (isUpdate && existing.version && compareVersions(existing.version, PKG.version) > 0) {
299
+ throw new Error(
300
+ `"${projectName}" has harness v${existing.version} — NEWER than this CLI (v${PKG.version}). ` +
301
+ `Not downgrading. Publish v${existing.version} (or this CLI) so \`update\` can re-sync from the registry.`
302
+ );
303
+ }
304
+
277
305
  const previousFiles = existing.files || {};
278
306
  const actions = [];
279
307
  const manifest = {};
@@ -349,6 +377,16 @@ async function deploy(target, opts) {
349
377
  }
350
378
  }
351
379
 
380
+ // Nothing-to-update detection: an update that made no effective change to any
381
+ // harness-owned file and was already running this exact version is a no-op —
382
+ // report it clearly instead of printing a zero-delta "Updated", and leave the
383
+ // target byte-for-byte untouched (no .harness.json timestamp churn, no commit).
384
+ const changedRelsAll = actions
385
+ .filter((a) => ["create", "overwrite", "overwrite (backup)", "merge"].includes(a.kind))
386
+ .map((a) => a.rel);
387
+ const nothingToUpdate =
388
+ !!isUpdate && changedRelsAll.length === 0 && compareVersions(existing.version || "0", PKG.version) === 0;
389
+
352
390
  const config = {
353
391
  version: PKG.version,
354
392
  projectName,
@@ -357,22 +395,13 @@ async function deploy(target, opts) {
357
395
  updatedAt: new Date().toISOString(),
358
396
  files: manifest,
359
397
  };
360
- if (!dryRun) {
398
+ if (!dryRun && !nothingToUpdate) {
361
399
  fs.writeFileSync(path.join(target, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n", "utf8");
362
400
  }
363
401
 
364
- // Nothing-to-update detection: an update that made no effective change to any
365
- // harness-owned file and was already running this exact version is a no-op —
366
- // still report it clearly instead of printing a zero-delta "Updated".
367
- const changedRelsAll = actions
368
- .filter((a) => ["create", "overwrite", "overwrite (backup)", "merge"].includes(a.kind))
369
- .map((a) => a.rel);
370
- const nothingToUpdate =
371
- !!isUpdate && changedRelsAll.length === 0 && compareVersions(existing.version || "0", PKG.version) === 0;
372
-
373
402
  // Auto-commit only this run's changed harness-owned files (update only).
374
403
  let commitResult = null;
375
- if (isUpdate && !dryRun) {
404
+ if (isUpdate && !dryRun && !nothingToUpdate) {
376
405
  const changedRels = changedRelsAll.filter(
377
406
  (r) => !r.startsWith(".agents/memory/") && !r.startsWith(".harness-backup/")
378
407
  );
@@ -404,10 +433,22 @@ function summarize(actions) {
404
433
 
405
434
  function printSummary(result, isUpdate) {
406
435
  const { projectName, actions, dryRun } = result;
436
+
437
+ if (isUpdate && result.nothingToUpdate) {
438
+ console.log(
439
+ `\n[Nothing-to-Update] agent-harness v${PKG.version}: "${projectName}" is already at ` +
440
+ `v${result.priorVersion || PKG.version} and no harness-owned file changed.`
441
+ );
442
+ return;
443
+ }
444
+
407
445
  const verb = isUpdate
408
446
  ? (dryRun ? "Would update" : "Updated")
409
447
  : (dryRun ? "Would initialize" : "Initialized");
410
448
  console.log(`\nagent-harness v${PKG.version} ${verb.toLowerCase()} "${projectName}"` + (dryRun ? " (dry run)" : ""));
449
+ if (isUpdate && result.priorVersion && result.priorVersion !== PKG.version) {
450
+ console.log(` harness: v${result.priorVersion} -> v${PKG.version}`);
451
+ }
411
452
  for (const a of actions) console.log(` [${a.kind}] ${a.rel}`);
412
453
  const counts = summarize(actions);
413
454
  console.log(
@@ -417,6 +458,22 @@ function printSummary(result, isUpdate) {
417
458
  );
418
459
  }
419
460
 
461
+ /* Best-effort warning: when this CLI's version is not the latest published on
462
+ * the registry, flag it. This catches the "bumped + ran update from a local
463
+ * checkout, but never published" silent gap. */
464
+ function printUnpublishedWarning() {
465
+ const published = publishedLatestVersion();
466
+ if (published == null || published === "" || published === PKG.version) return;
467
+ const cmp = compareVersions(PKG.version, published);
468
+ if (cmp <= 0) return;
469
+ console.log(
470
+ `\n WARNING: this CLI is v${PKG.version}, but the latest version published on ` +
471
+ `npm is v${published}. If you ran this from a local checkout, the changes were ` +
472
+ `NOT published — Publish first, then \`update\` other projects, or they will ` +
473
+ `branch off an older harness.`
474
+ );
475
+ }
476
+
420
477
  function printNextSteps() {
421
478
  console.log(" Next steps:");
422
479
  console.log(" 1. Fill in `.agents/memory/domain-map.md`, `.agents/memory/stack-versions.md`,");
@@ -487,6 +544,7 @@ async function update(target, flags) {
487
544
  push: !flags["--no-commit"] && !flags["--no-push"],
488
545
  });
489
546
  printSummary(result, true);
547
+ printUnpublishedWarning();
490
548
  printCommitResult(result);
491
549
  }
492
550
 
@@ -496,6 +554,12 @@ function printCommitResult(result) {
496
554
  console.log("");
497
555
  if (c.committed) {
498
556
  console.log(" committed harness files: " + c.staged.join(", "));
557
+ if (c.forceAdded.length) {
558
+ console.log(
559
+ " force-added (gitignored in this project, but harness-owned): " +
560
+ c.forceAdded.join(", ")
561
+ );
562
+ }
499
563
  console.log(
500
564
  c.pushed === true
501
565
  ? " pushed to origin."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dombaras/agent-harness",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Reusable multi-agent harness for AI-assisted development: personas, skills, operating rules, model routing, and QA gates. Deploy into any project with `npx @dombaras/agent-harness init`.",
5
5
  "bin": {
6
6
  "agent-harness": "bin/agent-harness.js"