opencode-harness-kit 0.5.4 → 0.5.6
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/CHANGELOG.md +207 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +514 -73
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +53 -11
- package/eval/measure-prune.mjs +228 -54
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +45 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +440 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/telemetry.ts +69 -9
- package/src/trust.js +182 -54
package/bin/oc-harness.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/${
|
|
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
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
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
|
-
|
|
1593
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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}/ (${
|
|
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.
|
package/dist/denylist.d.ts
CHANGED
|
@@ -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
|
|
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`
|
|
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[]}
|