@xmoon76/dsh-pi-tui 0.3.3 → 0.3.4

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": "@xmoon76/dsh-pi-tui",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "The dsh-pi-tui bundle: a TUI surface for DeepSeek Harness profiles (dsh --profile pi-tui)",
5
5
  "license": "MIT",
6
6
  "author": "XMoon <xmoonox@gmail.com>",
@@ -172,7 +172,8 @@
172
172
  "smoke:examples": "node scripts/examples-plugin-smoke.mjs",
173
173
  "pack:release": "rm -f xmoon76-dsh-pi-tui-*.tgz && pnpm pack --pack-destination .",
174
174
  "smoke:pack": "node scripts/tarball-smoke.mjs",
175
- "verify:prepush": "pnpm typecheck:fork && pnpm test:fork && pnpm test:docs && node scripts/naming-gate.mjs && pnpm audit --prod --audit-level high && pnpm pack:release",
176
- "verify:prepush:nofork": "pnpm test:docs && node scripts/naming-gate.mjs && pnpm audit --prod --audit-level high && pnpm pack:release"
175
+ "verify:prepush": "pnpm typecheck:fork && pnpm test:fork && pnpm test:docs && node scripts/naming-gate.mjs && pnpm gate:boundary && pnpm audit --prod --audit-level high && pnpm pack:release",
176
+ "verify:prepush:nofork": "pnpm test:docs && node scripts/naming-gate.mjs && pnpm gate:boundary && pnpm audit --prod --audit-level high && pnpm pack:release",
177
+ "gate:boundary": "node scripts/client-boundary-gate.mjs"
177
178
  }
178
179
  }
@@ -0,0 +1,82 @@
1
+ {
2
+ "commands.ts": [
3
+ "import:dsh-agent",
4
+ "import:dsh-session"
5
+ ],
6
+ "index.ts": [
7
+ "agentDefaultModel",
8
+ "agentPresets",
9
+ "agents",
10
+ "attachments",
11
+ "authorization",
12
+ "commands",
13
+ "credentials",
14
+ "import:dsh-agent",
15
+ "import:dsh-session",
16
+ "jobs",
17
+ "llm",
18
+ "permissionPresets",
19
+ "sessionPersistence",
20
+ "sessions",
21
+ "settings",
22
+ "shell",
23
+ "subagents",
24
+ "tokenMeter",
25
+ "tools"
26
+ ],
27
+ "model-menu.ts": [
28
+ "import:dsh-agent"
29
+ ],
30
+ "present.ts": [
31
+ "import:dsh-session"
32
+ ],
33
+ "preset-events.ts": [
34
+ "import:dsh-session"
35
+ ],
36
+ "rewind.ts": [
37
+ "import:dsh-session"
38
+ ],
39
+ "runtime/direct/interaction-direct.ts": [
40
+ "approval",
41
+ "userQuestions"
42
+ ],
43
+ "runtime/direct/session-direct.ts": [
44
+ "sessionPersistence",
45
+ "sessionQuery"
46
+ ],
47
+ "runtime/direct/session-lifecycle-direct.ts": [
48
+ "agents",
49
+ "import:dsh-agent",
50
+ "import:dsh-session"
51
+ ],
52
+ "runtime/direct/session-writer-direct.ts": [
53
+ "sessionTitle"
54
+ ],
55
+ "runtime/direct/subagent-direct.ts": [
56
+ "subagents"
57
+ ],
58
+ "session-fork.ts": [
59
+ "import:dsh-agent",
60
+ "import:dsh-session"
61
+ ],
62
+ "sessions.ts": [
63
+ "import:dsh-session"
64
+ ],
65
+ "skill-catalog-refresh.ts": [
66
+ "import:dsh-agent"
67
+ ],
68
+ "skill-catalog.ts": [
69
+ "agentPresets",
70
+ "skills"
71
+ ],
72
+ "stats.ts": [
73
+ "import:dsh-session"
74
+ ],
75
+ "surface-catalog.ts": [
76
+ "commands",
77
+ "import:dsh-agent"
78
+ ],
79
+ "transcript.ts": [
80
+ "import:dsh-session"
81
+ ]
82
+ }
@@ -0,0 +1,165 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @xmoon76/dsh-pi-tui/scripts/client-boundary-gate — CI guard for the
4
+ * server/client migration (AGENTS.md "Server/client migration guardrails
5
+ * (hard rules)"): new feature code must not add direct Host coupling outside
6
+ * the approved boundary. The gate is "baseline allowlist + no-new-debt": the
7
+ * current (file, pattern) pairs are frozen in
8
+ * scripts/client-boundary-baseline.json, and any NEW pair fails. Existing
9
+ * coupling may stay on the allowlist until its owning migration phase moves
10
+ * it (docs/client-server-coupling.md); the baseline is updated deliberately
11
+ * by a maintainer when a phase legitimately relocates coupling, never to
12
+ * absorb new debt.
13
+ *
14
+ * Patterns cover the Host services the migration must isolate (the plan's
15
+ * coupling inventory): `ctx.get('service')`, `ctx.<service>` property
16
+ * access, and `@deepseek-ai/dsh-agent` / `@deepseek-ai/dsh-session` type
17
+ * imports (Agent / AgentHandle / Session). Cordis process services with no
18
+ * Host business state (`loader`, `appExit`) and the TUI's own services
19
+ * (`TUI_STARTUP_SERVICE`, `PI_TUI_EXTENSIONS_SERVICE`) are deliberately NOT
20
+ * patterns — they are not migration coupling.
21
+ *
22
+ * Usage:
23
+ * node scripts/client-boundary-gate.mjs # check (CI; exit 1 on new debt)
24
+ * node scripts/client-boundary-gate.mjs --report # print the current inventory
25
+ * @module client-boundary-gate
26
+ */
27
+
28
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
29
+ import { join, relative, dirname } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
31
+
32
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
33
+ const BASELINE_PATH = join(ROOT, 'scripts', 'client-boundary-baseline.json')
34
+
35
+ // Host services the migration must isolate. Keep in sync with
36
+ // docs/client-server-coupling.md.
37
+ export const HOST_SERVICES = [
38
+ 'agents',
39
+ 'sessions',
40
+ 'subagents',
41
+ 'jobs',
42
+ 'credentials',
43
+ 'userQuestions',
44
+ 'agentPresets',
45
+ 'sessionQuery',
46
+ 'sessionPersistence',
47
+ 'sessionTitle',
48
+ 'commands',
49
+ 'tools',
50
+ 'skills',
51
+ 'attachments',
52
+ 'llm',
53
+ 'settings',
54
+ 'approval',
55
+ 'authorization',
56
+ 'permissionPresets',
57
+ 'tokenMeter',
58
+ 'agentDefaultModel',
59
+ 'workspaceRegistry',
60
+ 'directoryPicker',
61
+ 'goal',
62
+ 'planMode',
63
+ 'sandboxPolicy',
64
+ 'shell',
65
+ ]
66
+
67
+ const SERVICE_ALT = HOST_SERVICES.join('|')
68
+ const PATTERNS = [
69
+ // ctx.get('service') — the dominant access form in this codebase.
70
+ { key: (m) => m[1], re: new RegExp(`ctx\\.get\\(['"](${SERVICE_ALT})['"]`) },
71
+ // ctx.<service> property access (would throw without inject; still debt).
72
+ { key: (m) => m[1], re: new RegExp(`ctx\\.(${SERVICE_ALT})\\b`) },
73
+ // Concrete Host object types.
74
+ { key: () => 'import:dsh-agent', re: /@deepseek-ai\/dsh-agent/ },
75
+ { key: () => 'import:dsh-session', re: /@deepseek-ai\/dsh-session/ },
76
+ ]
77
+
78
+ /** True when the line is (heuristically) inside a comment. */
79
+ function isCommentLine(line) {
80
+ const t = line.trim()
81
+ return t.startsWith('*') || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*/')
82
+ }
83
+
84
+ /**
85
+ * Scan a directory tree for Host-coupling patterns in non-comment code
86
+ * lines. Returns a map of `src/...`-style relative path -> Set of matched
87
+ * pattern keys (service names or import markers).
88
+ * @param {string} dir directory to scan
89
+ * @param {string} [base] base for relative paths (defaults to dir)
90
+ * @returns {Record<string, string[]>}
91
+ */
92
+ export function scanTree(dir, base = dir) {
93
+ const out = {}
94
+ const walk = (d) => {
95
+ for (const entry of readdirSync(d, { withFileTypes: true })) {
96
+ const p = join(d, entry.name)
97
+ if (entry.isDirectory()) {
98
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue
99
+ walk(p)
100
+ } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.mts')) {
101
+ const rel = relative(base, p).split('\\').join('/')
102
+ const hits = new Set()
103
+ const lines = readFileSync(p, 'utf8').split('\n')
104
+ for (const line of lines) {
105
+ if (isCommentLine(line)) continue
106
+ for (const { key, re } of PATTERNS) {
107
+ const m = line.match(re)
108
+ if (m) hits.add(key(m))
109
+ }
110
+ }
111
+ if (hits.size > 0) out[rel] = [...hits].sort()
112
+ }
113
+ }
114
+ }
115
+ walk(dir)
116
+ return out
117
+ }
118
+
119
+ /** Load the checked-in baseline (missing file = empty baseline). */
120
+ export function loadBaseline() {
121
+ if (!existsSync(BASELINE_PATH)) return {}
122
+ return JSON.parse(readFileSync(BASELINE_PATH, 'utf8'))
123
+ }
124
+
125
+ /**
126
+ * Compare a scanned inventory against the baseline. Returns the list of NEW
127
+ * (file, pattern) pairs — the no-new-debt violations.
128
+ * @param {Record<string, string[]>} scanned
129
+ * @param {Record<string, string[]>} baseline
130
+ * @returns {string[]}
131
+ */
132
+ export function findNewDebt(scanned, baseline) {
133
+ const debt = []
134
+ for (const [file, patterns] of Object.entries(scanned)) {
135
+ const allowed = new Set(baseline[file] ?? [])
136
+ for (const pattern of patterns) {
137
+ if (!allowed.has(pattern)) debt.push(`${file}: ${pattern}`)
138
+ }
139
+ }
140
+ return debt
141
+ }
142
+
143
+ function main() {
144
+ const scanned = scanTree(join(ROOT, 'src'))
145
+ if (process.argv.includes('--report')) {
146
+ for (const [file, patterns] of Object.entries(scanned).sort()) {
147
+ console.log(`${file}: ${patterns.join(', ')}`)
148
+ }
149
+ console.log(`\n${Object.keys(scanned).length} file(s) with Host coupling.`)
150
+ return
151
+ }
152
+ const baseline = loadBaseline()
153
+ const debt = findNewDebt(scanned, baseline)
154
+ if (debt.length > 0) {
155
+ console.error('client-boundary-gate: NEW Host coupling outside the approved boundary:')
156
+ for (const d of debt) console.error(` ${d}`)
157
+ console.error('\nSee AGENTS.md "Server/client migration guardrails" and docs/client-server-coupling.md.')
158
+ process.exit(1)
159
+ }
160
+ console.log(`client-boundary-gate: ok (${Object.keys(scanned).length} file(s), no new Host coupling)`)
161
+ }
162
+
163
+ if (process.argv[1] && relative(ROOT, process.argv[1]).replace(/\\/g, '/') === 'scripts/client-boundary-gate.mjs') {
164
+ main()
165
+ }
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pre-push gate stage derivation — the single source of truth for what the
4
+ * husky pre-push hook runs stays in package.json's `verify:*` scripts; this
5
+ * module turns one of them into the ordered per-command stage list the hook
6
+ * executes with progress reporting.
7
+ *
8
+ * A script like `a && b && c` yields `['a', 'b', 'c']`. Only top-level `&&`
9
+ * separators are split (a `&&` inside quotes, e.g. inside a node -e snippet,
10
+ * is not a separator). A trailing `&&` (or a `&&` followed by nothing but
11
+ * whitespace) is rejected — `sh -e` treats such a line as a syntax error, so
12
+ * a split would silently drop the intended tail.
13
+ *
14
+ * Supported shell grammar (the parsed script must stay within this subset):
15
+ * - commands chained with `&&` (the split points), each otherwise passed
16
+ * verbatim to `sh -c` for execution — the splitter does NOT interpret
17
+ * the commands themselves;
18
+ * - single- and double-quoted strings anywhere in the script (quotes are
19
+ * tracked so `&&` inside them is not a separator);
20
+ * - backslash escapes inside and outside quotes: an escaped `&&` is NOT
21
+ * treated as a separator (it stays inside the current stage, whose
22
+ * shell semantics are then preserved unchanged by `sh -c`);
23
+ * - anything the command segment may contain (redirects, env assignments,
24
+ * pipes, subshells, `node -e "..."`, ...) is preserved verbatim and
25
+ * executed by `sh -c` unchanged.
26
+ * NOT supported (the splitter rejects them loudly): unterminated quotes,
27
+ * dangling `&&`, empty commands (consecutive `&&`), empty scripts.
28
+ *
29
+ * Usage:
30
+ * node scripts/pre-push-stages.mjs <verify-script-name>
31
+ * # e.g. node scripts/pre-push-stages.mjs verify:prepush
32
+ *
33
+ * @module pre-push-stages
34
+ */
35
+
36
+ import { readFileSync } from 'node:fs'
37
+ import { join, dirname } from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+
40
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
41
+
42
+ function fail(message) {
43
+ console.error(`pre-push-stages: ${message}`)
44
+ process.exit(1)
45
+ }
46
+
47
+ const [name] = process.argv.slice(2)
48
+ if (!name || !name.startsWith('verify:')) {
49
+ fail(`usage: node scripts/pre-push-stages.mjs <verify-script-name> (got: ${name ?? 'none'})`)
50
+ }
51
+
52
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
53
+ const script = pkg.scripts?.[name]
54
+ if (typeof script !== 'string' || script === '') {
55
+ fail(`package.json has no non-empty script "${name}" (update .husky/pre-push)`)
56
+ }
57
+
58
+ // Split on top-level `&&` only: walk the string tracking single-quote,
59
+ // double-quote and backslash escapes; a `&&` seen at depth 0 (no open
60
+ // quote) is a command separator. Escapes are honoured OUTSIDE quotes too
61
+ // (`\&&` is an escaped literal `&`, not a separator), and a script with
62
+ // an unterminated quote is rejected — sh would reject it as well.
63
+ const stages = []
64
+ let current = ''
65
+ let quote = null
66
+ for (let i = 0; i < script.length; i++) {
67
+ const ch = script[i]
68
+ if (quote === '"') {
69
+ if (ch === '\\') { current += ch + (script[i + 1] ?? ''); i++; continue }
70
+ if (ch === '"') quote = null
71
+ current += ch
72
+ continue
73
+ }
74
+ if (quote === "'") {
75
+ if (ch === "'") quote = null
76
+ current += ch
77
+ continue
78
+ }
79
+ if (ch === '\\') {
80
+ // Outside quotes: escape the next character (incl. `&`, so `\&&`
81
+ // stays literal). Keep the backslash in the fragment — it is
82
+ // meaningful to the shell that later runs the stage.
83
+ current += ch + (script[i + 1] ?? '')
84
+ i++
85
+ continue
86
+ }
87
+ if (ch === '"' || ch === "'") {
88
+ quote = ch
89
+ current += ch
90
+ continue
91
+ }
92
+ if (ch === '&' && script[i + 1] === '&') {
93
+ stages.push(current)
94
+ current = ''
95
+ i++
96
+ continue
97
+ }
98
+ current += ch
99
+ }
100
+ stages.push(current)
101
+
102
+ if (quote !== null) {
103
+ fail(`script "${name}" has an unterminated ${quote === '"' ? 'double' : 'single'} quote — sh would reject it; fix package.json`)
104
+ }
105
+
106
+ const trimmed = stages.map((s) => s.trim())
107
+ if (trimmed[trimmed.length - 1] === '') {
108
+ fail(`script "${name}" ends with a dangling "&&" — sh -e would reject it; fix package.json`)
109
+ }
110
+ for (const [i, stage] of trimmed.entries()) {
111
+ if (stage === '') {
112
+ fail(`script "${name}" has an empty command at position ${i + 1} (consecutive "&&")`)
113
+ }
114
+ }
115
+
116
+ process.stdout.write(trimmed.join('\n') + '\n')