genshin-mods 4.3.3 → 4.5.0

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 (78) hide show
  1. package/.claude-plugin/plugin.json +10 -2
  2. package/README.md +47 -37
  3. package/package.json +2 -2
  4. package/src/models/CommandRefusal.ts +3 -0
  5. package/src/models/ModName.ts +9 -0
  6. package/src/models/SinFigures.ts +4 -0
  7. package/src/models/SinName.ts +12 -0
  8. package/src/models/SinPosterior.ts +4 -0
  9. package/src/models/SinScore.ts +7 -0
  10. package/src/models/SinStatusKind.ts +8 -0
  11. package/src/models/SinsResult.ts +6 -0
  12. package/src/models/TaskCall.ts +1 -7
  13. package/src/models/TaskStatus.ts +6 -0
  14. package/src/models/UsageWindow.ts +1 -3
  15. package/src/register.ts +7 -3
  16. package/src/services/InitialState.ts +7 -2
  17. package/src/services/ModDescriptionMap.ts +10 -6
  18. package/src/services/band/getCommissionSummary.ts +2 -1
  19. package/src/services/band/registerBand.ts +51 -4
  20. package/src/services/commission/foldTaskCall.ts +2 -1
  21. package/src/services/constants.ts +3 -8
  22. package/src/services/delegation/checkIsLookupCommand.ts +2 -8
  23. package/src/services/delegation/stripLeadingSegments.ts +8 -0
  24. package/src/services/diskScan/getDenyReason.ts +7 -0
  25. package/src/services/diskScan/getDiskScanRefusal.ts +2 -78
  26. package/src/services/diskScan/getScanCommandRefusal.ts +8 -0
  27. package/src/services/diskScan/getScanRefusal.ts +21 -0
  28. package/src/services/diskScan/isRecursiveFlag.ts +4 -0
  29. package/src/services/diskScan/isRecursiveLongFlag.ts +3 -0
  30. package/src/services/diskScan/isRoot.ts +8 -0
  31. package/src/services/diskScan/normalizePath.ts +7 -0
  32. package/src/services/diskScan/readGrepArguments.ts +25 -0
  33. package/src/services/diskScan/registerDiskScan.ts +2 -1
  34. package/src/services/readHome.ts +7 -0
  35. package/src/services/registerLifecycle.ts +93 -25
  36. package/src/services/resin/RateLimitKindUsageWindowMap.ts +2 -10
  37. package/src/services/resin/formatResetsAt.ts +1 -1
  38. package/src/services/resin/getReserveSummary.ts +4 -4
  39. package/src/services/resin/getReserveWindow.ts +8 -13
  40. package/src/services/resin/reserveText.ts +4 -4
  41. package/src/services/shell/getDollarSubstitutionBodies.ts +25 -0
  42. package/src/services/shell/getShellRefusal.ts +4 -38
  43. package/src/services/shell/tokenize.ts +8 -0
  44. package/src/services/sins/SinColorMap.ts +12 -0
  45. package/src/services/sins/SinStatusTextMap.ts +10 -0
  46. package/src/services/sins/appendSinEvidence.ts +18 -0
  47. package/src/services/sins/constants.ts +33 -0
  48. package/src/services/sins/getBarCells.ts +8 -0
  49. package/src/services/sins/getPosteriorTrail.ts +21 -0
  50. package/src/services/sins/getScoreAndSpread.ts +8 -0
  51. package/src/services/sins/getSinQuestions.ts +28 -0
  52. package/src/services/sins/getSinScores.ts +17 -0
  53. package/src/services/sins/getTrend.ts +13 -0
  54. package/src/services/sins/parseSinsResult.ts +5 -0
  55. package/src/services/sins/pickTypeSafeKey.ts +4 -0
  56. package/src/services/sins/readIsLightTheme.ts +8 -0
  57. package/src/services/sins/readSinStatus.ts +20 -0
  58. package/src/services/sins/readTypeSafeKey.ts +10 -0
  59. package/src/services/sins/registerSins.ts +56 -0
  60. package/src/services/sins/sinsScript.ts +47 -0
  61. package/src/services/ward/getAddRefusal.ts +14 -0
  62. package/src/services/ward/getCheckoutPeer.ts +1 -3
  63. package/src/services/ward/getCheckoutRefusal.ts +6 -0
  64. package/src/services/ward/getCleanRefusal.ts +8 -0
  65. package/src/services/ward/getComparablePath.ts +2 -0
  66. package/src/services/ward/getGitRefusal.ts +32 -0
  67. package/src/services/ward/getResetRefusal.ts +19 -0
  68. package/src/services/ward/getRestoreRefusal.ts +10 -0
  69. package/src/services/ward/getStashRefusal.ts +2 -0
  70. package/src/services/ward/getSubcommand.ts +16 -0
  71. package/src/services/ward/getWholeIndexDenyReason.ts +9 -0
  72. package/src/services/ward/getWholeIndexRefusal.ts +1 -121
  73. package/src/services/ward/isFlag.ts +1 -0
  74. package/src/services/ward/isWholeCheckout.ts +5 -0
  75. package/src/services/ward/registerWholeIndex.ts +2 -1
  76. package/src/services/ward/resolveRecordsPath.ts +2 -1
  77. package/src/services/ward/splitArguments.ts +14 -0
  78. package/types/index.d.ts +11 -5
@@ -0,0 +1,10 @@
1
+ import type { SinStatus } from "../../../types";
2
+
3
+ import { SinStatusKind } from "../../models/SinStatusKind";
4
+
5
+ // Why the band and the pane say so instead of fresh scores, for the statuses that have a reason to say
6
+ export const SinStatusTextMap: Partial<Record<SinStatus, string>> = {
7
+ [SinStatusKind.Failed]: "Last prompt not scored",
8
+ [SinStatusKind.NodeTooOld]: "Needs Node 22.13 or later on 22, or 23.4 or later on 23",
9
+ [SinStatusKind.NoKey]: "Needs a TypeSafe key",
10
+ };
@@ -0,0 +1,18 @@
1
+ import type { SinsResult } from "../../models/SinsResult";
2
+ import type { SinStatusKind } from "../../models/SinStatusKind";
3
+
4
+ import { SIN_NAMES } from "../../models/SinName";
5
+ import { SIN_HISTORY_LENGTH, SIN_TOP_LEVEL } from "./constants";
6
+
7
+ // Each sin's evidence gains this prompt's score as a share of the top level, clamped to [0, 1], and the oldest values fall
8
+ // Away past the history's length
9
+ export const appendSinEvidence = (
10
+ history: Record<string, number[]>,
11
+ answers: Extract<SinsResult, { result: SinStatusKind.Answered }>["answers"],
12
+ ): Record<string, number[]> =>
13
+ Object.fromEntries(
14
+ SIN_NAMES.map((name) => {
15
+ const evidence = Math.min(1, Math.max(0, (answers[name]?.score ?? 0) / SIN_TOP_LEVEL));
16
+ return [name, [...(history[name] ?? []), evidence].slice(-SIN_HISTORY_LENGTH)];
17
+ }),
18
+ );
@@ -0,0 +1,33 @@
1
+ // The five levels every question grades on, from none to extreme, so the sins are comparable with one another
2
+ export const SIN_CRITERIA = [
3
+ "Absent: the prompt shows none of it",
4
+ "Slight: a trace of it in passing",
5
+ "Moderate: it shapes part of the prompt",
6
+ "Strong: it shapes most of the prompt",
7
+ "Extreme: it is the whole prompt",
8
+ ] as const;
9
+
10
+ // The top level the score reads, so a score divides by it to lie between none and extreme
11
+ export const SIN_TOP_LEVEL: number = SIN_CRITERIA.length - 1;
12
+
13
+ // The half-life in prompts: a sin's score moves about halfway toward a new pattern within this many prompts
14
+ export const SIN_HALF_LIFE_PROMPTS = 30;
15
+
16
+ // Applied to the old counts on each prompt, so each count halves every half-life
17
+ export const SIN_DISCOUNT: number = 2 ** (-1 / SIN_HALF_LIFE_PROMPTS);
18
+
19
+ // One pseudo-count of each outcome, so a sin with no evidence reads as an even half
20
+ export const SIN_PRIOR = 1;
21
+
22
+ // The evidence kept per sin: past this the discount has taken the rest, under a tenth of a percent
23
+ export const SIN_HISTORY_LENGTH = 300;
24
+
25
+ // The bar's width in cells, the solid run to the score and the tinted run to the score plus its spread
26
+ export const SIN_BAR_WIDTH = 24;
27
+
28
+ export const SINS_PANE_ID = "sins";
29
+
30
+ // The node script's whole run, its one request included; a run past this is killed and reads as failed
31
+ export const SINS_TIMEOUT_MS = 60_000;
32
+
33
+ export const SINS_API_URL = "https://api.typesafe.ai/v1/systemone";
@@ -0,0 +1,8 @@
1
+ import type { SinFigures } from "../../models/SinFigures";
2
+
3
+ // The solid run reaches the score, and the tinted run the score plus its spread, both in cells of the bar's width
4
+ export const getBarCells = ({ score, spread }: SinFigures, width: number): { solid: number; tinted: number } => {
5
+ const solid = Math.round(score * width);
6
+ const reach = Math.round(Math.min(1, score + spread) * width);
7
+ return { solid, tinted: Math.max(0, reach - solid) };
8
+ };
@@ -0,0 +1,21 @@
1
+ import type { SinPosterior } from "../../models/SinPosterior";
2
+
3
+ import { SIN_DISCOUNT, SIN_PRIOR } from "./constants";
4
+
5
+ // Each evidence value is replayed in order, the counts discounted before it is added, so the trail is the posterior
6
+ // After each prompt and its last entry is the posterior now
7
+ export const getPosteriorTrail = (evidences: number[]): SinPosterior[] => {
8
+ let posterior: SinPosterior = { alpha: SIN_PRIOR, beta: SIN_PRIOR };
9
+ return evidences.map((evidence) => {
10
+ posterior = {
11
+ alpha: SIN_DISCOUNT * posterior.alpha + evidence,
12
+ beta: SIN_DISCOUNT * posterior.beta + (1 - evidence),
13
+ };
14
+ return posterior;
15
+ });
16
+ };
17
+
18
+ // The posterior now is the trail's last entry, and a sin with no evidence reads as the prior: an even half with the spread
19
+ // Of one count of each outcome
20
+ export const getPosterior = (trail: SinPosterior[]): SinPosterior =>
21
+ trail.at(-1) ?? { alpha: SIN_PRIOR, beta: SIN_PRIOR };
@@ -0,0 +1,8 @@
1
+ import type { SinFigures } from "../../models/SinFigures";
2
+ import type { SinPosterior } from "../../models/SinPosterior";
3
+
4
+ // The score is the posterior mean and the spread its standard deviation, from the Beta's own variance
5
+ export const getScoreAndSpread = ({ alpha, beta }: SinPosterior): SinFigures => {
6
+ const total = alpha + beta;
7
+ return { score: alpha / total, spread: Math.sqrt((alpha * beta) / (total * total * (total + 1))) };
8
+ };
@@ -0,0 +1,28 @@
1
+ import { SIN_NAMES, SinName } from "../../models/SinName";
2
+ import { SIN_CRITERIA } from "./constants";
3
+
4
+ // The question each sin asks Jev about a prompt, held beside the one call that asks them all
5
+ const SinQuestionMap = {
6
+ [SinName.Envy]:
7
+ 'How far does the prompt measure against another tool, person or project, as in "the other tool", "like theirs" or "why doesn\'t ours do what X does"?',
8
+ [SinName.Gluttony]: "How heavy is the prompt, as in a large pasted share of text or a whole file or log pasted in?",
9
+ [SinName.Greed]:
10
+ 'How far does the prompt ask for more than one request holds, as in "and also", "while you\'re at it", "everything" or several tasks at once?',
11
+ [SinName.Lust]:
12
+ 'How far does the prompt crave the new over the finished, as in "rewrite", "from scratch", "redesign" or "a new approach"?',
13
+ [SinName.Pride]:
14
+ 'How far does the prompt assert certainty and wave off checks, as in "obviously", "definitely", "no need to test" or "trust me"?',
15
+ [SinName.Sloth]:
16
+ 'How far is effort handed off without a steer, as in "idk", "whatever", "you decide", "figure it out" or a one-word prompt?',
17
+ [SinName.Wrath]:
18
+ 'How heated is the prompt\'s register, as in runs of punctuation, words in capitals, "again", "still broken" or "why won\'t"?',
19
+ } as const satisfies Record<SinName, string>;
20
+
21
+ // The seven `score` questions one call answers, keyed by sin name, each graded on the same five levels
22
+ export const getSinQuestions = (): Record<
23
+ string,
24
+ { criteria: typeof SIN_CRITERIA; instructions: string; type: "score" }
25
+ > =>
26
+ Object.fromEntries(
27
+ SIN_NAMES.map((name) => [name, { criteria: SIN_CRITERIA, instructions: SinQuestionMap[name], type: "score" }]),
28
+ );
@@ -0,0 +1,17 @@
1
+ import type { SinScore } from "../../models/SinScore";
2
+
3
+ import { SIN_NAMES } from "../../models/SinName";
4
+ import { getPosterior, getPosteriorTrail } from "./getPosteriorTrail";
5
+ import { getScoreAndSpread } from "./getScoreAndSpread";
6
+ import { getTrend } from "./getTrend";
7
+
8
+ // The sins the person can read, in the pane's order: one with no evidence is left out rather than shown as a zero
9
+ export const getSinScores = (history: Record<string, number[]>): SinScore[] =>
10
+ SIN_NAMES.flatMap((name) => {
11
+ const evidences = history[name] ?? [];
12
+ if (evidences.length === 0) return [];
13
+
14
+ const trail = getPosteriorTrail(evidences);
15
+ const figures = getScoreAndSpread(getPosterior(trail));
16
+ return [{ name, ...figures, trend: getTrend(trail) }];
17
+ });
@@ -0,0 +1,13 @@
1
+ import type { SinPosterior } from "../../models/SinPosterior";
2
+
3
+ import { SIN_HALF_LIFE_PROMPTS } from "./constants";
4
+
5
+ const TREND_GLYPHS = "▁▂▃▄▅▆▇█";
6
+
7
+ // The score after each of the last half-life's prompts, from the trail the score reads, so it is the score's own curve
8
+ export const getTrend = (trail: SinPosterior[]): number[] =>
9
+ trail.slice(-SIN_HALF_LIFE_PROMPTS).map(({ alpha, beta }) => alpha / (alpha + beta));
10
+
11
+ // One glyph per point, its height the point's share of the tallest glyph
12
+ export const getTrendGlyphs = (trend: number[]): string =>
13
+ trend.map((score) => TREND_GLYPHS.charAt(Math.round(score * (TREND_GLYPHS.length - 1)))).join("");
@@ -0,0 +1,5 @@
1
+ import type { SinsResult } from "../../models/SinsResult";
2
+
3
+ // The node script writes one JSON result when it exits cleanly, so a run that exited otherwise never reaches this
4
+ // oxlint-disable-next-line no-restricted-properties -- packages/genshin-mods/package.json has no @esposter/shared dependency, so jsonDateParse is not importable here, and SinsResult (src/models/SinsResult.ts) holds no Date a revival would produce
5
+ export const parseSinsResult = (stdout: string): SinsResult => JSON.parse(stdout) as SinsResult;
@@ -0,0 +1,4 @@
1
+ // The key sins scores with: the plugin's own option when it is set, else the variable. A blank option is no key, so the
2
+ // Variable answers, and `||` is what makes it so, since `??` would keep a blank option over a variable that holds one
3
+ export const pickTypeSafeKey = (typeSafeKey: string, variableKey: string | undefined): string =>
4
+ typeSafeKey || variableKey || "";
@@ -0,0 +1,8 @@
1
+ import type { EngineInterface } from "claude-code";
2
+
3
+ // The engine's theme row names a light theme with "light" in it, and any other theme reads as dark
4
+ // oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` only into a function declaration
5
+ export async function readIsLightTheme($: EngineInterface): Promise<boolean> {
6
+ const theme = (await $.config.list()).find(({ key }) => key === "theme");
7
+ return String(theme?.value ?? "").includes("light");
8
+ }
@@ -0,0 +1,20 @@
1
+ import type { EngineInterface } from "claude-code";
2
+
3
+ import { atom, read } from "claude-code";
4
+
5
+ import type { SinStatus } from "../../../types";
6
+
7
+ import { SinStatusKind } from "../../models/SinStatusKind";
8
+ import { InitialState } from "../InitialState";
9
+ import { readTypeSafeKey } from "./readTypeSafeKey";
10
+
11
+ const sinStatusAtom = atom({ key: "sinStatus", plugin: "genshin-mods" } as const, InitialState.sinStatus);
12
+
13
+ // The status as the band and the pane draw it: a key removed since the last prompt is said so at each draw, before the next
14
+ // Prompt would say it, and the stored status stands otherwise
15
+ // oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` into a function declaration
16
+ export async function readSinStatus($: EngineInterface, typeSafeKey: string): Promise<SinStatus> {
17
+ if (!(await readTypeSafeKey($, typeSafeKey))) return SinStatusKind.NoKey;
18
+
19
+ return read($, sinStatusAtom);
20
+ }
@@ -0,0 +1,10 @@
1
+ import type { EngineInterface } from "claude-code";
2
+
3
+ import { pickTypeSafeKey } from "./pickTypeSafeKey";
4
+
5
+ // The key sins scores with: the plugin's option, else the TYPESAFE_API_KEY variable, the two places Genshin-persona reads
6
+ // Its key from, so one key set the same way serves both plugins
7
+ // oxlint-disable-next-line func-style -- A declaration, not an arrow: the hooks module's bundle follows an imported helper that takes `$` into a function declaration
8
+ export async function readTypeSafeKey($: EngineInterface, typeSafeKey: string): Promise<string> {
9
+ return pickTypeSafeKey(typeSafeKey, await $.env.get("TYPESAFE_API_KEY"));
10
+ }
@@ -0,0 +1,56 @@
1
+ import type { On } from "claude-code";
2
+
3
+ import { atom, read } from "claude-code";
4
+
5
+ import { InitialState } from "../InitialState";
6
+ import { SIN_BAR_WIDTH, SINS_PANE_ID } from "./constants";
7
+ import { getBarCells } from "./getBarCells";
8
+ import { getSinScores } from "./getSinScores";
9
+ import { getTrendGlyphs } from "./getTrend";
10
+ import { readIsLightTheme } from "./readIsLightTheme";
11
+ import { readSinStatus } from "./readSinStatus";
12
+ import { SinColorMap } from "./SinColorMap";
13
+ import { SinStatusTextMap } from "./SinStatusTextMap";
14
+
15
+ const enabledModsAtom = atom({ key: "enabledMods", plugin: "genshin-mods" } as const, InitialState.enabledMods);
16
+ const sinHistoryAtom = atom({ key: "sinHistory", plugin: "genshin-mods" } as const, InitialState.sinHistory);
17
+
18
+ // The seven sins in full: one bar per measured sin in the same order every time, its spread as the tinted tail, and its
19
+ // Last thirty prompts as a trend. It is drawn from the state alone, so a reload redraws it as it was, and it says so
20
+ // Rather than drawing stale bars once the switch is off
21
+ export const registerSins = (on: On, typeSafeKey: string): void => {
22
+ on("ui.render", { component: "Pane", requestId: SINS_PANE_ID }, async ($, e) => {
23
+ const { Box, Text } = $.ui.resolve(e);
24
+ if (!(await read($, enabledModsAtom)).sins)
25
+ return Box({
26
+ children: [Text({ children: "Sins is off: turn it on with /sins on.", dimColor: true })],
27
+ flexDirection: "column",
28
+ });
29
+
30
+ const scores = getSinScores(await read($, sinHistoryAtom));
31
+ const isLight = await readIsLightTheme($);
32
+ const statusText = SinStatusTextMap[await readSinStatus($, typeSafeKey)];
33
+ return Box({
34
+ children: [
35
+ Text({ bold: true, children: "Seven sins" }),
36
+ ...(scores.length === 0 ? [Text({ children: "No prompt scored yet.", dimColor: true })] : []),
37
+ ...scores.map(({ name, score, spread, trend }) => {
38
+ const color = SinColorMap[name][isLight ? "light" : "dark"];
39
+ const { solid, tinted } = getBarCells({ score, spread }, SIN_BAR_WIDTH);
40
+ const bar = "█".repeat(solid) + "▒".repeat(tinted) + " ".repeat(SIN_BAR_WIDTH - solid - tinted);
41
+ return Box({
42
+ children: [
43
+ Text({ children: name.padEnd(9), color }),
44
+ Text({ children: bar, color }),
45
+ Text({ children: `${Math.round(score * 100)}% ±${Math.round(spread * 100)}`.padEnd(12) }),
46
+ Text({ children: getTrendGlyphs(trend), color }),
47
+ ],
48
+ flexDirection: "row",
49
+ });
50
+ }),
51
+ ...(statusText ? [Text({ children: statusText, dimColor: true })] : []),
52
+ ],
53
+ flexDirection: "column",
54
+ });
55
+ });
56
+ };
@@ -0,0 +1,47 @@
1
+ import { SinStatusKind } from "../../models/SinStatusKind";
2
+
3
+ // The plugin's node script, run as `node --input-type=module -e` with its request on stdin: the one network call and the
4
+ // Evidence log run here, since the hooks module has no Node. It is a string because a plugin reaches none of its own
5
+ // Files from inside the module, and it writes exactly one JSON result, so a run that exits otherwise reads as failed
6
+ // oxlint-disable-next-line typescript/no-inferrable-types -- isolatedDeclarations asks for the type of an exported template that interpolates, and this rule would refuse that annotation
7
+ export const SINS_SCRIPT: string = `
8
+ import { mkdirSync } from "node:fs";
9
+ import { dirname } from "node:path";
10
+
11
+ let input = "";
12
+ for await (const chunk of process.stdin) input += chunk;
13
+ const { apiUrl, key, questions, session, state, storePath } = JSON.parse(input);
14
+ const finish = (result) => process.stdout.write(JSON.stringify(result));
15
+ const sqlite = process.getBuiltinModule?.("node:sqlite");
16
+
17
+ if (!key) finish({ result: "${SinStatusKind.NoKey}" });
18
+ else if (!sqlite) finish({ result: "${SinStatusKind.NodeTooOld}" });
19
+ else {
20
+ const response = await fetch(apiUrl, {
21
+ body: JSON.stringify({ model: "jev-latest", questions, state }),
22
+ headers: { Authorization: "Bearer " + key, "Content-Type": "application/json" },
23
+ method: "POST",
24
+ });
25
+ const answers = response.ok ? (await response.json()).answers || {} : {};
26
+ const names = Object.keys(questions);
27
+ const isAnswered = names.every(
28
+ (name) => typeof answers[name]?.score === "number" && typeof answers[name]?.confidence === "number",
29
+ );
30
+ if (!isAnswered) finish({ reason: response.ok ? "unanswered" : "HTTP " + response.status, result: "${SinStatusKind.Failed}" });
31
+ else {
32
+ mkdirSync(dirname(storePath), { recursive: true });
33
+ const database = new sqlite.DatabaseSync(storePath);
34
+ try {
35
+ database.exec("PRAGMA busy_timeout = 5000");
36
+ database.exec(
37
+ "CREATE TABLE IF NOT EXISTS evidence (id INTEGER PRIMARY KEY, session INTEGER NOT NULL, answers TEXT NOT NULL, recorded_at INTEGER NOT NULL)",
38
+ );
39
+ const picked = Object.fromEntries(names.map((name) => [name, { confidence: answers[name].confidence, score: answers[name].score }]));
40
+ database.prepare("INSERT INTO evidence (session, answers, recorded_at) VALUES (?, ?, ?)").run(session, JSON.stringify(picked), Date.now());
41
+ finish({ answers: picked, result: "${SinStatusKind.Answered}" });
42
+ } finally {
43
+ database.close();
44
+ }
45
+ }
46
+ }
47
+ `;
@@ -0,0 +1,14 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ const ADD_ALL_FLAGS: ReadonlySet<string> = new Set(["--all", "--update", "-A", "-u"]);
5
+
6
+ // `add .`, `add -A` and `add -N` over every path reach a peer's work, while a named path does not
7
+ export const getAddRefusal = (args: string[]): string | undefined => {
8
+ const { flags, operands } = splitArguments(args);
9
+ if (isWholeCheckout(operands)) return "git add .";
10
+ const allFlag = flags.find((flag) => ADD_ALL_FLAGS.has(flag));
11
+ if (allFlag !== undefined && operands.length === 0) return `git add ${allFlag}`;
12
+ const isIntentToAdd = flags.some((flag) => flag === "-N" || flag === "--intent-to-add");
13
+ return isIntentToAdd && operands.length === 0 ? "git add -N" : undefined;
14
+ };
@@ -1,9 +1,7 @@
1
1
  import type { WardRecord } from "../../../types";
2
2
 
3
3
  import { getCollidingRecord } from "./getCollidingRecord";
4
-
5
- // Windows paths compare as one spelling: forward slashes, lowercase, since the file system ignores case
6
- const getComparablePath = (path: string) => path.replaceAll("\\", "/").toLowerCase();
4
+ import { getComparablePath } from "./getComparablePath";
7
5
 
8
6
  // The edited path of another session's record inside this checkout, undefined when no other session in the window
9
7
  // Shares it, so a checkout alone with its session is never refused
@@ -0,0 +1,6 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ // `checkout .` and `checkout -- .` overwrite every path's working tree copy, a peer's edits included
5
+ export const getCheckoutRefusal = (args: string[]): string | undefined =>
6
+ isWholeCheckout(splitArguments(args).operands) ? "git checkout ." : undefined;
@@ -0,0 +1,8 @@
1
+ import { splitArguments } from "./splitArguments";
2
+
3
+ // A clean deletes every untracked file, a peer's new ones included; a dry run lists them and is allowed
4
+ export const getCleanRefusal = (args: string[]): string | undefined => {
5
+ const { flags } = splitArguments(args);
6
+ const isDryRun = flags.some((flag) => flag === "--dry-run" || (!flag.startsWith("--") && flag.includes("n")));
7
+ return isDryRun ? undefined : "git clean";
8
+ };
@@ -0,0 +1,2 @@
1
+ // Windows paths compare as one spelling: forward slashes, lowercase, since the file system ignores case
2
+ export const getComparablePath = (path: string) => path.replaceAll("\\", "/").toLowerCase();
@@ -0,0 +1,32 @@
1
+ import type { CommandRefusal } from "../../models/CommandRefusal";
2
+
3
+ import { joinDirectory } from "../shell/joinDirectory";
4
+ import { getAddRefusal } from "./getAddRefusal";
5
+ import { getCheckoutRefusal } from "./getCheckoutRefusal";
6
+ import { getCleanRefusal } from "./getCleanRefusal";
7
+ import { getResetRefusal } from "./getResetRefusal";
8
+ import { getRestoreRefusal } from "./getRestoreRefusal";
9
+ import { getStashRefusal } from "./getStashRefusal";
10
+ import { getSubcommand } from "./getSubcommand";
11
+
12
+ const GIT_NAMES: ReadonlySet<string> = new Set(["git", "git.exe"]);
13
+
14
+ const WHOLE_INDEX_REFUSALS: ReadonlyMap<string, (args: string[]) => string | undefined> = new Map([
15
+ ["add", getAddRefusal],
16
+ ["checkout", getCheckoutRefusal],
17
+ ["clean", getCleanRefusal],
18
+ ["reset", getResetRefusal],
19
+ ["restore", getRestoreRefusal],
20
+ ["stash", getStashRefusal],
21
+ ]);
22
+
23
+ // The form refused and the folder git runs in, from the session's ("" for the session's own): the folder a `cd` before
24
+ // It moved to, then each of its `-C` folders
25
+ export const getGitRefusal: CommandRefusal<{ directory: string; form: string }> = (name, args, directory) => {
26
+ if (!GIT_NAMES.has(name)) return undefined;
27
+ const subcommand = getSubcommand(args);
28
+ const form = WHOLE_INDEX_REFUSALS.get(subcommand.name)?.(subcommand.arguments);
29
+ return form === undefined
30
+ ? undefined
31
+ : { directory: subcommand.directories.reduce((base, next) => joinDirectory(base, next), directory), form };
32
+ };
@@ -0,0 +1,19 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ // The reset modes that touch the working tree or the index beyond HEAD, so a reset with one of them is not a commit move
5
+ const RESET_INDEX_MODES: ReadonlySet<string> = new Set(["--hard", "--keep", "--merge", "--mixed"]);
6
+
7
+ // A reset that names a path is path-scoped: after `--`, or as a second operand with none, since git reads the first
8
+ // As a commit or a path and every one after it as a path. The checkout's own pathspec names no commit, so it is the
9
+ // Whole checkout in either place. A lone operand may be a commit, so it is refused, and a reset that moves HEAD alone
10
+ // With `--soft` leaves the index as it is; every other reset rewrites the index to HEAD or the working tree, a peer's
11
+ // Staged entries included
12
+ export const getResetRefusal = (args: string[]): string | undefined => {
13
+ const { after, flags, hasSeparator, operands } = splitArguments(args);
14
+ if (isWholeCheckout(hasSeparator ? after : operands)) return "git reset -- .";
15
+ const paths = hasSeparator ? after : operands.slice(1);
16
+ if (paths.length > 0) return undefined;
17
+ const isSoftOnly = flags.includes("--soft") && !flags.some((flag) => RESET_INDEX_MODES.has(flag));
18
+ return isSoftOnly ? undefined : "git reset";
19
+ };
@@ -0,0 +1,10 @@
1
+ import { isWholeCheckout } from "./isWholeCheckout";
2
+ import { splitArguments } from "./splitArguments";
3
+
4
+ // `restore .` rewrites every path, staged or in the working tree, so the staged form is refused as the plain one is
5
+ export const getRestoreRefusal = (args: string[]): string | undefined => {
6
+ const { flags, operands } = splitArguments(args);
7
+ if (!isWholeCheckout(operands)) return undefined;
8
+ const isStaged = flags.some((flag) => flag === "--staged" || flag === "-S");
9
+ return `git restore ${isStaged ? "--staged " : ""}.`;
10
+ };
@@ -0,0 +1,2 @@
1
+ // A stash in any form sets a peer's work aside, which is why the git skill never runs one
2
+ export const getStashRefusal = (): string => "git stash";
@@ -0,0 +1,16 @@
1
+ import { isFlag } from "./isFlag";
2
+
3
+ // The options git takes before its subcommand, each with its value as the next word
4
+ const GIT_VALUE_OPTIONS: ReadonlySet<string> = new Set(["--git-dir", "--namespace", "--work-tree", "-C", "-c"]);
5
+
6
+ // The subcommand past the options git takes before it, the arguments after the subcommand, and the folders its `-C`
7
+ // Options name, each from the one before
8
+ export const getSubcommand = (args: string[]) => {
9
+ const directories: string[] = [];
10
+ let index = 0;
11
+ while (index < args.length && isFlag(args[index] ?? "")) {
12
+ if (args[index] === "-C") directories.push(args[index + 1] ?? "");
13
+ index += GIT_VALUE_OPTIONS.has(args[index] ?? "") ? 2 : 1;
14
+ }
15
+ return { arguments: args.slice(index + 1), directories, name: args[index] ?? "" };
16
+ };
@@ -0,0 +1,9 @@
1
+ // The path-scoped forms that do the same job for the paths the session touched, named for the person's shared checkout
2
+ export const getWholeIndexDenyReason = (form: string, peerPath: string): string =>
3
+ [
4
+ `Refused: \`${form}\` reaches the staged and working changes of another session in this checkout, which is editing ${peerPath} now. Name the paths you touched instead:`,
5
+ "- `git add <path>` to stage them;",
6
+ "- `git reset -- <path>` to unstage them;",
7
+ '- `git commit -m "…" -- <path>` to commit only them.',
8
+ "The git skill's rule (`.agents/skills/git/SKILL.md`) owns the whole-index list.",
9
+ ].join("\n");
@@ -1,127 +1,7 @@
1
- import type { CommandRefusal } from "../shell/getShellRefusal";
2
-
3
1
  import { getShellRefusal } from "../shell/getShellRefusal";
4
- import { joinDirectory } from "../shell/joinDirectory";
5
-
6
- const GIT_NAMES: ReadonlySet<string> = new Set(["git", "git.exe"]);
7
-
8
- // The options git takes before its subcommand, each with its value as the next word
9
- const GIT_VALUE_OPTIONS: ReadonlySet<string> = new Set(["-C", "-c"]);
10
-
11
- // The reset modes that touch the working tree or the index beyond HEAD, so a reset with one of them is not a commit move
12
- const RESET_INDEX_MODES: ReadonlySet<string> = new Set(["--hard", "--keep", "--merge", "--mixed"]);
13
-
14
- const ADD_ALL_FLAGS: ReadonlySet<string> = new Set(["--all", "--update", "-A", "-u"]);
15
-
16
- // The pathspecs git reads as every path of the checkout, from its root or the folder the command runs in
17
- const WHOLE_CHECKOUT_PATHSPECS: ReadonlySet<string> = new Set([".", "./", ":/", ":/."]);
18
-
19
- const isFlag = (arg: string) => arg.startsWith("-");
20
-
21
- const isWholeCheckout = (operands: string[]) => operands.some((operand) => WHOLE_CHECKOUT_PATHSPECS.has(operand));
22
-
23
- // The flags, the words before a `--` and the words after it, so a path is read whether or not git was given a separator
24
- const splitArguments = (args: string[]) => {
25
- const separator = args.indexOf("--");
26
- const before = separator === -1 ? args : args.slice(0, separator);
27
- const after = separator === -1 ? [] : args.slice(separator + 1);
28
- return {
29
- after,
30
- flags: before.filter((arg) => isFlag(arg)),
31
- hasSeparator: separator !== -1,
32
- operands: [...before.filter((arg) => !isFlag(arg)), ...after],
33
- };
34
- };
35
-
36
- // The subcommand past the options git takes before it, the arguments after the subcommand, and the folders its `-C`
37
- // Options name, each from the one before
38
- const getSubcommand = (args: string[]) => {
39
- const directories: string[] = [];
40
- let index = 0;
41
- while (index < args.length && isFlag(args[index] ?? "")) {
42
- if (args[index] === "-C") directories.push(args[index + 1] ?? "");
43
- index += GIT_VALUE_OPTIONS.has(args[index] ?? "") ? 2 : 1;
44
- }
45
- return { arguments: args.slice(index + 1), directories, name: args[index] ?? "" };
46
- };
47
-
48
- // `add .`, `add -A` and `add -N` over every path reach a peer's work, while a named path does not
49
- const getAddRefusal = (args: string[]): string | undefined => {
50
- const { flags, operands } = splitArguments(args);
51
- if (isWholeCheckout(operands)) return "git add .";
52
- const allFlag = flags.find((flag) => ADD_ALL_FLAGS.has(flag));
53
- if (allFlag !== undefined && operands.length === 0) return `git add ${allFlag}`;
54
- const isIntentToAdd = flags.some((flag) => flag === "-N" || flag === "--intent-to-add");
55
- return isIntentToAdd && operands.length === 0 ? "git add -N" : undefined;
56
- };
57
-
58
- // A reset that names a path is path-scoped: after `--`, or as a second operand with none, since git reads the first
59
- // As a commit or a path and every one after it as a path. The checkout's own pathspec names no commit, so it is the
60
- // Whole checkout in either place. A lone operand may be a commit, so it is refused, and a reset that moves HEAD alone
61
- // With `--soft` leaves the index as it is; every other reset rewrites the index to HEAD or the working tree, a peer's
62
- // Staged entries included
63
- const getResetRefusal = (args: string[]): string | undefined => {
64
- const { after, flags, hasSeparator, operands } = splitArguments(args);
65
- if (isWholeCheckout(hasSeparator ? after : operands)) return "git reset -- .";
66
- const paths = hasSeparator ? after : operands.slice(1);
67
- if (paths.length > 0) return undefined;
68
- const isSoftOnly = flags.includes("--soft") && !flags.some((flag) => RESET_INDEX_MODES.has(flag));
69
- return isSoftOnly ? undefined : "git reset";
70
- };
71
-
72
- // `restore .` rewrites every path, staged or in the working tree, so the staged form is refused as the plain one is
73
- const getRestoreRefusal = (args: string[]): string | undefined => {
74
- const { flags, operands } = splitArguments(args);
75
- if (!isWholeCheckout(operands)) return undefined;
76
- const isStaged = flags.some((flag) => flag === "--staged" || flag === "-S");
77
- return `git restore ${isStaged ? "--staged " : ""}.`;
78
- };
79
-
80
- // A stash in any form sets a peer's work aside, which is why the git skill never runs one
81
- const getStashRefusal = (): string => "git stash";
82
-
83
- // `checkout .` and `checkout -- .` overwrite every path's working tree copy, a peer's edits included
84
- const getCheckoutRefusal = (args: string[]): string | undefined =>
85
- isWholeCheckout(splitArguments(args).operands) ? "git checkout ." : undefined;
86
-
87
- // A clean deletes every untracked file, a peer's new ones included; a dry run lists them and is allowed
88
- const getCleanRefusal = (args: string[]): string | undefined => {
89
- const { flags } = splitArguments(args);
90
- const isDryRun = flags.some((flag) => flag === "--dry-run" || (!flag.startsWith("--") && flag.includes("n")));
91
- return isDryRun ? undefined : "git clean";
92
- };
93
-
94
- const WHOLE_INDEX_REFUSALS: ReadonlyMap<string, (args: string[]) => string | undefined> = new Map([
95
- ["add", getAddRefusal],
96
- ["checkout", getCheckoutRefusal],
97
- ["clean", getCleanRefusal],
98
- ["reset", getResetRefusal],
99
- ["restore", getRestoreRefusal],
100
- ["stash", getStashRefusal],
101
- ]);
102
-
103
- // The form refused and the folder git runs in, from the session's ("" for the session's own): the folder a `cd` before
104
- // It moved to, then each of its `-C` folders
105
- const getGitRefusal: CommandRefusal<{ directory: string; form: string }> = (name, args, directory) => {
106
- if (!GIT_NAMES.has(name)) return undefined;
107
- const subcommand = getSubcommand(args);
108
- const form = WHOLE_INDEX_REFUSALS.get(subcommand.name)?.(subcommand.arguments);
109
- return form === undefined
110
- ? undefined
111
- : { directory: subcommand.directories.reduce((base, next) => joinDirectory(base, next), directory), form };
112
- };
2
+ import { getGitRefusal } from "./getGitRefusal";
113
3
 
114
4
  // The whole-index git form a Bash command runs, named as the form it is, with the folder it runs in, undefined for a
115
5
  // Path-scoped or read-only command
116
6
  export const getWholeIndexRefusal = (command: string): undefined | { directory: string; form: string } =>
117
7
  getShellRefusal(command, getGitRefusal);
118
-
119
- // The path-scoped forms that do the same job for the paths the session touched, named for the person's shared checkout
120
- export const getWholeIndexDenyReason = (form: string, peerPath: string): string =>
121
- [
122
- `Refused: \`${form}\` reaches the staged and working changes of another session in this checkout, which is editing ${peerPath} now. Name the paths you touched instead:`,
123
- "- `git add <path>` to stage them;",
124
- "- `git reset -- <path>` to unstage them;",
125
- '- `git commit -m "…" -- <path>` to commit only them.',
126
- "The git skill's rule (`.agents/skills/git/SKILL.md`) owns the whole-index list.",
127
- ].join("\n");
@@ -0,0 +1 @@
1
+ export const isFlag = (arg: string) => arg.startsWith("-");
@@ -0,0 +1,5 @@
1
+ // The pathspecs git reads as every path of the checkout, from its root or the folder the command runs in
2
+ const WHOLE_CHECKOUT_PATHSPECS: ReadonlySet<string> = new Set([".", "./", ":/", ":/."]);
3
+
4
+ export const isWholeCheckout = (operands: string[]) =>
5
+ operands.some((operand) => WHOLE_CHECKOUT_PATHSPECS.has(operand));
@@ -6,7 +6,8 @@ import { InitialState } from "../InitialState";
6
6
  import { joinDirectory } from "../shell/joinDirectory";
7
7
  import { getCheckoutPeer } from "./getCheckoutPeer";
8
8
  import { getParentFolder } from "./getParentFolder";
9
- import { getWholeIndexDenyReason, getWholeIndexRefusal } from "./getWholeIndexRefusal";
9
+ import { getWholeIndexDenyReason } from "./getWholeIndexDenyReason";
10
+ import { getWholeIndexRefusal } from "./getWholeIndexRefusal";
10
11
  import { readRecords } from "./readRecords";
11
12
  import { resolveRecordsPath } from "./resolveRecordsPath";
12
13