@pan-stack/cli 0.3.31 → 0.3.33

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.
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "pan-stack",
3
+ "interface": {
4
+ "displayName": "PAN Stack"
5
+ },
6
+ "plugins": [
7
+ {
8
+ "name": "pan-harness",
9
+ "source": {
10
+ "source": "local",
11
+ "path": "./plugins/pan-harness"
12
+ },
13
+ "policy": {
14
+ "installation": "AVAILABLE",
15
+ "authentication": "ON_INSTALL"
16
+ },
17
+ "category": "Coding"
18
+ }
19
+ ]
20
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "pan-stack",
3
+ "owner": {
4
+ "name": "n46-labs"
5
+ },
6
+ "description": "PAN Stack: harness (danh tính AI, Pan Lineage) và bộ skill PAN, đi kèm @pan-stack/cli — cài bằng `pan harness setup`.",
7
+ "plugins": [
8
+ {
9
+ "name": "pan-harness",
10
+ "source": "./plugins/pan-harness",
11
+ "description": "Harness cho workspace PAN Stack: mọi commit của Claude mang danh tính AI + người chủ quản + máy; nguồn tài liệu (Pan Lineage) của tệp hiện ra trước khi sửa."
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "pan-harness",
3
+ "displayName": "Pan Harness",
4
+ "version": "0.3.33",
5
+ "description": "Harness cho workspace PAN Stack: mọi commit của Claude mang danh tính AI + người chủ quản + máy; nguồn tài liệu (Pan Lineage) của tệp hiện ra trước khi sửa.",
6
+ "author": {
7
+ "name": "n46-labs"
8
+ },
9
+ "license": "SEE LICENSE IN LICENSE.txt",
10
+ "keywords": [
11
+ "pan-stack",
12
+ "lineage",
13
+ "harness"
14
+ ],
15
+ "hooks": "./hooks/claude.json",
16
+ "userConfig": {
17
+ "agent_code": {
18
+ "type": "string",
19
+ "title": "Mã agent",
20
+ "description": "Mã ghi vào sổ lineage cho mọi việc Claude làm trong phiên (vd claude, dev-a)",
21
+ "default": "claude"
22
+ },
23
+ "strict_lineage": {
24
+ "type": "boolean",
25
+ "title": "Chặn sửa khi nguồn đã đổi",
26
+ "description": "Bật: không cho sửa tệp có nguồn tài liệu đã đổi cho tới khi xem pan lineage diff rồi derive hoặc ack",
27
+ "default": false
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "pan-harness",
3
+ "version": "0.3.33",
4
+ "description": "PAN Stack harness for AI agents: every commit carries AI + owner + machine identity, document sources (Pan Lineage) show before edits, and PAN skills load on their own.",
5
+ "author": {
6
+ "name": "n46-labs"
7
+ },
8
+ "license": "SEE LICENSE IN LICENSE.txt",
9
+ "keywords": [
10
+ "pan-stack",
11
+ "lineage",
12
+ "harness"
13
+ ],
14
+ "skills": "./skills/",
15
+ "interface": {
16
+ "displayName": "Pan Harness",
17
+ "shortDescription": "PAN skills and workflow rules for PAN Stack workspaces",
18
+ "longDescription": "PAN Stack harness for AI agents: every commit carries AI + owner + machine identity, document sources (Pan Lineage) show before edits, and PAN skills load on their own.",
19
+ "developerName": "n46-labs",
20
+ "category": "Coding",
21
+ "capabilities": [
22
+ "Read",
23
+ "Write"
24
+ ]
25
+ }
26
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "pan-harness",
3
+ "displayName": "Pan Harness",
4
+ "version": "0.3.33",
5
+ "description": "PAN Stack harness for AI agents: every commit carries AI + owner + machine identity, document sources (Pan Lineage) show before edits, and PAN skills load on their own.",
6
+ "author": {
7
+ "name": "n46-labs"
8
+ },
9
+ "license": "SEE LICENSE IN LICENSE.txt",
10
+ "keywords": [
11
+ "pan-stack",
12
+ "lineage",
13
+ "harness"
14
+ ],
15
+ "skills": "./skills/",
16
+ "rules": "./rules/"
17
+ }
@@ -0,0 +1,41 @@
1
+ NaNa Software License
2
+
3
+ Copyright (c) 2026 NaNa Software. All rights reserved.
4
+
5
+ This software, including its source code, compiled code and documentation (the
6
+ "Software"), is owned by NaNa Software. It is licensed, not sold.
7
+
8
+ 1. License required. You may install, run or otherwise use the Software only if
9
+ NaNa Software has granted you a license in writing, and only within the scope
10
+ and term of that license. Being able to download the Software from a public
11
+ package registry or code host does not grant you a license.
12
+
13
+ 2. Restrictions. Unless your written license from NaNa Software allows it, you
14
+ may not:
15
+ a. copy, publish, distribute, sublicense, rent, lease, sell or otherwise
16
+ transfer the Software, in whole or in part;
17
+ b. modify the Software or create derivative works based on it;
18
+ c. reverse engineer, decompile or disassemble the Software, except to the
19
+ extent that applicable law expressly permits this despite this
20
+ restriction;
21
+ d. remove or change any copyright, trademark or other proprietary notice in
22
+ the Software.
23
+
24
+ 3. Third-party components. Parts of the Software come from third parties. Those
25
+ parts stay under their own licenses, which apply to those parts instead of
26
+ this license.
27
+
28
+ 4. Termination. Your right to use the Software ends automatically when you
29
+ break these terms or when your written license ends. You must then stop
30
+ using the Software and delete all copies of it.
31
+
32
+ 5. No warranty. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
33
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
34
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
35
+
36
+ 6. Limitation of liability. IN NO EVENT SHALL NANA SOFTWARE BE LIABLE FOR ANY
37
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
38
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
39
+ USE OF OR OTHER DEALINGS IN THE SOFTWARE.
40
+
41
+ To request a license, contact NaNa Software.
@@ -0,0 +1,41 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "matcher": "startup|resume|clear|compact",
6
+ "hooks": [
7
+ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs", "session-start"], "timeout": 20 }
8
+ ]
9
+ }
10
+ ],
11
+ "PostToolUse": [
12
+ {
13
+ "matcher": "Read",
14
+ "hooks": [
15
+ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs", "after-read"], "timeout": 20 }
16
+ ]
17
+ },
18
+ {
19
+ "matcher": "Edit|Write|MultiEdit",
20
+ "hooks": [
21
+ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs", "after-edit"], "timeout": 30 }
22
+ ]
23
+ }
24
+ ],
25
+ "PreToolUse": [
26
+ {
27
+ "matcher": "Read|Edit|Write|MultiEdit|Bash|Agent|Task",
28
+ "hooks": [
29
+ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs", "pre-tool"], "timeout": 20 }
30
+ ]
31
+ }
32
+ ],
33
+ "Stop": [
34
+ {
35
+ "hooks": [
36
+ { "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook.mjs", "stop"], "timeout": 60 }
37
+ ]
38
+ }
39
+ ]
40
+ }
41
+ }
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Tự chọn skill PAN cho mỗi yêu cầu
3
+ alwaysApply: true
4
+ ---
5
+
6
+ # Skill PAN
7
+
8
+ Plugin pan-harness mang sẵn bộ skill PAN (thư mục `skills/` của plugin). Với **mỗi** yêu cầu của người dùng:
9
+
10
+ 1. Đối chiếu yêu cầu với tên và mô tả các skill PAN.
11
+ 2. Khớp rõ một skill: đọc `SKILL.md` của skill đó rồi làm theo, không cần người dùng gọi tên skill.
12
+ 3. Chỉ khớp một phần: hỏi người dùng một câu ngắn "Dùng skill <tên> cho việc này không?" trước khi làm.
13
+ 4. Không khớp skill nào: làm bình thường.
14
+
15
+ Người dùng không phải gõ tên skill; tự chọn là việc của agent.
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env node
2
+ // pan-harness — hook của Claude Code, một lối vào cho mọi sự kiện (`node hook.mjs <sự kiện>`, JSON của Claude Code
3
+ // đi vào stdin). Thiết kế: repo pan-harness, docs/decisions/2026-10-28-pan-harness-plugin-solution.md và
4
+ // …-lineage-solution.md §8.
5
+ //
6
+ // session-start ghi PAN_ACTOR_KIND/PAN_ACTOR/PAN_MODEL/PAN_RUN_MODE vào CLAUDE_ENV_FILE → mọi `git commit` Claude chạy
7
+ // được hook `pan lineage` ghi đúng là AI thay mặt người chủ quản; nạp vài dòng ngữ cảnh (người chủ
8
+ // quản, việc lineage cần xem lại).
9
+ // after-read (PostToolUse của Read) tệp vừa đọc có nguồn trong sổ lineage → nạp nguồn + trạng thái, nguồn đã đổi
10
+ // thì kèm diff. Đặt ở Read vì Claude Code bắt đọc tệp trước khi sửa, còn additionalContext của
11
+ // PreToolUse chỉ đến Claude CÙNG kết quả tool — tức là sau khi đã sửa (đã đối chiếu tài liệu hooks).
12
+ // pre-tool (PreToolUse) khối B — rào chắn: đọc / sửa tệp secret → deny (cả qua `cat .env` trong Bash); lệnh
13
+ // không hoàn tác (`pan release-*`, `git push --force`, `--no-verify`…) → ask; `subagents: deny` trong
14
+ // .pan-harness.yaml → deny tool Agent/Task. Lệnh khớp theo TOKEN (tôn trọng ngoặc kép) — chuỗi
15
+ // `--no-verify` trong message commit không bị chặn nhầm. Sau đó với Edit/Write: lineage strict như cũ.
16
+ // after-edit (PostToolUse của Edit/Write/MultiEdit) khối C — `pan harness lint <tệp>` theo `lint:` của
17
+ // .pan-harness.yaml; vi phạm → nạp cho agent tự sửa, không chặn. Không khai `lint:` thì không gọi pan.
18
+ // before-edit (giữ tên cũ) chỉ khi bật strict_lineage hoặc PAN_LINEAGE_STRICT=1: tệp có nguồn đã đổi → deny.
19
+ // stop (Stop) khối D — cổng xong: `pan harness status --json`; cổng có sửa chưa commit trong phạm vi
20
+ // (`changed`) mà chưa qua → mode enforce: chặn báo xong kèm cách làm; mode warn: chỉ nhắc người dùng.
21
+ // Tối đa 2 lần chặn liên tiếp mỗi phiên (stop_hook_active) để không lặp vô hạn.
22
+ // Program (OD-52) workspace khai Program (`studio:` trong .project.yaml), mọi lệnh `pan` chạy nền, không chờ:
23
+ // pre-tool → `pan program presence` (đang làm; kèm tệp đang sửa để biết project con, mã phiên để mỗi
24
+ // phiên là một bot), tối đa 30 giây/lần mỗi phiên trừ khi đổi trạng thái; stop → rảnh + `pan program
25
+ // sync --auto`; session-start → `pan program sync --auto` (bắt kịp git pull giữa hai phiên).
26
+ //
27
+ // Ngoài workspace PAN thì không làm gì. Lỗi của chính hook không làm hỏng phiên: thoát 0, không in gì.
28
+ import { execFileSync, spawn } from 'node:child_process';
29
+ import fs from 'node:fs';
30
+ import os from 'node:os';
31
+ import path from 'node:path';
32
+
33
+ const MESSAGES = {
34
+ vi: {
35
+ head: '[pan-harness] workspace {ws} · người chủ quản {principal} · agent {agent}{mode}',
36
+ noPrincipal: '[pan-harness] chưa có danh tính người chủ quản — chạy `pan me` trước khi commit',
37
+ autonomous: ' · tự chạy',
38
+ skills: 'Skill PAN có sẵn trong plugin pan-harness: yêu cầu khớp rõ một skill thì tự dùng skill đó; chỉ khớp một phần thì hỏi người dùng có dùng skill ấy không.',
39
+ staleCount: 'Lineage: {count} sản phẩm cần xem lại vì nguồn tài liệu đã đổi (`pan lineage stale`):',
40
+ rules:
41
+ 'Luật: sửa tệp theo một mục tài liệu thì ghi nguồn `pan lineage derive <tệp> --source <repo>:<tài liệu>#<mục>`; ' +
42
+ 'commit tự ghi phần còn lại nhờ hook `pan lineage`. Đã xem thay đổi nguồn mà tệp không cần sửa: `pan lineage ack … --reason "…"`.',
43
+ sources: '[pan-harness] {file} dẫn xuất từ:',
44
+ stale: 'Nguồn đã đổi từ lần dẫn xuất — cập nhật tệp theo nguồn mới rồi `pan lineage derive {file} --source {source}`, hoặc `pan lineage ack {file} --source {source} --reason "…"` nếu không cần sửa.',
45
+ deny: 'pan-harness (strict_lineage): {file} dựa trên nguồn đã đổi: {sources}. Xem `pan lineage diff {file}`, sửa theo nguồn mới, rồi `pan lineage derive` hoặc `ack` trước khi sửa tiếp.',
46
+ state: { fresh: 'khớp', stale: 'CẦN XEM LẠI', moved: 'đổi khoá', missing: 'nguồn mất', decision: 'quyết định', unknown: 'không rõ' },
47
+ secret: 'pan-harness: {file} chứa secret — không đọc / sửa nội dung. Dùng biến môi trường; cần giá trị thì hỏi người dùng.',
48
+ irreversible: 'pan-harness: `{command}` không hoàn tác được (phát hành / xoá / ép lịch sử / bỏ hook) — người dùng xác nhận trước.',
49
+ subagents: 'pan-harness: workspace cấm phóng subagent (.pan-harness.yaml › subagents: deny) — làm trực tiếp trong phiên này.',
50
+ approve: 'pan-harness: AI duyệt được — chạy `pan harness approve <pha>`, sổ ghi là AI thay người chủ quản. Không giả làm người: không đặt / xoá PAN_ACTOR_KIND, PAN_ACTOR trong lệnh duyệt, không gọi thẳng API nút Duyệt của dashboard (nút đó ghi là người bấm).',
51
+ gatesBlock: 'pan-harness (cổng xong): còn cổng chưa qua ở phần bạn đã sửa — {gates}. Chạy {commands}, sửa tới khi xanh rồi mới báo xong.',
52
+ gatesWarn: 'pan-harness: còn cổng chưa qua ở phần vừa sửa — {gates}. Chạy {commands} (hoặc nút Chạy cổng trên dashboard).',
53
+ gate: { fail: 'đỏ', outdated: 'cũ', never: 'chưa chạy' },
54
+ lint: '[pan-harness] lint {file}: {count} vi phạm luật của workspace (.pan-harness.yaml › lint) — sửa luôn:',
55
+ },
56
+ en: {
57
+ head: '[pan-harness] workspace {ws} · principal {principal} · agent {agent}{mode}',
58
+ noPrincipal: '[pan-harness] no principal identity yet — run `pan me` before committing',
59
+ autonomous: ' · autonomous',
60
+ skills: 'PAN skills ship with the pan-harness plugin: when a request clearly matches a skill, use it; when it only partly matches, ask the user whether to use that skill.',
61
+ staleCount: 'Lineage: {count} artifact(s) need review because their source documents changed (`pan lineage stale`):',
62
+ rules:
63
+ 'Rule: when you change a file to follow a document section, record it with `pan lineage derive <file> --source <repo>:<doc>#<section>`; ' +
64
+ 'commits record the rest through the `pan lineage` hook. Reviewed a source change and the file needs nothing: `pan lineage ack … --reason "…"`.',
65
+ sources: '[pan-harness] {file} is derived from:',
66
+ stale: 'The source changed since derivation — update the file to the new source, then `pan lineage derive {file} --source {source}`, or `pan lineage ack {file} --source {source} --reason "…"` if nothing needs to change.',
67
+ deny: 'pan-harness (strict_lineage): {file} depends on changed sources: {sources}. Read `pan lineage diff {file}`, follow the new source, then `pan lineage derive` or `ack` before editing further.',
68
+ state: { fresh: 'fresh', stale: 'NEEDS REVIEW', moved: 'key changed', missing: 'source gone', decision: 'decision', unknown: 'unknown' },
69
+ secret: 'pan-harness: {file} holds secrets — do not read or edit it. Use environment variables; ask the user if you need a value.',
70
+ irreversible: 'pan-harness: `{command}` cannot be undone (release / delete / rewrite history / skip hooks) — the user confirms first.',
71
+ subagents: 'pan-harness: this workspace forbids subagents (.pan-harness.yaml › subagents: deny) — do the work in this session.',
72
+ approve: 'pan-harness: the AI may approve — run `pan harness approve <phase>`; the ledger records the AI acting for the principal. Do not pose as a human: no setting / unsetting PAN_ACTOR_KIND or PAN_ACTOR in the approve command, no calling the dashboard Approve API directly (that button records a human click).',
73
+ gatesBlock: 'pan-harness (done gate): gates not passing in what you changed — {gates}. Run {commands}, fix until green, then report done.',
74
+ gatesWarn: 'pan-harness: gates not passing in what was just changed — {gates}. Run {commands} (or Run gates on the dashboard).',
75
+ gate: { fail: 'failing', outdated: 'outdated', never: 'not run' },
76
+ lint: '[pan-harness] lint {file}: {count} violation(s) of workspace rules (.pan-harness.yaml › lint) — fix them now:',
77
+ },
78
+ };
79
+ const MAX_DIFF_LINES = 60;
80
+
81
+ function readInput() {
82
+ try {
83
+ return JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
84
+ } catch {
85
+ return {};
86
+ }
87
+ }
88
+
89
+ function findUp(start, test) {
90
+ let dir = path.resolve(start);
91
+ while (true) {
92
+ if (test(dir)) return dir;
93
+ const parent = path.dirname(dir);
94
+ if (parent === dir) return '';
95
+ dir = parent;
96
+ }
97
+ }
98
+
99
+ const workspaceOf = (start) => findUp(start, (dir) => fs.existsSync(path.join(dir, '.project.yaml')));
100
+ const repoOf = (start) => findUp(start, (dir) => fs.existsSync(path.join(dir, '.git')));
101
+
102
+ function format(template, values) {
103
+ return template.replace(/\{(\w+)\}/g, (match, key) => (values[key] ?? match));
104
+ }
105
+
106
+ /** Ngôn ngữ theo pan: PAN_LANG → `language:` của `.project.yaml` → en. */
107
+ function messagesFor(root) {
108
+ let code = (process.env.PAN_LANG || '').toLowerCase();
109
+ if (!code && root) code = /^language\s*:\s*(\w+)/m.exec(fs.readFileSync(path.join(root, '.project.yaml'), 'utf8'))?.[1] ?? '';
110
+ return MESSAGES[code] ?? MESSAGES.en;
111
+ }
112
+
113
+ /**
114
+ * Gọi `pan … --json`; lỗi / không có pan → null. Mã thoát 1 vẫn đọc JSON: theo hợp đồng của pan nó nghĩa là "còn việc
115
+ * chưa đạt" (vd `pan harness status` khi còn pha chưa xong), không phải lỗi.
116
+ */
117
+ function pan(args, cwd) {
118
+ const parse = (out) => {
119
+ const parsed = JSON.parse(out);
120
+ return parsed.ok || Array.isArray(parsed.data) ? parsed.data : null;
121
+ };
122
+ try {
123
+ return parse(execFileSync('pan', [...args, '--json'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 30000 }));
124
+ } catch (error) {
125
+ try {
126
+ return error?.status === 1 && error.stdout ? parse(error.stdout) : null;
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+ }
132
+
133
+ /** Lọc nhanh trước khi gọi pan: tệp có xuất hiện trong sổ `.pan-lineage/lineage.csv` của repo chứa nó không. */
134
+ function inLedger(file) {
135
+ const repo = repoOf(path.dirname(file));
136
+ if (!repo) return false;
137
+ const ledger = path.join(repo, '.pan-lineage', 'lineage.csv');
138
+ if (!fs.existsSync(ledger)) return false;
139
+ return fs.readFileSync(ledger, 'utf8').includes(`:${path.relative(repo, file).split(path.sep).join('/')}`);
140
+ }
141
+
142
+ function emit(eventName, extra) {
143
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: eventName, ...extra } }));
144
+ }
145
+
146
+ function principalOf(root) {
147
+ try {
148
+ const text = fs.readFileSync(path.join(root, '.me.pan.yaml'), 'utf8');
149
+ const block = /^author\s*:[ \t]*\n((?:[ \t]+.*(?:\n|$))*)/m.exec(text)?.[1] ?? '';
150
+ const field = (key) => new RegExp(`^[ \\t]+${key}\\s*:\\s*(.*)$`, 'm').exec(block)?.[1]?.trim().replace(/^["']|["']$/g, '') ?? '';
151
+ return field('email') ? `${field('user')} <${field('email')}>` : '';
152
+ } catch {
153
+ return '';
154
+ }
155
+ }
156
+
157
+ function sessionStart(input) {
158
+ const root = workspaceOf(input.cwd || process.cwd());
159
+ if (!root) return;
160
+ const m = messagesFor(root);
161
+ const agent = process.env.CLAUDE_PLUGIN_OPTION_AGENT_CODE || 'claude';
162
+ const model = typeof input.model === 'string' ? input.model : (input.model?.id ?? '');
163
+ const runMode = process.env.PAN_RUN_MODE === 'autonomous' ? 'autonomous' : 'interactive';
164
+ if (process.env.CLAUDE_ENV_FILE) {
165
+ const quote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
166
+ const lines = [`export PAN_ACTOR_KIND=ai`, `export PAN_ACTOR=${quote(agent)}`, `export PAN_RUN_MODE=${runMode}`];
167
+ if (model) lines.push(`export PAN_MODEL=${quote(model)}`);
168
+ fs.appendFileSync(process.env.CLAUDE_ENV_FILE, `${lines.join('\n')}\n`);
169
+ }
170
+ const principal = principalOf(root);
171
+ const lines = [
172
+ principal
173
+ ? format(m.head, { ws: path.basename(root), principal, agent, mode: runMode === 'autonomous' ? m.autonomous : '' })
174
+ : m.noPrincipal,
175
+ ];
176
+ if (hasProgram(root)) background(['program', 'sync', '--auto'], root);
177
+ const stale = pan(['lineage', 'stale'], root) ?? [];
178
+ if (stale.length) {
179
+ lines.push(format(m.staleCount, { count: stale.length }));
180
+ for (const edge of stale.slice(0, 5)) lines.push(` - ${edge.artifact} ← ${edge.source}`);
181
+ }
182
+ lines.push(m.skills, m.rules);
183
+ emit('SessionStart', { additionalContext: lines.join('\n') });
184
+ }
185
+
186
+ function afterRead(input) {
187
+ const file = input.tool_input?.file_path;
188
+ if (!file || !inLedger(file)) return;
189
+ const root = workspaceOf(path.dirname(file));
190
+ if (!root) return;
191
+ const m = messagesFor(root);
192
+ const edges = pan(['lineage', 'why', file], path.dirname(file)) ?? [];
193
+ if (!edges.length) return;
194
+ // địa chỉ `repo:đường dẫn` — ngắn, không lộ thư mục home, mọi lệnh `pan lineage` đều nhận
195
+ const address = edges[0].artifact || file;
196
+ const lines = [format(m.sources, { file: address })];
197
+ const changed = [];
198
+ for (const edge of edges) {
199
+ lines.push(` - ${edge.source} [${m.state[edge.state] ?? edge.state}]${edge.note ? ` — ${edge.note}` : ''}`);
200
+ if (['stale', 'moved'].includes(edge.state)) changed.push(edge.source);
201
+ }
202
+ if (changed.length) {
203
+ const diffs = pan(['lineage', 'diff', file], path.dirname(file)) ?? [];
204
+ const text = diffs.map((item) => item.diff).join('\n').split('\n');
205
+ lines.push(...text.slice(0, MAX_DIFF_LINES), ...(text.length > MAX_DIFF_LINES ? ['…'] : []));
206
+ for (const source of changed) lines.push(format(m.stale, { file: address, source }));
207
+ }
208
+ emit('PostToolUse', { additionalContext: lines.join('\n') });
209
+ }
210
+
211
+ const MAX_LINT_LINES = 20;
212
+
213
+ function afterEdit(input) {
214
+ const file = input.tool_input?.file_path;
215
+ if (!file) return;
216
+ const root = workspaceOf(path.dirname(file));
217
+ const repo = repoOf(path.dirname(file));
218
+ const declares = (dir) => dir && /^lint\s*:/m.test(workspaceConfig(dir));
219
+ if (!root || !(declares(root) || declares(repo))) return;
220
+ const violations = pan(['harness', 'lint', file], root) ?? [];
221
+ if (!violations.length) return;
222
+ const m = messagesFor(root);
223
+ const lines = [format(m.lint, { file: violations[0].file, count: violations.length })];
224
+ for (const item of violations) {
225
+ const where = `${item.file}${item.line ? `:${item.line}` : ''}`;
226
+ lines.push(` - ${where} ${item.rule}${item.message ? ` — ${item.message}` : ''}`);
227
+ if (item.text) lines.push(...item.text.split('\n').map((text) => ` ${text}`));
228
+ }
229
+ emit('PostToolUse', { additionalContext: [...lines.slice(0, MAX_LINT_LINES), ...(lines.length > MAX_LINT_LINES ? ['…'] : [])].join('\n') });
230
+ }
231
+
232
+ function beforeEdit(input) {
233
+ const strict = ['true', '1'].includes(String(process.env.CLAUDE_PLUGIN_OPTION_STRICT_LINEAGE)) || process.env.PAN_LINEAGE_STRICT === '1';
234
+ const file = input.tool_input?.file_path;
235
+ if (!strict || !file || !inLedger(file)) return;
236
+ const root = workspaceOf(path.dirname(file));
237
+ if (!root) return;
238
+ const changed = (pan(['lineage', 'why', file], path.dirname(file)) ?? []).filter((edge) => ['stale', 'moved', 'missing'].includes(edge.state));
239
+ if (!changed.length) return;
240
+ const m = messagesFor(root);
241
+ emit('PreToolUse', {
242
+ permissionDecision: 'deny',
243
+ permissionDecisionReason: format(m.deny, { file: changed[0].artifact || file, sources: changed.map((edge) => edge.source).join(', ') }),
244
+ });
245
+ }
246
+
247
+ // ── Khối B: rào chắn ─────────────────────────────────────────────────────────────────────────────────────────────
248
+
249
+ /** Tệp secret (plugin §4.5 no-secret-read): theo tên tệp, mẫu .example/.sample được đọc. */
250
+ function isSecretFile(file) {
251
+ const name = path.basename(String(file ?? ''));
252
+ if (/\.(example|sample|template)$/i.test(name)) return false;
253
+ return name === '.pan-env-build.yaml' || name === '.env' || name.startsWith('.env.') || name === 'credentials.json';
254
+ }
255
+
256
+ /** Tách lệnh shell thành các đoạn (`&&` `||` `;` `|`), mỗi đoạn là danh sách token — tôn trọng ngoặc đơn / kép. */
257
+ function shellSegments(command) {
258
+ const segments = [[]];
259
+ let token = '';
260
+ let quote = '';
261
+ let hasToken = false;
262
+ const push = () => {
263
+ if (hasToken) segments.at(-1).push(token);
264
+ token = '';
265
+ hasToken = false;
266
+ };
267
+ const text = String(command ?? '');
268
+ for (let i = 0; i < text.length; i++) {
269
+ const char = text[i];
270
+ if (quote) {
271
+ if (char === quote) quote = '';
272
+ else token += char;
273
+ continue;
274
+ }
275
+ if (char === '"' || char === "'") {
276
+ quote = char;
277
+ hasToken = true;
278
+ } else if (/\s/.test(char)) push();
279
+ else if (char === ';' || char === '|' || char === '&') {
280
+ push();
281
+ if (segments.at(-1).length) segments.push([]);
282
+ if ((char === '|' || char === '&') && text[i + 1] === char) i++;
283
+ } else {
284
+ token += char;
285
+ hasToken = true;
286
+ }
287
+ }
288
+ push();
289
+ return segments.filter((segment) => segment.length);
290
+ }
291
+
292
+ const READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'grep', 'rg', 'sed', 'awk', 'strings', 'xxd', 'base64', 'cp', 'scp', 'open', 'code', 'vi', 'vim', 'nano']);
293
+ const HTTP_CLIENTS = new Set(['curl', 'wget', 'http', 'https', 'xh']);
294
+
295
+ /** Đoạn lệnh không hoàn tác được → mô tả ngắn để hỏi người dùng; không thì ''. */
296
+ function irreversible(tokens) {
297
+ const words = tokens.filter((token) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token));
298
+ const [tool, sub] = words;
299
+ if (tool === 'pan' && (/^release-/.test(sub ?? '') || sub === 'publish-npm' || (sub === 'gh-artifacts' && words.includes('delete')))) return words.slice(0, 3).join(' ');
300
+ if (tool === 'git' && sub === 'push' && words.some((word) => word === '--force' || word === '-f' || word.startsWith('--force-with-lease') || /^\+/.test(word))) return 'git push --force';
301
+ if (tool === 'git' && sub === 'commit' && words.includes('--no-verify')) return 'git commit --no-verify';
302
+ if (tool === 'git' && sub === 'reset' && words.includes('--hard')) return 'git reset --hard';
303
+ return '';
304
+ }
305
+
306
+ function workspaceConfig(root) {
307
+ try {
308
+ return fs.readFileSync(path.join(root, '.pan-harness.yaml'), 'utf8');
309
+ } catch {
310
+ return '';
311
+ }
312
+ }
313
+
314
+ function guard(input) {
315
+ const tool = input.tool_name ?? '';
316
+ const args = input.tool_input ?? {};
317
+ const root = workspaceOf(input.cwd || process.cwd());
318
+ const m = messagesFor(root);
319
+ const deny = (reason) => emit('PreToolUse', { permissionDecision: 'deny', permissionDecisionReason: reason });
320
+ const ask = (reason) => emit('PreToolUse', { permissionDecision: 'ask', permissionDecisionReason: reason });
321
+
322
+ if (['Read', 'Edit', 'Write', 'MultiEdit'].includes(tool) && isSecretFile(args.file_path)) {
323
+ deny(format(m.secret, { file: path.basename(args.file_path) }));
324
+ return true;
325
+ }
326
+ if (tool === 'Bash') {
327
+ // AI duyệt được, sổ ghi là AI (OD-51) — chỉ chặn giả làm người: đổi PAN_ACTOR_KIND/PAN_ACTOR trong lệnh duyệt, hoặc
328
+ // gọi thẳng API nút Duyệt bằng công cụ HTTP (dashboard ghi mọi lần bấm là người).
329
+ const command = String(args.command ?? '');
330
+ const touchesIdentity = /\bPAN_ACTOR(_KIND)?\b/.test(command);
331
+ for (const tokens of shellSegments(command)) {
332
+ const words = tokens.filter((token) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token));
333
+ const at = words.findIndex((word, index) => word === 'pan' && words[index + 1] === 'harness' && words[index + 2] === 'approve');
334
+ const viaEnv = at > 0 && words.slice(0, at).every((word) => /^(env|-u|-i|--unset(=.*)?|-|[A-Z_][A-Z0-9_]*)$/.test(word));
335
+ const approving = at === 0 || viaEnv;
336
+ if ((approving && touchesIdentity) || (HTTP_CLIENTS.has(path.basename(words[0] ?? '')) && tokens.some((token) => token.includes('/api/harness/approve')))) {
337
+ deny(m.approve);
338
+ return true;
339
+ }
340
+ const program = path.basename(tokens.find((token) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) ?? '');
341
+ const secret = tokens.find((token) => token !== tokens[0] && isSecretFile(token));
342
+ if ((READERS.has(program) || tokens.some((token) => token === '<')) && secret) {
343
+ deny(format(m.secret, { file: path.basename(secret) }));
344
+ return true;
345
+ }
346
+ const risky = irreversible(tokens);
347
+ if (risky) {
348
+ ask(format(m.irreversible, { command: risky }));
349
+ return true;
350
+ }
351
+ }
352
+ }
353
+ if ((tool === 'Agent' || tool === 'Task') && root && /^subagents\s*:\s*deny\b/m.test(workspaceConfig(root))) {
354
+ deny(m.subagents);
355
+ return true;
356
+ }
357
+ return false;
358
+ }
359
+
360
+ function preTool(input) {
361
+ if (guard(input)) return;
362
+ beat(input, 'working');
363
+ if (['Edit', 'Write', 'MultiEdit'].includes(input.tool_name)) beforeEdit(input);
364
+ }
365
+
366
+ // ── Nhịp tim Program (OD-52) ─────────────────────────────────────────────────────────────────────────────────────
367
+
368
+ const BEAT_MS = 30_000;
369
+
370
+ const hasProgram = (root) => /^studio\s*:/m.test(fs.readFileSync(path.join(root, '.project.yaml'), 'utf8'));
371
+ const sessionOf = (input) => String(input.session_id ?? 'session').replace(/[^\w-]/g, '');
372
+
373
+ /** `pan …` tách khỏi hook, không chờ — không làm chậm tool; lỗi (chưa đăng nhập, VIEWER…) `pan` tự ghi cho `pan program status`. */
374
+ function background(args, root) {
375
+ const env = { ...process.env, PAN_ACTOR_KIND: 'ai', PAN_ACTOR: process.env.CLAUDE_PLUGIN_OPTION_AGENT_CODE || 'claude' };
376
+ spawn('pan', args, { cwd: root, env, detached: true, stdio: 'ignore' })
377
+ .on('error', () => {})
378
+ .unref();
379
+ }
380
+
381
+ /** Bảng công trường của Program biết agent nào đang làm, ở project con nào (suy từ tệp đang sửa). */
382
+ function beat(input, presence) {
383
+ const root = workspaceOf(input.cwd || process.cwd());
384
+ if (!root || !hasProgram(root)) return;
385
+ const file = path.join(os.tmpdir(), `pan-harness-beat-${sessionOf(input)}.json`);
386
+ let last = {};
387
+ try {
388
+ last = JSON.parse(fs.readFileSync(file, 'utf8'));
389
+ } catch {
390
+ last = {};
391
+ }
392
+ if (last.presence === presence && Date.now() - (last.at ?? 0) < BEAT_MS) return;
393
+ fs.writeFileSync(file, JSON.stringify({ presence, at: Date.now() }));
394
+ const where = input.tool_input?.file_path || input.cwd || root;
395
+ background(['program', 'presence', '--presence', presence, '--path', where, '--session', sessionOf(input).slice(0, 8)], root);
396
+ }
397
+
398
+ // ── Khối D: cổng xong ────────────────────────────────────────────────────────────────────────────────────────────
399
+
400
+ const MAX_STOP_BLOCKS = 2;
401
+
402
+ function stop(input) {
403
+ beat(input, 'idle');
404
+ const root = workspaceOf(input.cwd || process.cwd());
405
+ // Agent làm xong một lượt: đẩy mọi thay đổi Pan Plans lên Program, không trông vào việc agent nhớ gọi sync.
406
+ if (root && hasProgram(root)) background(['program', 'sync', '--auto'], root);
407
+ if (!root || !workspaceConfig(root)) return;
408
+ // Đếm số lần chặn liên tiếp của phiên — quá ngưỡng thì để agent dừng (người dùng thấy nhắc), không lặp vô hạn.
409
+ const counter = path.join(os.tmpdir(), `pan-harness-stop-${String(input.session_id ?? 'session').replace(/[^\w-]/g, '')}.json`);
410
+ let blocks = 0;
411
+ try {
412
+ blocks = input.stop_hook_active ? JSON.parse(fs.readFileSync(counter, 'utf8')).blocks ?? 0 : 0;
413
+ } catch {
414
+ blocks = 0;
415
+ }
416
+ const phases = pan(['harness', 'status'], root) ?? [];
417
+ const pending = new Map();
418
+ for (const phase of phases)
419
+ for (const gate of phase.gates ?? [])
420
+ if (gate.changed && gate.status !== 'pass') pending.set(`${gate.repo}:${gate.id}`, { ...gate, phase: phase.phase });
421
+ if (!pending.size) {
422
+ fs.rmSync(counter, { force: true });
423
+ return;
424
+ }
425
+ const m = messagesFor(root);
426
+ const gates = [...pending.values()].map((gate) => `${gate.id} @ ${gate.repo} (${m.gate[gate.status] ?? gate.status})`).join(', ');
427
+ const commands = [...new Set([...pending.values()].map((gate) => `\`pan harness check ${gate.phase}\``))].join(', ');
428
+ const enforce = /^mode\s*:\s*enforce\b/m.test(workspaceConfig(root));
429
+ if (!enforce || blocks >= MAX_STOP_BLOCKS) {
430
+ fs.rmSync(counter, { force: true });
431
+ process.stdout.write(JSON.stringify({ systemMessage: format(m.gatesWarn, { gates, commands }) }));
432
+ return;
433
+ }
434
+ fs.writeFileSync(counter, JSON.stringify({ blocks: blocks + 1 }));
435
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: format(m.gatesBlock, { gates, commands }) }));
436
+ }
437
+
438
+ try {
439
+ const input = readInput();
440
+ const handlers = { 'session-start': sessionStart, 'after-read': afterRead, 'after-edit': afterEdit, 'before-edit': beforeEdit, 'pre-tool': preTool, stop };
441
+ handlers[process.argv[2]]?.(input);
442
+ } catch {
443
+ // hook không được làm hỏng phiên
444
+ }
445
+ process.exit(0);