@dungle-scrubs/harness-cli-normalizer 0.1.1

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 (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +103 -0
  3. package/dist/execution/channel.d.ts +33 -0
  4. package/dist/execution/channel.d.ts.map +1 -0
  5. package/dist/execution/channel.js +86 -0
  6. package/dist/execution/channel.js.map +1 -0
  7. package/dist/execution/decode.d.ts +16 -0
  8. package/dist/execution/decode.d.ts.map +1 -0
  9. package/dist/execution/decode.js +60 -0
  10. package/dist/execution/decode.js.map +1 -0
  11. package/dist/execution/deps.d.ts +53 -0
  12. package/dist/execution/deps.d.ts.map +1 -0
  13. package/dist/execution/deps.js +8 -0
  14. package/dist/execution/deps.js.map +1 -0
  15. package/dist/execution/events.d.ts +54 -0
  16. package/dist/execution/events.d.ts.map +1 -0
  17. package/dist/execution/events.js +2 -0
  18. package/dist/execution/events.js.map +1 -0
  19. package/dist/execution/index.d.ts +19 -0
  20. package/dist/execution/index.d.ts.map +1 -0
  21. package/dist/execution/index.js +19 -0
  22. package/dist/execution/index.js.map +1 -0
  23. package/dist/execution/lines.d.ts +21 -0
  24. package/dist/execution/lines.d.ts.map +1 -0
  25. package/dist/execution/lines.js +70 -0
  26. package/dist/execution/lines.js.map +1 -0
  27. package/dist/execution/node-deps.d.ts +13 -0
  28. package/dist/execution/node-deps.d.ts.map +1 -0
  29. package/dist/execution/node-deps.js +164 -0
  30. package/dist/execution/node-deps.js.map +1 -0
  31. package/dist/execution/open-session.d.ts +31 -0
  32. package/dist/execution/open-session.d.ts.map +1 -0
  33. package/dist/execution/open-session.js +360 -0
  34. package/dist/execution/open-session.js.map +1 -0
  35. package/dist/execution/stream-turn.d.ts +43 -0
  36. package/dist/execution/stream-turn.d.ts.map +1 -0
  37. package/dist/execution/stream-turn.js +283 -0
  38. package/dist/execution/stream-turn.js.map +1 -0
  39. package/dist/index.d.ts +8 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +8 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/interpretation/argv.d.ts +35 -0
  44. package/dist/interpretation/argv.d.ts.map +1 -0
  45. package/dist/interpretation/argv.js +147 -0
  46. package/dist/interpretation/argv.js.map +1 -0
  47. package/dist/interpretation/capabilities.d.ts +19 -0
  48. package/dist/interpretation/capabilities.d.ts.map +1 -0
  49. package/dist/interpretation/capabilities.js +25 -0
  50. package/dist/interpretation/capabilities.js.map +1 -0
  51. package/dist/interpretation/content.d.ts +33 -0
  52. package/dist/interpretation/content.d.ts.map +1 -0
  53. package/dist/interpretation/content.js +169 -0
  54. package/dist/interpretation/content.js.map +1 -0
  55. package/dist/interpretation/context.d.ts +17 -0
  56. package/dist/interpretation/context.d.ts.map +1 -0
  57. package/dist/interpretation/context.js +16 -0
  58. package/dist/interpretation/context.js.map +1 -0
  59. package/dist/interpretation/dimensions.d.ts +13 -0
  60. package/dist/interpretation/dimensions.d.ts.map +1 -0
  61. package/dist/interpretation/dimensions.js +6 -0
  62. package/dist/interpretation/dimensions.js.map +1 -0
  63. package/dist/interpretation/identity.d.ts +32 -0
  64. package/dist/interpretation/identity.d.ts.map +1 -0
  65. package/dist/interpretation/identity.js +44 -0
  66. package/dist/interpretation/identity.js.map +1 -0
  67. package/dist/interpretation/index.d.ts +23 -0
  68. package/dist/interpretation/index.d.ts.map +1 -0
  69. package/dist/interpretation/index.js +23 -0
  70. package/dist/interpretation/index.js.map +1 -0
  71. package/dist/interpretation/limits.d.ts +20 -0
  72. package/dist/interpretation/limits.d.ts.map +1 -0
  73. package/dist/interpretation/limits.js +35 -0
  74. package/dist/interpretation/limits.js.map +1 -0
  75. package/dist/interpretation/parse-resume.d.ts +28 -0
  76. package/dist/interpretation/parse-resume.d.ts.map +1 -0
  77. package/dist/interpretation/parse-resume.js +91 -0
  78. package/dist/interpretation/parse-resume.js.map +1 -0
  79. package/dist/interpretation/presence.d.ts +16 -0
  80. package/dist/interpretation/presence.d.ts.map +1 -0
  81. package/dist/interpretation/presence.js +32 -0
  82. package/dist/interpretation/presence.js.map +1 -0
  83. package/dist/interpretation/resume-last.d.ts +32 -0
  84. package/dist/interpretation/resume-last.d.ts.map +1 -0
  85. package/dist/interpretation/resume-last.js +70 -0
  86. package/dist/interpretation/resume-last.js.map +1 -0
  87. package/dist/interpretation/session-id.d.ts +22 -0
  88. package/dist/interpretation/session-id.d.ts.map +1 -0
  89. package/dist/interpretation/session-id.js +29 -0
  90. package/dist/interpretation/session-id.js.map +1 -0
  91. package/dist/interpretation/session-input.d.ts +13 -0
  92. package/dist/interpretation/session-input.d.ts.map +1 -0
  93. package/dist/interpretation/session-input.js +32 -0
  94. package/dist/interpretation/session-input.js.map +1 -0
  95. package/dist/interpretation/shape.d.ts +17 -0
  96. package/dist/interpretation/shape.d.ts.map +1 -0
  97. package/dist/interpretation/shape.js +54 -0
  98. package/dist/interpretation/shape.js.map +1 -0
  99. package/dist/interpretation/store.d.ts +16 -0
  100. package/dist/interpretation/store.d.ts.map +1 -0
  101. package/dist/interpretation/store.js +27 -0
  102. package/dist/interpretation/store.js.map +1 -0
  103. package/dist/interpretation/versions.d.ts +16 -0
  104. package/dist/interpretation/versions.d.ts.map +1 -0
  105. package/dist/interpretation/versions.js +44 -0
  106. package/dist/interpretation/versions.js.map +1 -0
  107. package/dist/interpretation/vocabulary.d.ts +29 -0
  108. package/dist/interpretation/vocabulary.d.ts.map +1 -0
  109. package/dist/interpretation/vocabulary.js +55 -0
  110. package/dist/interpretation/vocabulary.js.map +1 -0
  111. package/dist/knowledge/claude-code.d.ts +8 -0
  112. package/dist/knowledge/claude-code.d.ts.map +1 -0
  113. package/dist/knowledge/claude-code.js +132 -0
  114. package/dist/knowledge/claude-code.js.map +1 -0
  115. package/dist/knowledge/codex.d.ts +9 -0
  116. package/dist/knowledge/codex.d.ts.map +1 -0
  117. package/dist/knowledge/codex.js +106 -0
  118. package/dist/knowledge/codex.js.map +1 -0
  119. package/dist/knowledge/descriptor.d.ts +211 -0
  120. package/dist/knowledge/descriptor.d.ts.map +1 -0
  121. package/dist/knowledge/descriptor.js +20 -0
  122. package/dist/knowledge/descriptor.js.map +1 -0
  123. package/dist/knowledge/index.d.ts +19 -0
  124. package/dist/knowledge/index.d.ts.map +1 -0
  125. package/dist/knowledge/index.js +19 -0
  126. package/dist/knowledge/index.js.map +1 -0
  127. package/dist/knowledge/matchers.d.ts +10 -0
  128. package/dist/knowledge/matchers.d.ts.map +1 -0
  129. package/dist/knowledge/matchers.js +11 -0
  130. package/dist/knowledge/matchers.js.map +1 -0
  131. package/dist/knowledge/muse.d.ts +12 -0
  132. package/dist/knowledge/muse.d.ts.map +1 -0
  133. package/dist/knowledge/muse.js +94 -0
  134. package/dist/knowledge/muse.js.map +1 -0
  135. package/dist/knowledge/overrides.d.ts +11 -0
  136. package/dist/knowledge/overrides.d.ts.map +1 -0
  137. package/dist/knowledge/overrides.js +166 -0
  138. package/dist/knowledge/overrides.js.map +1 -0
  139. package/dist/knowledge/pi.d.ts +11 -0
  140. package/dist/knowledge/pi.d.ts.map +1 -0
  141. package/dist/knowledge/pi.js +97 -0
  142. package/dist/knowledge/pi.js.map +1 -0
  143. package/package.json +69 -0
  144. package/src/execution/channel.ts +86 -0
  145. package/src/execution/decode.ts +79 -0
  146. package/src/execution/deps.ts +59 -0
  147. package/src/execution/events.ts +37 -0
  148. package/src/execution/index.ts +18 -0
  149. package/src/execution/lines.ts +71 -0
  150. package/src/execution/node-deps.ts +179 -0
  151. package/src/execution/open-session.ts +392 -0
  152. package/src/execution/stream-turn.ts +310 -0
  153. package/src/index.ts +7 -0
  154. package/src/interpretation/argv.ts +188 -0
  155. package/src/interpretation/capabilities.ts +51 -0
  156. package/src/interpretation/content.ts +191 -0
  157. package/src/interpretation/context.ts +28 -0
  158. package/src/interpretation/dimensions.ts +20 -0
  159. package/src/interpretation/identity.ts +81 -0
  160. package/src/interpretation/index.ts +22 -0
  161. package/src/interpretation/limits.ts +63 -0
  162. package/src/interpretation/parse-resume.ts +117 -0
  163. package/src/interpretation/presence.ts +49 -0
  164. package/src/interpretation/resume-last.ts +93 -0
  165. package/src/interpretation/session-id.ts +35 -0
  166. package/src/interpretation/session-input.ts +45 -0
  167. package/src/interpretation/shape.ts +55 -0
  168. package/src/interpretation/store.ts +43 -0
  169. package/src/interpretation/versions.ts +43 -0
  170. package/src/interpretation/vocabulary.ts +74 -0
  171. package/src/knowledge/claude-code.ts +132 -0
  172. package/src/knowledge/codex.ts +106 -0
  173. package/src/knowledge/descriptor.ts +217 -0
  174. package/src/knowledge/index.ts +23 -0
  175. package/src/knowledge/matchers.ts +19 -0
  176. package/src/knowledge/muse.ts +94 -0
  177. package/src/knowledge/overrides.ts +182 -0
  178. package/src/knowledge/pi.ts +97 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Resume-command parsing: recognizes a pasted "resume this session" shell
3
+ * command and recovers {harness, sessionId, autonomy} - or a resume-last
4
+ * request where the harness supports one. The inverse of buildResumeArgv,
5
+ * tolerant of what shell history actually carries (quoted ids, resolved
6
+ * bin paths, --flag=value, flag aliases, root options on either side of a
7
+ * subcommand). Anchoring rules ported from v1 (D-011): the id must follow
8
+ * the harness's own resume grammar, match its id shape, and be the ONLY
9
+ * id-shaped token - a UUID inside quoted prompt text must never be
10
+ * returned as the session id. Position independence is safe because
11
+ * tokenize collapses quoted text into single words: a bare resume word can
12
+ * only come from a real argv slot.
13
+ */
14
+ import type { HarnessDescriptor, HarnessName } from "../knowledge/descriptor.js";
15
+ import { isUsableSessionId } from "./session-id.js";
16
+ import { basenameOf, tokenize } from "./shape.js";
17
+
18
+ export interface ParsedResume {
19
+ readonly harness: HarnessName;
20
+ readonly sessionId: string;
21
+ readonly autonomy: boolean;
22
+ }
23
+
24
+ /** A recorded `--last` resume: no id to anchor - corroboration ranking
25
+ * (rankResumeLast) decides what it names. */
26
+ export interface ParsedResumeLast {
27
+ readonly harness: HarnessName;
28
+ readonly resumeLast: true;
29
+ readonly autonomy: boolean;
30
+ }
31
+
32
+ const flagTokens = (h: HarnessDescriptor): readonly string[] => [
33
+ h.resume.flag,
34
+ ...h.resume.aliases,
35
+ ];
36
+
37
+ /** The id token per the descriptor's resume grammar, or null. */
38
+ const idTokenOf = (h: HarnessDescriptor, words: readonly string[]): string | null => {
39
+ const positionalWords: string[] = [];
40
+ if (h.resume.style === "positional") positionalWords.push(h.resume.flag);
41
+ if (h.resume.positionalParseWord !== undefined) {
42
+ positionalWords.push(h.resume.positionalParseWord);
43
+ }
44
+ if (positionalWords.length > 0) {
45
+ // Walk consuming `--flag value` pairs so an option VALUE spelled
46
+ // "resume" (muse exec --workspace resume <uuid>) is never mistaken for
47
+ // the resume word. Root options may sit on either side of a subcommand,
48
+ // so position alone is not the anchor - a bare, unconsumed resume word
49
+ // followed by an id-shaped token is. Conservative direction: a boolean
50
+ // flag directly before the resume word reads as a pair and yields null
51
+ // (display-only false negative), never a stranger's session.
52
+ for (let i = 1; i < words.length; i++) {
53
+ const word = words[i];
54
+ if (word === undefined) break;
55
+ if (word.startsWith("-")) {
56
+ const next = words[i + 1];
57
+ if (!word.includes("=") && next !== undefined && !next.startsWith("-")) i++;
58
+ continue;
59
+ }
60
+ if (positionalWords.includes(word)) {
61
+ const candidate = words[i + 1];
62
+ if (candidate !== undefined && h.resume.idShape.test(candidate)) return candidate;
63
+ }
64
+ }
65
+ }
66
+ if (h.resume.style === "flag" || h.resume.positionalParseWord === undefined) {
67
+ for (const token of flagTokens(h)) {
68
+ if (h.resume.style === "positional") break;
69
+ const at = words.indexOf(token, 1);
70
+ if (at !== -1) return words[at + 1] ?? null;
71
+ const eqForm = words.find((w) => w.startsWith(`${token}=`));
72
+ if (eqForm !== undefined) return eqForm.slice(token.length + 1);
73
+ }
74
+ }
75
+ return null;
76
+ };
77
+
78
+ /** True when the command asks for the harness's resume-most-recent form. */
79
+ const isResumeLast = (h: HarnessDescriptor, words: readonly string[]): boolean => {
80
+ if (h.resumeLast === null) return false;
81
+ if (!words.includes(h.resumeLast.flag)) return false;
82
+ // --last only means "resume last" in the resume grammar's context: after
83
+ // the positional resume word, or anywhere for flag-style harnesses.
84
+ const anchor =
85
+ h.resume.style === "positional" ? h.resume.flag : (h.resume.positionalParseWord ?? null);
86
+ if (anchor !== null) {
87
+ const at = words.indexOf(anchor, 1);
88
+ return at !== -1 && words.indexOf(h.resumeLast.flag) > at;
89
+ }
90
+ return true;
91
+ };
92
+
93
+ export const parseResumeCommand = (
94
+ known: readonly HarnessDescriptor[],
95
+ command: string,
96
+ ): ParsedResume | ParsedResumeLast | null => {
97
+ const words = tokenize(command);
98
+ const bin = words[0];
99
+ if (bin === undefined) return null;
100
+ const h = known.find((d) => basenameOf(bin) === d.bin);
101
+ if (h === undefined) return null;
102
+
103
+ const autonomy = h.autonomy !== null && words.includes(h.autonomy.flag);
104
+
105
+ const id = idTokenOf(h, words);
106
+ if (id === null) {
107
+ return isResumeLast(h, words) ? { harness: h.name, resumeLast: true, autonomy } : null;
108
+ }
109
+ if (!h.resume.idShape.test(id) || !isUsableSessionId(id)) return null;
110
+
111
+ // D-011: any OTHER id-shaped token means the command is ambiguous -
112
+ // refuse rather than resume a stranger.
113
+ const idShaped = words.filter((w) => w !== id && h.resume.idShape.test(w));
114
+ if (idShaped.length > 0) return null;
115
+
116
+ return { harness: h.name, sessionId: id, autonomy };
117
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Presence: does an INTERACTIVE process for this session id exist in the
3
+ * given process listing? Pure over injected rows - the host polls `ps` and
4
+ * feeds rows in. Presence says a process exists; it never creates a tap,
5
+ * never proves a channel, and never decides attachment (that is the chat
6
+ * layer's liveness state machine). Known blind spot, inherent to argv
7
+ * matching and shared with v1: an interactive session whose argv carries no
8
+ * id (`claude --continue`) is invisible here - false-negative is the safe
9
+ * direction, because presence only ever corroborates.
10
+ */
11
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
12
+ import { basenameOf, tokenize } from "./shape.js";
13
+
14
+ export interface ProcessRow {
15
+ readonly argv: string;
16
+ }
17
+
18
+ const idBearingFlags = (h: HarnessDescriptor): readonly string[] => [
19
+ h.resume.flag,
20
+ ...h.resume.aliases,
21
+ ...(h.sessionMode === null ? [] : [h.sessionMode.idFlag]),
22
+ // muse's interactive process IS `muse resume <id>` - the parse-only
23
+ // spelling is exactly what a live interactive argv looks like.
24
+ ...(h.resume.positionalParseWord === undefined ? [] : [h.resume.positionalParseWord]),
25
+ ];
26
+
27
+ export const isInteractive = (
28
+ h: HarnessDescriptor,
29
+ sessionId: string,
30
+ rows: readonly ProcessRow[],
31
+ ): boolean => {
32
+ const flags = idBearingFlags(h);
33
+ return rows.some((row) => {
34
+ // Cheap rejection before tokenizing: most ps rows are unrelated.
35
+ if (!row.argv.includes(sessionId)) return false;
36
+ const words = tokenize(row.argv);
37
+ const bin = words[0];
38
+ // ps reports the resolved path (/usr/local/bin/claude), not the bare bin.
39
+ if (bin === undefined || basenameOf(bin) !== h.bin) return false;
40
+ // The id must be the VALUE of an id-bearing flag - an id-shaped word in
41
+ // prompt text is not presence.
42
+ const bound = flags.some((flag) => {
43
+ const at = words.indexOf(flag);
44
+ return at !== -1 && words[at + 1] === sessionId;
45
+ });
46
+ if (!bound) return false;
47
+ return !h.presence.headlessMarkers.some((marker) => words.includes(marker));
48
+ });
49
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * resumeLast corroboration (the codex --last race): given the candidate
3
+ * sessions a store scan produced, rank them by evidence and choose only
4
+ * when the evidence actually distinguishes them. Two candidates the
5
+ * ranking cannot tell apart is a refusal the caller must surface - "most
6
+ * recent" over a race window is a guess, and a guess resumes a stranger.
7
+ *
8
+ * cwd corroboration compares path STRINGS (trailing slashes normalized);
9
+ * canonicalization (symlinks, /tmp vs /private/tmp) is the caller's job at
10
+ * the impure boundary - harnesses record resolved paths, so pass resolved
11
+ * paths in.
12
+ */
13
+
14
+ export interface ResumeCandidate {
15
+ readonly id: string;
16
+ readonly mtimeMs: number;
17
+ readonly cwd: string;
18
+ }
19
+
20
+ export type ResumeLastVerdict =
21
+ | { readonly kind: "chosen"; readonly id: string; readonly ranked: readonly string[] }
22
+ | { readonly kind: "ambiguous"; readonly candidates: readonly string[]; readonly reason: string }
23
+ | { readonly kind: "none" };
24
+
25
+ /** Two same-tier candidates whose mtimes are closer than this cannot be
26
+ * distinguished by recency - filesystem timestamps race at this scale. */
27
+ const RECENCY_EPSILON_MS = 2_000;
28
+
29
+ const normalizePath = (p: string): string => (p.length > 1 ? p.replace(/\/+$/, "") : p);
30
+
31
+ export const rankResumeLast = (
32
+ candidates: readonly ResumeCandidate[],
33
+ context: { readonly cwd: string },
34
+ ): ResumeLastVerdict => {
35
+ const cwd = normalizePath(context.cwd);
36
+
37
+ // A store scan can surface one session twice (rotated file, symlinked
38
+ // root); duplicates are one candidate, not an ambiguity. Keep the best
39
+ // evidence per id: cwd match wins, then the newest mtime.
40
+ const byId = new Map<string, ResumeCandidate>();
41
+ for (const candidate of candidates) {
42
+ const held = byId.get(candidate.id);
43
+ if (held === undefined) {
44
+ byId.set(candidate.id, candidate);
45
+ continue;
46
+ }
47
+ const heldMatch = normalizePath(held.cwd) === cwd;
48
+ const nextMatch = normalizePath(candidate.cwd) === cwd;
49
+ if (
50
+ (nextMatch && !heldMatch) ||
51
+ (nextMatch === heldMatch && candidate.mtimeMs > held.mtimeMs)
52
+ ) {
53
+ byId.set(candidate.id, candidate);
54
+ }
55
+ }
56
+
57
+ const unique = [...byId.values()];
58
+ if (unique.length === 0) return { kind: "none" };
59
+
60
+ const tierOf = (c: ResumeCandidate): number => (normalizePath(c.cwd) === cwd ? 0 : 1);
61
+ const ranked = unique.sort((x, y) => {
62
+ const tier = tierOf(x) - tierOf(y);
63
+ if (tier !== 0) return tier;
64
+ return y.mtimeMs - x.mtimeMs;
65
+ });
66
+
67
+ const first = ranked[0];
68
+ if (first === undefined) return { kind: "none" };
69
+ if (!Number.isFinite(first.mtimeMs)) {
70
+ return {
71
+ kind: "ambiguous",
72
+ candidates: ranked.map((c) => c.id),
73
+ reason: "candidate timestamps are unusable (non-finite mtime) - refusing to guess",
74
+ };
75
+ }
76
+
77
+ // Every same-tier candidate within the epsilon of the winner is part of
78
+ // the ambiguity - reporting only two of N hides contenders.
79
+ const contenders = ranked.filter(
80
+ (c) =>
81
+ c !== first &&
82
+ tierOf(c) === tierOf(first) &&
83
+ (!Number.isFinite(c.mtimeMs) || Math.abs(first.mtimeMs - c.mtimeMs) < RECENCY_EPSILON_MS),
84
+ );
85
+ if (contenders.length > 0) {
86
+ return {
87
+ kind: "ambiguous",
88
+ candidates: [first.id, ...contenders.map((c) => c.id)],
89
+ reason: `${contenders.length + 1} candidates share the same corroboration tier within ${RECENCY_EPSILON_MS}ms - refusing to guess`,
90
+ };
91
+ }
92
+ return { kind: "chosen", id: first.id, ranked: ranked.map((c) => c.id) };
93
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The shape a harness session id must have before it is believed. Ported
3
+ * from lucid v1 core/session-id.ts: a discovered id comes from the harness's
4
+ * own stdout - the least trusted input in the flow - and is later substituted
5
+ * into resume argv and filesystem paths. Bounding length and stripping
6
+ * control characters is not enough: an id of `--dangerously-skip-permissions`
7
+ * is printable and short, and would be handed to the CLI as a FLAG; an id of
8
+ * `../../etc/passwd` is a path traversal. An id is an opaque token: letters,
9
+ * digits, and the few separators real harnesses use, never leading with a
10
+ * dash or a dot.
11
+ */
12
+
13
+ export const SESSION_ID_MAX = 128;
14
+
15
+ const SESSION_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._:@-]*$/;
16
+
17
+ /** True when this id may be believed, placed into resume argv, or used as a
18
+ * path segment. */
19
+ export const isUsableSessionId = (value: string): boolean =>
20
+ value.length > 0 && value.length <= SESSION_ID_MAX && SESSION_ID_SHAPE.test(value);
21
+
22
+ /** Raised when a session id fails the shape rule at a boundary that cannot
23
+ * return null (argv building, store-path resolution). */
24
+ export class SessionIdRefusalError extends Error {
25
+ constructor(value: string) {
26
+ super(
27
+ `session id ${JSON.stringify(value.slice(0, 64))} is not a usable id: must match ${String(SESSION_ID_SHAPE)} and be at most ${SESSION_ID_MAX} chars`,
28
+ );
29
+ this.name = "SessionIdRefusalError";
30
+ }
31
+ }
32
+
33
+ export const assertUsableSessionId = (value: string): void => {
34
+ if (!isUsableSessionId(value)) throw new SessionIdRefusalError(value);
35
+ };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure validation and encoding for descriptor-declared persistent-session
3
+ * input. This module owns supported wire records, not process I/O.
4
+ */
5
+ import type {
6
+ HarnessDescriptor,
7
+ SessionInputContract,
8
+ SessionInputKind,
9
+ } from "../knowledge/descriptor.js";
10
+ import { SESSION_INPUT_KINDS } from "../knowledge/descriptor.js";
11
+ import { asRecord } from "./shape.js";
12
+
13
+ export type SessionInputIssue = "missing-session-input-contract" | "unsupported-session-input-kind";
14
+
15
+ export class SessionInputRefusalError extends Error {
16
+ constructor(readonly issue: SessionInputIssue) {
17
+ super(`session input refused: ${issue}`);
18
+ this.name = "SessionInputRefusalError";
19
+ }
20
+ }
21
+
22
+ const isSessionInputKind = (value: unknown): value is SessionInputKind =>
23
+ SESSION_INPUT_KINDS.some((kind) => kind === value);
24
+
25
+ export const resolveSessionInput = (harness: HarnessDescriptor): SessionInputContract => {
26
+ const sessionMode = asRecord(harness.sessionMode);
27
+ const input = asRecord(sessionMode?.input);
28
+ if (input === null || !("kind" in input)) {
29
+ throw new SessionInputRefusalError("missing-session-input-contract");
30
+ }
31
+ if (!isSessionInputKind(input.kind)) {
32
+ throw new SessionInputRefusalError("unsupported-session-input-kind");
33
+ }
34
+ return { kind: input.kind };
35
+ };
36
+
37
+ export const encodeSessionInput = (input: SessionInputContract, text: string): string => {
38
+ switch (input.kind) {
39
+ case "claude-sdk-user-message":
40
+ return `${JSON.stringify({
41
+ type: "user",
42
+ message: { role: "user", content: [{ type: "text", text }] },
43
+ })}\n`;
44
+ }
45
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Tiny shared shape helpers for the interpretation layer. They exist so the
3
+ * unsafe cast to Record and the shell-word splitter each have exactly one
4
+ * owner - quoting rules live in tokenize, not at call sites.
5
+ */
6
+
7
+ export const asRecord = (value: unknown): Record<string, unknown> | null =>
8
+ typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
9
+
10
+ /**
11
+ * Split a command line into shell words: whitespace separates, single or
12
+ * double quotes group (so a flag inside quoted prompt text stays part of
13
+ * that one word and can never be mistaken for a real flag - the D-011
14
+ * resume-anchoring rule depends on this).
15
+ */
16
+ export const tokenize = (command: string): string[] => {
17
+ const words: string[] = [];
18
+ let current = "";
19
+ let quote: '"' | "'" | null = null;
20
+ let inWord = false;
21
+ for (const ch of command) {
22
+ if (quote !== null) {
23
+ if (ch === quote) {
24
+ quote = null;
25
+ } else {
26
+ current += ch;
27
+ }
28
+ continue;
29
+ }
30
+ if (ch === '"' || ch === "'") {
31
+ quote = ch;
32
+ inWord = true;
33
+ continue;
34
+ }
35
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
36
+ if (inWord) {
37
+ words.push(current);
38
+ current = "";
39
+ inWord = false;
40
+ }
41
+ continue;
42
+ }
43
+ current += ch;
44
+ inWord = true;
45
+ }
46
+ if (inWord) words.push(current);
47
+ return words;
48
+ };
49
+
50
+ /** The final path segment of a command word, without importing node:path -
51
+ * argv[0] arrives as `claude`, `/usr/local/bin/claude`, or similar. */
52
+ export const basenameOf = (word: string): string => {
53
+ const at = word.lastIndexOf("/");
54
+ return at === -1 ? word : word.slice(at + 1);
55
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Store-path resolution: where a harness files the transcript for a session,
3
+ * resolved purely from descriptor template data - the tail reader in the
4
+ * execution layer consumes this, it is never guessed at a call site. The
5
+ * session id is shape-checked before it becomes a path segment (a traversal
6
+ * id must never reach the filesystem), and replacements are literal - no
7
+ * `$&` pattern semantics from String.replace.
8
+ */
9
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
10
+ import { assertUsableSessionId } from "./session-id.js";
11
+
12
+ export interface StorePathInputs {
13
+ readonly home: string;
14
+ readonly cwd: string;
15
+ readonly sessionId: string;
16
+ }
17
+
18
+ const slugFor = (h: HarnessDescriptor, cwd: string): string => {
19
+ const normalized = cwd.length > 1 ? cwd.replace(/\/+$/, "") : cwd;
20
+ switch (h.store.cwdSlug) {
21
+ case "dash-separators":
22
+ return normalized.replace(/[/.]/g, "-");
23
+ case "pi-dash-wrapped":
24
+ // pi 0.84.1, verified on-disk: leading slash stripped, '/' -> '-',
25
+ // dots preserved, wrapped in double dashes:
26
+ // /Users/kevin/dev/x -> --Users-kevin-dev-x--
27
+ return `--${normalized.replace(/^\//, "").replace(/\//g, "-")}--`;
28
+ case "verbatim":
29
+ return normalized;
30
+ default: {
31
+ const exhaustive: never = h.store.cwdSlug;
32
+ return exhaustive;
33
+ }
34
+ }
35
+ };
36
+
37
+ export const storePath = (h: HarnessDescriptor, inputs: StorePathInputs): string => {
38
+ assertUsableSessionId(inputs.sessionId);
39
+ return h.store.template
40
+ .replaceAll("{home}", () => inputs.home)
41
+ .replaceAll("{cwdSlug}", () => slugFor(h, inputs.cwd))
42
+ .replaceAll("{sessionId}", () => inputs.sessionId);
43
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Version comparison for the harness-update pipeline: compare a descriptor's
3
+ * `verifiedAgainst` against the latest published version to decide whether
4
+ * the descriptor's facts are stale. Pure and dotted-numeric: prerelease and
5
+ * build suffixes are split on and compared numerically where present, else
6
+ * ignored - the signal is "a newer version shipped", not full semver
7
+ * precedence.
8
+ */
9
+
10
+ export type VersionStatus = "ok" | "behind" | "ahead" | "unknown";
11
+
12
+ const parse = (v: string): number[] =>
13
+ v
14
+ .trim()
15
+ .split(/[.\-+]/)
16
+ .map((p) => Number.parseInt(p, 10))
17
+ .filter((n) => !Number.isNaN(n));
18
+
19
+ /** -1 if a < b, 0 if equal, 1 if a > b, comparing dotted-numeric parts with
20
+ * missing trailing parts treated as 0. */
21
+ export const compareVersions = (a: string, b: string): -1 | 0 | 1 => {
22
+ const pa = parse(a);
23
+ const pb = parse(b);
24
+ const len = Math.max(pa.length, pb.length);
25
+ for (let i = 0; i < len; i++) {
26
+ const x = pa[i] ?? 0;
27
+ const y = pb[i] ?? 0;
28
+ if (x < y) return -1;
29
+ if (x > y) return 1;
30
+ }
31
+ return 0;
32
+ };
33
+
34
+ /** Where `verified` stands relative to `latest`. `unknown` when either is
35
+ * absent or has no numeric parts (so a wrong descriptor never reads as ok). */
36
+ export const versionStatus = (verified: string, latest: string | null): VersionStatus => {
37
+ if (latest === null) return "unknown";
38
+ if (parse(verified).length === 0 || parse(latest).length === 0) return "unknown";
39
+ const cmp = compareVersions(verified, latest);
40
+ if (cmp < 0) return "behind"; // a newer version has shipped - descriptor unverified for it
41
+ if (cmp > 0) return "ahead"; // verified newer than the published latest (unexpected)
42
+ return "ok";
43
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Model/effort vocabulary: pure checks of a caller's selector against the
3
+ * descriptor's curated vocabulary, resolving aliases to the harness's own
4
+ * spelling. Rejections name the accepted vocabulary so the refusal is
5
+ * actionable without reading the descriptor. Resolution lives in ONE place
6
+ * (resolveModel) so validation and capability claims cannot drift.
7
+ */
8
+ import type { HarnessDescriptor } from "../knowledge/descriptor.js";
9
+
10
+ export type Validated = { readonly ok: true; readonly id: string } | ValidationRefusal;
11
+
12
+ export interface ValidationRefusal {
13
+ readonly ok: false;
14
+ readonly reason: string;
15
+ }
16
+
17
+ /** The selector grammar an extensible registry still demands: pi documents
18
+ * models as provider/id[:thinking], so word characters plus the few real
19
+ * separators - never whitespace, shell metacharacters, or control/format
20
+ * characters, and bounded like session ids. */
21
+ const CLEAN_SELECTOR = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,127}$/;
22
+
23
+ export interface ResolvedModel {
24
+ readonly id: string;
25
+ readonly curated: boolean;
26
+ }
27
+
28
+ /** Alias-resolve a model selector. Object.hasOwn guards the alias lookup -
29
+ * "__proto__" must resolve to itself, not to Object.prototype. */
30
+ export const resolveModel = (h: HarnessDescriptor, model: string): ResolvedModel => {
31
+ const trimmed = model.trim();
32
+ const aliased = Object.hasOwn(h.vocabulary.aliases, trimmed)
33
+ ? h.vocabulary.aliases[trimmed]
34
+ : undefined;
35
+ const id = aliased ?? trimmed;
36
+ return { id, curated: h.vocabulary.models.includes(id) };
37
+ };
38
+
39
+ export const validateModel = (h: HarnessDescriptor, model: string): Validated => {
40
+ const { id, curated } = resolveModel(h, model);
41
+ if (curated) return { ok: true, id };
42
+ if (h.vocabulary.extensible) {
43
+ // D-008: the registry is runtime-extensible - accept any CLEAN unknown
44
+ // selector; capability claims for it degrade separately.
45
+ if (CLEAN_SELECTOR.test(id)) return { ok: true, id };
46
+ return {
47
+ ok: false,
48
+ reason: `model selector ${JSON.stringify(model)} for ${h.bin} is blank, flag-shaped, over-long, or carries whitespace/control/shell characters`,
49
+ };
50
+ }
51
+ return {
52
+ ok: false,
53
+ reason: `unknown ${h.bin} model ${JSON.stringify(model)}; accepted: ${h.vocabulary.models.join(", ")} (aliases: ${Object.keys(h.vocabulary.aliases).join(", ")})`,
54
+ };
55
+ };
56
+
57
+ /** Validate an effort against the ladder that applies to the pick: the
58
+ * model's own ladder where the harness constrains per model (codex), else
59
+ * the harness-wide ladder. */
60
+ export const validateEffort = (h: HarnessDescriptor, effort: string, model?: string): Validated => {
61
+ let ladder = h.vocabulary.efforts;
62
+ if (model !== undefined && h.vocabulary.effortsByModel !== undefined) {
63
+ const { id } = resolveModel(h, model);
64
+ const perModel = Object.hasOwn(h.vocabulary.effortsByModel, id)
65
+ ? h.vocabulary.effortsByModel[id]
66
+ : undefined;
67
+ if (perModel !== undefined) ladder = perModel;
68
+ }
69
+ if (ladder.includes(effort)) return { ok: true, id: effort };
70
+ return {
71
+ ok: false,
72
+ reason: `unknown ${h.bin} effort ${JSON.stringify(effort)}${model === undefined ? "" : ` for model ${JSON.stringify(model)}`}; ladder: ${ladder.join(" < ")}`,
73
+ };
74
+ };