opencode-harness-kit 0.5.3 → 0.5.5

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.
@@ -374,17 +374,36 @@ function cmdLink() {
374
374
  fail("dist/index.js missing. Run: npm run build")
375
375
  }
376
376
  const dir = join(CONFIG_DIR, "plugins")
377
- mkdirSync(dir, { recursive: true })
377
+ try {
378
+ mkdirSync(dir, { recursive: true })
379
+ } catch (err) {
380
+ // The plugin is the guard. A config directory the user cannot write to
381
+ // is not a small problem, and the error used to arrive as a raw
382
+ // exception from deep inside the command.
383
+ fail(
384
+ `could not create ${dir}: ${err instanceof Error ? err.message : String(err)}. The guard is not enforcing anything without the plugin link.`,
385
+ )
386
+ }
378
387
  const target = join(dir, "opencode-harness-kit.js")
379
388
  // Relative to wherever this package actually is. The previous version wrote a
380
389
  // literal `../../../harness/space-harness/...`, which was right on one machine
381
390
  // and broken everywhere else, CI included.
382
- linkOne(dist, target)
391
+ const outcome = linkOne(dist, target)
392
+ // The link is the whole guard. A plugin that is not linked is a guard that
393
+ // is not enforcing anything, and the command used to say "linked" whether
394
+ // or not the link was there - a real file at that path (a copy somebody
395
+ // made) was enough to get the tick.
396
+ if (outcome !== "linked") {
397
+ fail(
398
+ `could not link the plugin into ${target} (${outcome}). The guard is not enforcing anything without it.`,
399
+ )
400
+ }
383
401
  console.log(`\n ${green("✓")} linked ${target}`)
384
402
  console.log(
385
403
  ` ${stamp("opencode loads ~/.config/opencode/plugins automatically")}\n` +
386
404
  ` ${stamp("restart opencode, then: opencode debug config")}\n`,
387
405
  )
406
+ return outcome
388
407
  }
389
408
 
390
409
  /** Where the behavioural layer has to land for the config to mean anything. */
@@ -510,7 +529,12 @@ async function cmdInstall() {
510
529
  if (!existsSync(dist)) fail("dist/index.js missing. Run: npm run build")
511
530
 
512
531
  // The plugin link first: without it the guard is not enforcing anything.
513
- cmdLink()
532
+ //
533
+ // The outcome is kept, because the link is the one target whose absence
534
+ // makes the kit do NOTHING: the prompts and the skills are still worth
535
+ // having, but the guard, the pruning and the lane failover are all in the
536
+ // plugin, and a copy somebody left at that path is not it.
537
+ const pluginOutcome = cmdLink()
514
538
 
515
539
  const legacy = removeLegacyAgentFiles()
516
540
  if (legacy.length) {
@@ -519,9 +543,26 @@ async function cmdInstall() {
519
543
  )
520
544
  }
521
545
 
546
+ // Every target the kit needs, and what happened to each of them.
547
+ //
548
+ // The list is the point. The install used to print "installed into" and
549
+ // exit 0 whether it had linked everything, linked nothing, or left the
550
+ // user's own file in place because the prompt was answered no - and a kit
551
+ // that is not installed is a kit that is not running, which is exactly the
552
+ // kind of thing this command is supposed to be the last word on.
553
+ //
554
+ // Two lists, because the two mean different things. A link that FAILED is
555
+ // a step that failed: the exit code is 1. A target that was LEFT ALONE is
556
+ // a file of the user's that the installer refused to destroy, which
557
+ // docs/EXIT-CODES.md says is exit 0 with the reason printed - and the
558
+ // reason is printed, loudly, and `verify` reports the target as missing.
559
+ const failed = []
560
+ const leftAlone = []
561
+
522
562
  for (const { src, dest, kind } of installTargets()) {
523
563
  if (!existsSync(src)) {
524
564
  console.log(` ${yellow("!")} ${stamp(`${kind} missing in repo: ${src}`)}`)
565
+ failed.push(`${dest} (missing in the package)`)
525
566
  continue
526
567
  }
527
568
  const parent = dirname(dest)
@@ -529,6 +570,7 @@ async function cmdInstall() {
529
570
 
530
571
  // A link that already points at us needs no permission. A file that holds
531
572
  // something else would be destroyed by the link, so that gets a question.
573
+ let force = false
532
574
  if (kind === "file" && existsSync(dest) && interactive()) {
533
575
  let ours = false
534
576
  try {
@@ -538,9 +580,14 @@ async function cmdInstall() {
538
580
  } catch {
539
581
  ours = false
540
582
  }
541
- if (!ours && !(await confirm(`overwrite ${dest}?`))) {
542
- console.log(` ${stamp(`left alone: ${dest}`)}`)
543
- continue
583
+ if (!ours) {
584
+ if (await confirm(`overwrite ${dest}?`)) {
585
+ force = true
586
+ } else {
587
+ console.log(` ${stamp(`left alone: ${dest}`)}`)
588
+ leftAlone.push(`${dest} (left alone at the prompt)`)
589
+ continue
590
+ }
544
591
  }
545
592
  }
546
593
 
@@ -553,14 +600,51 @@ async function cmdInstall() {
553
600
  if (!entry.isDirectory() && !entry.name.endsWith(".md") && !entry.name.endsWith(".json")) {
554
601
  continue
555
602
  }
556
- linkOne(join(src, entry.name), join(dest, entry.name))
603
+ const outcome = linkOne(join(src, entry.name), join(dest, entry.name))
604
+ if (outcome === "failed") {
605
+ failed.push(`${join(dest, entry.name)} (could not be linked)`)
606
+ } else if (outcome === "left alone") {
607
+ leftAlone.push(`${join(dest, entry.name)} (a real file that is not the kit's)`)
608
+ }
557
609
  }
558
610
  continue
559
611
  }
560
- linkOne(src, dest)
612
+ const outcome = linkOne(src, dest, { force })
613
+ if (outcome === "failed") {
614
+ failed.push(`${dest} (could not be linked)`)
615
+ } else if (outcome === "left alone") {
616
+ leftAlone.push(`${dest} (a real file that is not the kit's)`)
617
+ }
618
+ }
619
+
620
+ if (pluginOutcome !== "linked") {
621
+ failed.push(
622
+ `${join(CONFIG_DIR, "plugins", "opencode-harness-kit.js")} (${pluginOutcome} - the guard, the pruning and the lane failover are all in it)`,
623
+ )
624
+ }
625
+
626
+ if (failed.length) {
627
+ console.log(`\n ${red("✗")} the install failed: ${failed.length} target(s) are not in place`)
628
+ for (const t of failed) console.log(` ${stamp(t)}`)
629
+ console.log(
630
+ `\n ${stamp("the kit is not in place, so the parts that depend on it are not running")}`,
631
+ )
632
+ process.exit(1)
561
633
  }
562
634
 
563
- console.log(`\n ${green("✓")} installed into ${CONFIG_DIR}`)
635
+ if (leftAlone.length) {
636
+ // Not a failure, and not a success either. The reason is printed, which
637
+ // is what docs/EXIT-CODES.md asks for, and the target is NOT counted as
638
+ // installed: a tick next to a file that holds somebody else's rules is a
639
+ // claim the installer cannot make.
640
+ console.log(`\n ${yellow("!")} installed into ${CONFIG_DIR}, but ${leftAlone.length} target(s) were left alone:`)
641
+ for (const t of leftAlone) console.log(` ${stamp(t)}`)
642
+ console.log(
643
+ `\n ${stamp("the kit's own file is not in place, so whatever reads it reads somebody else's. Run: oc-harness verify")}`,
644
+ )
645
+ } else {
646
+ console.log(`\n ${green("✓")} installed into ${CONFIG_DIR}`)
647
+ }
564
648
  for (const { dest } of installTargets()) {
565
649
  console.log(` ${stamp(` ${dest.replace(`${homedir()}/`, "~/")}`)}`)
566
650
  }
@@ -691,8 +775,19 @@ async function askChoice(question) {
691
775
  * /private/var, so a relative path computed from /var/... resolves against
692
776
  * /private/var/... and a link built that way ends up one level short of its
693
777
  * target - a broken symlink that only appears outside the home directory.
778
+ *
779
+ * The outcome is RETURNED, and the install needs it. This function used to
780
+ * print a warning and return nothing, so a caller that had linked nothing
781
+ * could not tell it apart from one that had linked everything - and the
782
+ * install said "installed" either way.
783
+ *
784
+ * @param {string} src
785
+ * @param {string} dest
786
+ * @param {{ force?: boolean }} [opts] `force` replaces a real file that is not
787
+ * the kit's. The installer sets it only where the user has just said yes.
788
+ * @returns {"linked" | "left alone" | "failed"}
694
789
  */
695
- function linkOne(src, dest) {
790
+ function linkOne(src, dest, opts = {}) {
696
791
  const parent = realpathSync(dirname(dest))
697
792
  const target = relative(parent, realpathSync(src))
698
793
 
@@ -704,7 +799,7 @@ function linkOne(src, dest) {
704
799
  } catch {
705
800
  // Nothing at `dest` yet.
706
801
  }
707
- if (ours) return
802
+ if (ours) return "linked"
708
803
 
709
804
  if (isLink) {
710
805
  // A symlink is a symlink whether or not its target exists, and a
@@ -729,16 +824,26 @@ function linkOne(src, dest) {
729
824
  // agent was deleted and replaced by a symlink without a word. A real
730
825
  // directory is left alone too -- unlinking it is not this installer's
731
826
  // call to make.
732
- const kind = lstatSync(dest).isDirectory() ? "directory" : "file"
733
- console.log(` ${yellow("!")} ${stamp(`left alone: ${dest} is a real ${kind} and was not ours to replace`)}`)
734
- return
827
+ //
828
+ // `force` is the user having just said yes at the prompt. It replaces a
829
+ // FILE, and a file only: a directory is somebody's collection of prompts
830
+ // and no answer to one question is consent to delete it.
831
+ const isDir = lstatSync(dest).isDirectory()
832
+ if (isDir || !opts.force) {
833
+ const kind = isDir ? "directory" : "file"
834
+ console.log(` ${yellow("!")} ${stamp(`left alone: ${dest} is a real ${kind} and was not ours to replace`)}`)
835
+ return "left alone"
836
+ }
837
+ rmSync(dest, { force: true })
735
838
  }
736
839
 
737
840
  try {
738
841
  symlinkSync(target, dest)
739
842
  } catch (err) {
740
843
  console.log(` ${yellow("!")} ${stamp(`could not link ${dest}: ${err instanceof Error ? err.message : String(err)}`)}`)
844
+ return "failed"
741
845
  }
846
+ return "linked"
742
847
  }
743
848
 
744
849
  /**
@@ -788,11 +893,21 @@ function cmdVerify() {
788
893
  // over a harness that was doing nothing.
789
894
  const instructions = /** @type {any} */ (actual?.instructions) ?? [globalInstructionsPath()]
790
895
  for (const raw of instructions) {
791
- const target = raw.startsWith("~")
792
- ? join(homedir(), raw.slice(1))
793
- : raw.startsWith("/")
794
- ? raw
795
- : join(process.cwd(), raw)
896
+ // A relative path is not a path as far as opencode is concerned.
897
+ //
898
+ // The kit's own source says it: "A relative reference is silently ignored
899
+ // by opencode". The check here resolved it against the directory the
900
+ // command happened to be run from, so a relative instruction "resolved"
901
+ // or not depending on where the user was standing - and the verdict was
902
+ // a statement about the shell's working directory rather than about the
903
+ // config.
904
+ if (!raw.startsWith("~") && !raw.startsWith("/")) {
905
+ problems.push(
906
+ `instruction "${raw}" is a relative path, and opencode ignores relative instruction paths - the rules in it are not being loaded. Use an absolute path, or one that starts with ~`,
907
+ )
908
+ continue
909
+ }
910
+ const target = raw.startsWith("~") ? join(homedir(), raw.slice(1)) : raw
796
911
  if (existsSync(target)) {
797
912
  ok.push(`instruction ${raw} resolves`)
798
913
  } else {
@@ -813,6 +928,15 @@ function cmdVerify() {
813
928
  problems.push(
814
929
  `${kitInstruction} does not exist - the rules in it are not being loaded. Run: oc-harness install`,
815
930
  )
931
+ } else if (!pointsIntoKit(kitTarget)) {
932
+ // The kit's own file, and not a file that happens to have its name. The
933
+ // installer refuses to replace a real file, so a user who already had a
934
+ // GLOBAL.md keeps it - and the check above was satisfied by its mere
935
+ // existence, so the report said the rules were loaded while the file
936
+ // being read was somebody else's.
937
+ problems.push(
938
+ `${kitInstruction} is not the kit's file - the rules being loaded are not the ones this kit ships. Run: oc-harness install`,
939
+ )
816
940
  }
817
941
 
818
942
  // 3. Every lane the profile enables must have its prompt installed, and the
@@ -830,7 +954,25 @@ function cmdVerify() {
830
954
  )
831
955
  continue
832
956
  }
833
- ok.push(`prompt ${role}.md installed`)
957
+ // The file has to be the KIT'S file, not a file that happens to sit at
958
+ // that path.
959
+ //
960
+ // The installer refuses to replace a real file, so a user who already
961
+ // had a prompts/build.md keeps it. The check above was happy with a file
962
+ // at that path, so the lane ran on the user's own prompt while the
963
+ // report said the prompt was installed - and the whole behavioural layer
964
+ // of the kit was absent with a green tick next to it.
965
+ //
966
+ // The config's own reference is still checked below: the lane's prompt
967
+ // path is a separate thing, and a broken one is worth saying whatever
968
+ // the file at that path happens to be.
969
+ if (!pointsIntoKit(file)) {
970
+ problems.push(
971
+ `prompts/${role}.md is not the kit's file - the ${role} lane runs on somebody else's instructions. Run: oc-harness install`,
972
+ )
973
+ } else {
974
+ ok.push(`prompt ${role}.md installed`)
975
+ }
834
976
  const ref = actual?.agent?.[role]?.prompt
835
977
  if (ref) {
836
978
  const target = ref.replace(/^\{file:/, "").replace(/\}$/, "")
@@ -874,10 +1016,17 @@ function cmdVerify() {
874
1016
  problems.push(`agent file ${where}/${name} cannot be read (a broken symlink?)`)
875
1017
  continue
876
1018
  }
1019
+ // A model reference, and only a model reference.
1020
+ //
1021
+ // The pattern used to match `opencode/opencode.jsonc` - the name of a
1022
+ // FILE - and report it as a model that is not in the profile, so a
1023
+ // perfectly good agent that mentioned its config was reported as a
1024
+ // problem. The id has to look like a model id: no file extension.
877
1025
  const named = text.match(/opencode\/([a-z0-9.\-]+)/i)
878
- if (named && !modelsInPlay(p).includes(named[1])) {
1026
+ const id = named?.[1]
1027
+ if (id && !/\.(json|jsonc|md|js|ts|txt|ya?ml)$/i.test(id) && !modelsInPlay(p).includes(id)) {
879
1028
  problems.push(
880
- `agent file ${where}/${name} mentions opencode/${named[1]}, which is not in the active profile`,
1029
+ `agent file ${where}/${name} mentions opencode/${id}, which is not in the active profile`,
881
1030
  )
882
1031
  }
883
1032
  }
@@ -955,6 +1104,23 @@ function cmdVerify() {
955
1104
  )
956
1105
  continue
957
1106
  }
1107
+ // The kit's own skill, and not a file that happens to sit at that path.
1108
+ // The installer leaves a real directory alone, so a user with their own
1109
+ // skill under the kit's name keeps it - and the loop would run on it
1110
+ // while the report said the skill was installed.
1111
+ //
1112
+ // The link is on the DIRECTORY: the installer links the skill folder
1113
+ // itself, so the SKILL.md inside it is reached through that link and
1114
+ // is not a link of its own.
1115
+ //
1116
+ // This does not stop the frontmatter check below. A directory that is
1117
+ // not the kit's can still be a skill whose name has drifted away from
1118
+ // its directory, and that is a second thing worth saying.
1119
+ if (!pointsIntoKit(join(skillsDir, name))) {
1120
+ problems.push(
1121
+ `skills/${name} is not the kit's skill - the ${name} step of the loop runs on somebody else's instructions. Run: oc-harness install`,
1122
+ )
1123
+ }
958
1124
  const front = stripFrontmatter(readFileSync(skillFile, "utf8"))
959
1125
  if (front.name !== name) {
960
1126
  problems.push(`skills/${name}/SKILL.md names "${front.name ?? "nothing"}", expected "${name}"`)
@@ -1096,7 +1262,22 @@ function memorySpec() {
1096
1262
 
1097
1263
  async function cmdUse(id) {
1098
1264
  if (!id) fail("usage: oc-harness use <profile> [--print] [--yes]")
1099
- const p = loadProfile(id)
1265
+ let p = null
1266
+ try {
1267
+ p = loadProfile(id)
1268
+ } catch (err) {
1269
+ // The loader refuses a model whose retention nobody has stated, and the
1270
+ // message it produces does not say what to DO about it. The check below
1271
+ // says exactly that, so it is worth reaching from here too.
1272
+ const message = err instanceof Error ? err.message : String(err)
1273
+ if (/has no retention\.yml entry/.test(message)) {
1274
+ fail(
1275
+ `retention for the models in ${id} is not recorded in retention.yml.\n` +
1276
+ `Add an entry with retention: zero-retention | may-train before installing it.`,
1277
+ )
1278
+ }
1279
+ fail(message)
1280
+ }
1100
1281
 
1101
1282
  // A split profile puts the user behind every model in play, not just the base
1102
1283
  // one. `p.retention` describes the base only, so gating on it let a profile
@@ -1174,11 +1355,18 @@ async function cmdUse(id) {
1174
1355
  fail("left the existing config alone")
1175
1356
  }
1176
1357
  const kept = keepBackup(readFileSync(CONFIG_FILE, "utf8"))
1177
- if (kept) {
1178
- console.error(
1179
- stamp(` existing config backed up to ${join(BACKUP_DIR, kept)}`),
1358
+ if (!kept) {
1359
+ // The backup is the safety net for an overwrite, and this is the one
1360
+ // path that overwrites without asking. A backup that could not be
1361
+ // written and was not mentioned leaves the user with no way back and
1362
+ // no idea that they have none.
1363
+ fail(
1364
+ `could not write a backup of ${CONFIG_FILE}, so the config was left alone. Fix the permissions on ${join(CONFIG_DIR, BACKUP_DIR)} and run it again`,
1180
1365
  )
1181
1366
  }
1367
+ console.error(
1368
+ stamp(` existing config backed up to ${join(BACKUP_DIR, kept)}`),
1369
+ )
1182
1370
  }
1183
1371
  writeFileSync(CONFIG_FILE, out)
1184
1372
  console.log(`\n ${green("✓")} wrote ${CONFIG_FILE}`)
@@ -1415,7 +1603,16 @@ function pointsIntoKit(p) {
1415
1603
  if (!lstatSync(p).isSymbolicLink()) return false
1416
1604
  const target = readlinkSync(p)
1417
1605
  const resolved = isAbsolute(target) ? target : resolve(dirname(p), target)
1418
- return resolved.startsWith(ROOT + sep) || resolved.startsWith(ROOT + "/")
1606
+ // The kit's own directory is compared in its CANONICAL form.
1607
+ //
1608
+ // The link is written to `realpathSync(src)`, so its target is canonical,
1609
+ // while ROOT is whatever path the package was reached through - and a
1610
+ // package under a linked home, or under /tmp on macOS, is not canonical.
1611
+ // Comparing the two as they are would call a correct install somebody
1612
+ // else's, and the checks that use this would report a kit that is
1613
+ // installed as one that is not.
1614
+ const root = realpathSync(ROOT)
1615
+ return resolved === root || resolved.startsWith(root + sep) || resolved.startsWith(root + "/")
1419
1616
  } catch {
1420
1617
  return false
1421
1618
  }
@@ -1511,6 +1708,9 @@ function keepProfileBackup(path, text) {
1511
1708
  * Keep a copy of the config before it is overwritten.
1512
1709
  *
1513
1710
  * @param {string} text the config as it is now
1711
+ * @returns {string | null} the backup's name, or null when it could not be
1712
+ * written. The caller has to treat null as a refusal to go on: a backup
1713
+ * that silently did not happen is a safety net that is not there.
1514
1714
  */
1515
1715
  function keepBackup(text) {
1516
1716
  try {
@@ -1518,8 +1718,15 @@ function keepBackup(text) {
1518
1718
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
1519
1719
  // The most recent first, and only a few: a backup nobody can read
1520
1720
  // through is not a backup.
1721
+ //
1722
+ // Ten of them, not one. The old code removed the nine OLDEST files and
1723
+ // then wrote the new one, so the directory held a single config no
1724
+ // matter how many times the command ran - and the comment above it
1725
+ // promised a history.
1521
1726
  const files = readdirSync(dir).filter((f) => f.endsWith(".jsonc")).sort()
1522
- for (const f of files.slice(0, 9)) rmSync(join(dir, f), { force: true })
1727
+ for (const f of files.slice(0, Math.max(0, files.length - 9))) {
1728
+ rmSync(join(dir, f), { force: true })
1729
+ }
1523
1730
  const stamp = new Date().toISOString().replace(/[:.]/g, "-")
1524
1731
  const name = `opencode-${stamp}.jsonc`
1525
1732
  writeFileSync(join(dir, name), text)
@@ -1577,27 +1784,45 @@ function cmdCost() {
1577
1784
  let saved = 0
1578
1785
  let stubbed = 0
1579
1786
  if (pruneLog && existsSync(pruneLog)) {
1580
- try {
1581
- for (const line of readFileSync(pruneLog, "utf8").split("\n")) {
1582
- if (!line.trim()) continue
1583
- const r = JSON.parse(line)
1584
- prunes++
1585
- // The published 0.4.3 wrote `savedBytes` into the same file, and the
1586
- // file is project-local and persistent, so the old records are still
1587
- // there. Reading only the new name would report every project that
1588
- // upgraded as having saved nothing.
1589
- saved += r.savedChars ?? r.savedBytes ?? 0
1590
- stubbed += r.stubbed ?? 0
1787
+ for (const line of readFileSync(pruneLog, "utf8").split("\n")) {
1788
+ if (!line.trim()) continue
1789
+ // One bad line, not the whole log.
1790
+ //
1791
+ // The parse used to be wrapped around the LOOP, so a single malformed
1792
+ // record - a half-written line, a record from a future version - threw
1793
+ // the whole file away and the command said the pruner had never run.
1794
+ // The log is append-only and project-local, so one bad line is a
1795
+ // reason to skip a line and not a reason to lose the history.
1796
+ let r = null
1797
+ try {
1798
+ r = JSON.parse(line)
1799
+ } catch {
1800
+ continue
1591
1801
  }
1592
- } catch {
1593
- // A log that cannot be read is a log with nothing in it.
1802
+ prunes++
1803
+ // The published 0.4.3 wrote `savedBytes` into the same file, and the
1804
+ // file is project-local and persistent, so the old records are still
1805
+ // there. Reading only the new name would report every project that
1806
+ // upgraded as having saved nothing.
1807
+ saved += r.savedChars ?? r.savedBytes ?? 0
1808
+ stubbed += r.stubbed ?? 0
1594
1809
  }
1595
1810
  }
1596
1811
  console.log(` ${bold("pruned")}`)
1597
1812
  if (!prunes) {
1598
1813
  console.log(` ${stamp("nothing yet - the pruner has not run in this project")}`)
1599
1814
  } else {
1600
- console.log(` ${bold(saved.toLocaleString("en-US"))} characters removed over ${prunes} pass(es)`)
1815
+ // The number can be negative, and it is: the stub is a line of text
1816
+ // standing in for a tool output, and the stub's own metadata is part of
1817
+ // the request that goes out. A pass that made the request BIGGER says
1818
+ // so, rather than being reported as a saving.
1819
+ if (saved < 0) {
1820
+ console.log(
1821
+ ` ${bold(Math.abs(saved).toLocaleString("en-US"))} characters ADDED over ${prunes} pass(es) - the request grew`,
1822
+ )
1823
+ } else {
1824
+ console.log(` ${bold(saved.toLocaleString("en-US"))} characters removed over ${prunes} pass(es)`)
1825
+ }
1601
1826
  console.log(` ${bold(stubbed.toLocaleString("en-US"))} tool outputs stubbed`)
1602
1827
  console.log(` ${stamp("the same context, re-sent on every request of the session")}`)
1603
1828
  }
@@ -1709,12 +1934,20 @@ function cmdUninstall() {
1709
1934
  kept.push(`${dir}/ holds real files`)
1710
1935
  continue
1711
1936
  }
1937
+ let removedHere = 0
1712
1938
  for (const f of ours) {
1713
1939
  try {
1714
1940
  rmSync(join(full, f), { force: true })
1715
- } catch {}
1941
+ removedHere++
1942
+ } catch {
1943
+ // A removal that did not happen is not a removal. The old code
1944
+ // counted every link it had tried to unlink, so a directory the
1945
+ // user could not write to was reported as removed and was still
1946
+ // there, pointing into a package that was on its way out.
1947
+ kept.push(`${dir}/${f} (could not be removed)`)
1948
+ }
1716
1949
  }
1717
- removed.push(`${dir}/ (${ours.length} link(s))`)
1950
+ if (removedHere) removed.push(`${dir}/ (${removedHere} link(s))`)
1718
1951
  }
1719
1952
  for (const t of targets) {
1720
1953
  if (!existsSync(t) && !isSymlink(t)) continue
@@ -1733,7 +1966,7 @@ function cmdUninstall() {
1733
1966
  rmSync(t, { force: true })
1734
1967
  removed.push(t)
1735
1968
  } catch {
1736
- kept.push(t)
1969
+ kept.push(`${t} (could not be removed)`)
1737
1970
  }
1738
1971
  }
1739
1972
  console.log(`\n${bold("uninstall")}\n`)
package/dist/config.d.ts CHANGED
@@ -81,6 +81,11 @@ export declare function utilityModel(p: any): string | null;
81
81
  * `instructions` array used to replace it wholesale, which unloaded the
82
82
  * whole behavioural layer while everything still looked correct.
83
83
  *
84
+ * A SINGLE string is a list of one. The parameter has always been typed
85
+ * `string[] | string | undefined`, but a string was dropped on the floor:
86
+ * the file that came back named only the kit's GLOBAL.md, so the user's own
87
+ * instructions were unloaded without a word.
88
+ *
84
89
  * The user's own files keep their order and come first; the kit's is
85
90
  * appended. Duplicates are dropped, so re-running `use` does not grow the
86
91
  * list.
@@ -5,6 +5,10 @@ export declare const BASH_DENYLIST: string[];
5
5
  *
6
6
  * `*` matches any run of characters, including none.
7
7
  *
8
+ * Every other metacharacter is literal. `?` is a regex quantifier, so an
9
+ * unescaped one made the character before it optional and the pattern
10
+ * matched a command it never named.
11
+ *
8
12
  * @param {string} command
9
13
  * @param {string} pattern
10
14
  * @returns {boolean}
@@ -23,7 +27,7 @@ export declare function matchesPattern(command: string, pattern: string): boolea
23
27
  * same list and one of them reads it more carefully; that is the price of
24
28
  * having the list in two places.
25
29
  *
26
- * The two PIPE patterns are for the config's side only. `segments()` splits
30
+ * The PIPE patterns are for the config's side only. `segments()` splits
27
31
  * on `|`, so a segment never contains " | sh" and the guard catches a piped
28
32
  * script through its own rule instead.
29
33
  *
package/dist/guard.d.ts CHANGED
@@ -5,7 +5,23 @@ export declare const MARKER = "[opencode-harness-kit] blocked";
5
5
  *
6
6
  * `apply_patch` sends a patch, not a path, so the paths have to be read out
7
7
  * of it. Two formats are in use: opencode's own `*** Update File: path`
8
- * markers, and a plain unified diff with `+++ b/path` headers.
8
+ * markers, and a plain unified diff with `--- a/path` and `+++ b/path`
9
+ * headers.
10
+ *
11
+ * A unified diff names the file TWICE: `--- a/path` is where the change
12
+ * comes FROM, `+++ b/path` is where it goes TO. A deletion is `--- a/path`
13
+ * and `+++ /dev/null`, so the path that has to be scope-checked is the one
14
+ * in the `---` header and nowhere else. Reading only the `+++` header meant
15
+ * a patch that deleted a file outside the project was checked against
16
+ * nothing at all - and a patch that deletes a file is exactly the shape
17
+ * that reaches outside the project.
18
+ *
19
+ * The headers of a diff come BEFORE its first hunk, and a body line can
20
+ * look like a header: a removed line whose content is "-- foo" renders as
21
+ * "--- foo", and an added line "++ bar" renders as "+++ bar". Reading
22
+ * those as the headers of a file collected two words as paths, and a path
23
+ * that is not a path is a write the guard refuses for a reason nobody can
24
+ * find.
9
25
  *
10
26
  * @param {string} text
11
27
  * @returns {string[]}