@raresportan/marram 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@raresportan/marram",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "The gate, ratchet lints and journeys that keep agent-written code from drifting.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -6,6 +6,7 @@
6
6
  // progress: /lines from a stage worth showing as it runs/, // besides ✓ and ✗ lines
7
7
  // sleepGapMs: 30000, // how long a pause counts as the machine sleeping, so a stage that failed runs again
8
8
  // tiers: { fast: [stage, …], default: [stage, …], full: [stage, …] }, // stage: { name, run: [command, …args], env? }
9
+ // prePush: "full", // the gate a push must pass (marram hook pre-push): "default" unless set
9
10
  // },
10
11
  // review: { classes: "the mistake-classes doc", rungs: ["type", "lint", …] },
11
12
  // };
@@ -78,9 +78,23 @@ export function updateState(cwd, change) {
78
78
  });
79
79
  }
80
80
 
81
- /** `{ passed, stage?, log?, at }` for a tree the default or full gate passed, or failed less than 15 minutes ago. */
82
- export function verdictOf(cwd, tree) {
83
- const verdict = readState(cwd).trees[tree];
84
- if (verdict === undefined || verdict.passed) return verdict;
85
- return Date.now() - verdict.at < FAILURE_TTL_MS ? verdict : undefined;
81
+ /**
82
+ * `{ passed, tier, stage?, log?, at }` for a tree `tier` (the default gate, or the full one) passed, or failed less than
83
+ * 15 minutes ago. A full pass counts for the default gate.
84
+ */
85
+ export function verdictOf(cwd, tree, tier = "default") {
86
+ const trees = readState(cwd).trees;
87
+ const own = current(trees[verdictKey(tree, tier)]);
88
+ // The full gate runs every default stage, so its pass answers for the default gate too.
89
+ if (own === undefined && tier === "default" && trees[verdictKey(tree, "full")]?.passed) return trees[verdictKey(tree, "full")];
90
+ return own;
86
91
  }
92
+
93
+ /** A verdict that still counts: a pass, or a failure from the last FAILURE_TTL_MS. */
94
+ const current = (verdict) => (verdict === undefined || verdict.passed || Date.now() - verdict.at < FAILURE_TTL_MS ? verdict : undefined);
95
+
96
+ /**
97
+ * Where a tier's verdict on `tree` is kept: the default gate's under the tree, the full gate's beside it, so a default run
98
+ * after a full pass doesn't replace it.
99
+ */
100
+ export const verdictKey = (tree, tier) => (tier === "full" ? `${tree}:full` : tree);
package/src/core/gate.mjs CHANGED
@@ -12,7 +12,7 @@ import { spawn } from "node:child_process";
12
12
  import { createWriteStream } from "node:fs";
13
13
  import { mkdir, rename, writeFile } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
- import { treeOf, updateState } from "./gate-state.mjs";
15
+ import { treeOf, updateState, verdictKey } from "./gate-state.mjs";
16
16
 
17
17
  const TIERS = ["fast", "default", "full"];
18
18
 
@@ -77,7 +77,7 @@ export async function gate(root, config, [tier = "default"]) {
77
77
  // A fast pass doesn't make a change done, so only the default and full gates record a verdict.
78
78
  const tree = tier === "fast" ? undefined : treeOf(root);
79
79
  const verdict = (passed, extra = {}) => {
80
- if (tree !== undefined) updateState(root, (state) => { state.trees[tree] = { passed, tier, at: Date.now(), ...extra }; });
80
+ if (tree !== undefined) updateState(root, (state) => { state.trees[verdictKey(tree, tier)] = { passed, tier, at: Date.now(), ...extra }; });
81
81
  };
82
82
 
83
83
  console.log(`Gate (${tier}). Logs: ${dir}`);
package/src/core/hook.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Runs the gate where work is declared done, so nothing finishes or ships while it's red. Usage:
2
- // marram hook pre-push git's pre-push hook: what's pushed must have passed.
2
+ // marram hook pre-push git's pre-push hook: what's pushed must have passed `gate.prePush`, the default gate
3
+ // unless that names "full".
3
4
  // marram hook claude-turn Claude Code's UserPromptSubmit: notes the code a turn starts from.
4
5
  // marram hook claude-stop Claude Code's Stop: a turn that changed code ends only once the gate passes, sent back at
5
6
  // most 3 times a turn.
@@ -15,16 +16,16 @@ const MAX_SENT_BACK = 3;
15
16
  const MARRAM = join(import.meta.dirname, "..", "..", "bin", "marram.mjs");
16
17
  const stdin = () => readFileSync(0, "utf8");
17
18
 
18
- /** The gate's verdict on `tree`, running the default gate when there is none. `show` sends its progress to the terminal. */
19
- async function judge(root, tree, show) {
20
- const known = verdictOf(root, tree);
19
+ /** `tier`'s verdict on `tree`, running that gate when there is none. `show` sends its progress to the terminal. */
20
+ async function judge(root, tree, show, tier = "default") {
21
+ const known = verdictOf(root, tree, tier);
21
22
  if (known !== undefined) return { ...known, reused: true };
22
- const gate = spawn(process.execPath, [MARRAM, "gate"], { cwd: root, stdio: ["ignore", show ? "inherit" : "ignore", "inherit"] });
23
+ const gate = spawn(process.execPath, [MARRAM, "gate", ...(tier === "default" ? [] : [tier])], { cwd: root, stdio: ["ignore", show ? "inherit" : "ignore", "inherit"] });
23
24
  // A hook that's killed (its timeout) passes it on, so the gate can stop what it started.
24
25
  for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.once(signal, () => gate.kill(signal));
25
26
  await new Promise((done) => gate.once("close", done));
26
27
  // A gate that changed files as it ran records no pass; it counts as failed with no stage.
27
- return verdictOf(root, tree) ?? { passed: false };
28
+ return verdictOf(root, tree, tier) ?? { passed: false };
28
29
  }
29
30
 
30
31
  /** What an agent is told when the gate fails: where it failed, and the end of that stage's log. */
@@ -45,19 +46,24 @@ function failure(verdict) {
45
46
  ].join("\n");
46
47
  }
47
48
 
48
- async function prePush(root) {
49
+ async function prePush(root, config) {
50
+ const tier = config.gate?.prePush ?? "default";
51
+ if (tier !== "default" && tier !== "full") {
52
+ console.error(`gate.prePush is "${tier}": a push can require the default gate or the full one.`);
53
+ process.exit(1);
54
+ }
49
55
  // stdin: one line per ref pushed, "<local ref> <local sha> <remote ref> <remote sha>".
50
56
  for (const line of stdin().split("\n").filter(Boolean)) {
51
57
  const [ref, sha] = line.split(" ");
52
58
  if (/^0+$/.test(sha)) continue; // a deletion
53
59
  const tree = git(root, ["rev-parse", `${sha}^{tree}`]);
54
- if (verdictOf(root, tree)?.passed) continue;
60
+ if (verdictOf(root, tree, tier)?.passed) continue;
55
61
  if (treeOf(root) !== tree) {
56
- console.error(`${ref} hasn't passed the gate, and the working tree isn't that commit, so it can't be checked here.\nCommit or stash your changes, check it out and push again (or \`git push --no-verify\` to skip the gate).`);
62
+ console.error(`${ref} hasn't passed the ${tier} gate, and the working tree isn't that commit, so it can't be checked here.\nCommit or stash your changes, check it out and push again (or \`git push --no-verify\` to skip the gate).`);
57
63
  process.exit(1);
58
64
  }
59
- console.error(`Gating ${ref} before it's pushed (\`git push --no-verify\` skips this).`);
60
- const verdict = await judge(root, tree, true);
65
+ console.error(`Gating ${ref} (the ${tier} gate) before it's pushed (\`git push --no-verify\` skips this).`);
66
+ const verdict = await judge(root, tree, true, tier);
61
67
  if (!verdict.passed) {
62
68
  console.error(failure(verdict));
63
69
  process.exit(1);
@@ -105,10 +111,10 @@ async function finish(root) {
105
111
 
106
112
  const modes = { "pre-push": prePush, "claude-turn": claudeTurn, "claude-stop": claudeStop, finish };
107
113
 
108
- export async function hook(root, _config, [mode]) {
114
+ export async function hook(root, config, [mode]) {
109
115
  if (!(mode in modes)) {
110
116
  console.error(`Usage: marram hook ${Object.keys(modes).join(" | ")}`);
111
117
  process.exit(1);
112
118
  }
113
- await modes[mode](root);
119
+ await modes[mode](root, config);
114
120
  }
@@ -1,19 +1,25 @@
1
- import { useCallback, useRef, useState } from "react";
1
+ import { useCallback, useReducer } from "react";
2
+
3
+ type Value<T> = T | ((previous: T) => T);
2
4
 
3
5
  /**
4
6
  * State that belongs to a key (a watch's parameters, a file, a document): under another key it reads as `initial`, so
5
7
  * nothing shows the last key's value while the new key's loads, not even for the one render an effect resetting it
6
8
  * would leave. Set it with the key the value is for, or with an update of that key's value; a late answer for a key
7
- * that's gone reads as `initial` too.
9
+ * that's gone is dropped, so it can't take the place of the current key's value. A key says only what a value is for:
10
+ * two requests under one key can still answer out of order, so a caller that re-reads puts a version or try in the key.
8
11
  */
9
- export function useKeyed<T>(key: string, initial: T): [T, (forKey: string, value: T | ((previous: T) => T)) => void] {
10
- const [held, setHeld] = useState<{ key: string; value: T }>({ key, value: initial });
11
- const first = useRef(initial);
12
- const set = useCallback((forKey: string, value: T | ((previous: T) => T)) => {
13
- setHeld((last) => {
14
- const previous = last.key === forKey ? last.value : first.current;
15
- return { key: forKey, value: typeof value === "function" ? (value as (previous: T) => T)(previous) : value };
16
- });
17
- }, []);
12
+ export function useKeyed<T>(key: string, initial: T): [T, (forKey: string, value: Value<T>) => void] {
13
+ // Written inline because React runs a reducer in the render an update lands in: this one sees that render's key, which
14
+ // `set`, called from a promise or a listener, can't know.
15
+ const [held, apply] = useReducer(
16
+ (last: { key: string; value: T }, { forKey, value }: { forKey: string; value: Value<T> }) => {
17
+ if (forKey !== key) return last;
18
+ const previous = last.key === key ? last.value : initial;
19
+ return { key, value: typeof value === "function" ? (value as (previous: T) => T)(previous) : value };
20
+ },
21
+ { key, value: initial },
22
+ );
23
+ const set = useCallback((forKey: string, value: Value<T>) => apply({ forKey, value }), []);
18
24
  return [held.key === key ? held.value : initial, set];
19
25
  }