@passioncode-ai/passioncode 0.1.14 → 0.1.17
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/CHANGELOG.md +40 -0
- package/README.md +22 -6
- package/SECURITY.md +24 -8
- package/bin/passioncode.js +32 -5
- package/family.json +1 -1
- package/lib/launcher.js +143 -39
- package/package.json +4 -4
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +92 -0
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +86 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +92 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +86 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +92 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +86 -0
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/passioncode/hooks/repo-rules.js +1 -1
- package/payload/plugins/passioncode/hooks/session-start.js +34 -12
- package/payload/plugins/passioncode/hooks/update-check.js +8 -3
- package/payload/plugins/passioncode/skills/working-in-passioncode/SKILL.md +14 -3
- package/payload/plugins/passioncode/skills/working-in-passioncode/references/releasing.md +7 -6
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check one structured dashboard handoff from stdin. No I/O effects; exit 0/1.
|
|
3
|
+
|
|
4
|
+
The caller supplies a trusted host resolver result and observed device/host context.
|
|
5
|
+
This is an output correctness gate, not authentication, discovery, or a relay.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from urllib.parse import parse_qs, urlsplit
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def require(condition, reason):
|
|
14
|
+
if not condition:
|
|
15
|
+
raise ValueError(reason)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def safe_path(path):
|
|
19
|
+
return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
|
|
20
|
+
and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check(data):
|
|
24
|
+
require(isinstance(data, dict), 'invalid_input')
|
|
25
|
+
resolved, host, action = (data.get(k) for k in ('resolved', 'host', 'action'))
|
|
26
|
+
require(all(isinstance(v, dict) for v in (resolved, host, action)), 'missing_context')
|
|
27
|
+
service, native, http = (resolved.get(k) for k in ('service', 'open_link', 'http_url'))
|
|
28
|
+
require(isinstance(service, str) and re.fullmatch(r'[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*', service), 'invalid_service')
|
|
29
|
+
require(isinstance(native, str) and isinstance(http, str), 'missing_resolved_links')
|
|
30
|
+
require(not re.search(r'[\\\x00-\x20\x7f]', native + http), 'unsafe_url')
|
|
31
|
+
deep, plain = urlsplit(native), urlsplit(http)
|
|
32
|
+
require(deep.scheme == 'fabric-dashboards' and not deep.username and not deep.password and not deep.port and not deep.fragment, 'invalid_deep_link')
|
|
33
|
+
query = parse_qs(deep.query, keep_blank_values=True, strict_parsing=True)
|
|
34
|
+
if deep.netloc == 'service':
|
|
35
|
+
require(deep.path in ('/' + service, '/' + service + '/') and set(query) <= {'path'}, 'wrong_service_or_parameter')
|
|
36
|
+
else:
|
|
37
|
+
require(deep.netloc == 'open' and deep.path in ('', '/') and query.get('service') == [service] and set(query) <= {'service', 'path'}, 'invalid_legacy_link')
|
|
38
|
+
require('path' not in query or len(query['path']) == 1, 'duplicate_path')
|
|
39
|
+
page = query.get('path', [None])[0]
|
|
40
|
+
require(page is None or safe_path(page), 'unsafe_path')
|
|
41
|
+
require(plain.scheme == 'http' and plain.hostname in ('127.0.0.1', 'localhost', '::1') and plain.port is not None
|
|
42
|
+
and not plain.username and not plain.password, 'invalid_http_fallback')
|
|
43
|
+
http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
|
|
44
|
+
require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
|
|
45
|
+
sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'}
|
|
46
|
+
page_query = parse_qs(urlsplit(http_page).query)
|
|
47
|
+
require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
|
|
48
|
+
require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
|
|
49
|
+
state = host.get('state')
|
|
50
|
+
require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
|
|
51
|
+
target, viewer = data.get('target_device'), data.get('viewer_device')
|
|
52
|
+
require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
|
|
53
|
+
fallback = data.get('fallback', 'if_absent')
|
|
54
|
+
require(fallback in ('if_absent', 'never'), 'invalid_fallback')
|
|
55
|
+
kind = action.get('kind')
|
|
56
|
+
if kind == 'remote_open':
|
|
57
|
+
require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
|
|
58
|
+
require(action.get('device_id') == target and action.get('service') == service and action.get('path') == page, 'wrong_remote_target')
|
|
59
|
+
require(viewer != target, 'remote_action_on_local_device')
|
|
60
|
+
elif kind == 'native_link':
|
|
61
|
+
require(set(action) == {'kind', 'url'} and action.get('url') == native, 'not_host_provided_link')
|
|
62
|
+
require(viewer == target, 'wrong_viewer_device')
|
|
63
|
+
require(state == 'available', 'host_unavailable')
|
|
64
|
+
elif kind == 'browser_link':
|
|
65
|
+
require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
|
|
66
|
+
require(viewer == target, 'wrong_viewer_device')
|
|
67
|
+
require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
|
|
68
|
+
else:
|
|
69
|
+
raise ValueError('unsupported_dashboard_action')
|
|
70
|
+
return {'ok': True, 'kind': kind}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main():
|
|
74
|
+
try:
|
|
75
|
+
raw = sys.stdin.read(65537)
|
|
76
|
+
require(len(raw) <= 65536, 'input_too_large')
|
|
77
|
+
result = check(json.loads(raw))
|
|
78
|
+
except (ValueError, TypeError, KeyError, AttributeError):
|
|
79
|
+
# Fixed reasons only: never echo a submitted URL, token or JSON blob.
|
|
80
|
+
result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
|
|
81
|
+
print(json.dumps(result))
|
|
82
|
+
return 0 if result['ok'] else 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == '__main__':
|
|
86
|
+
sys.exit(main())
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
|
|
3
3
|
"name": "passioncode",
|
|
4
4
|
"displayName": "PassionCode.ai",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.17",
|
|
6
6
|
"description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
@@ -32,7 +32,7 @@ function originOf(dir) {
|
|
|
32
32
|
/** The one line to print at session start in `dir`, or null. */
|
|
33
33
|
function rulesLine(dir) {
|
|
34
34
|
if (!orgOf(originOf(dir))) return null;
|
|
35
|
-
return `[passioncode] passioncode-ai repository: read the knowledge base (${KNOWLEDGE} or ${KNOWLEDGE_WEB}), then this repository's AGENTS.md, before the first edit; after the work, update the knowledge base page that owns any cross-repository fact you changed.`;
|
|
35
|
+
return `[passioncode] passioncode-ai repository: read the knowledge base (${KNOWLEDGE} or ${KNOWLEDGE_WEB}), then this repository's AGENTS.md, before the first edit; after the work, update the knowledge base page that owns any cross-repository fact you changed; edit each canonical task source declared in docs/backlog-sources.json and publish the derived workspace backlog.`;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
module.exports = { KNOWLEDGE, KNOWLEDGE_WEB, orgOf, rulesLine };
|
|
@@ -16,24 +16,32 @@ const DIR = process.env.PASSIONCODE_HOME || path.join(os.homedir(), '.passioncod
|
|
|
16
16
|
const STATE = path.join(DIR, 'state.json');
|
|
17
17
|
const DAY = 24 * 60 * 60 * 1000;
|
|
18
18
|
|
|
19
|
-
function read(file, fallback) {
|
|
19
|
+
function read(file, fallback) {
|
|
20
|
+
try { const doc = JSON.parse(fs.readFileSync(file, 'utf8')); return doc && typeof doc === 'object' && !Array.isArray(doc) ? doc : fallback; }
|
|
21
|
+
catch (_) { return fallback; }
|
|
22
|
+
}
|
|
20
23
|
function detached(cmd, args, logName) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let out;
|
|
26
|
+
try {
|
|
27
|
+
fs.mkdirSync(path.join(DIR, 'logs'), { recursive: true });
|
|
28
|
+
out = fs.openSync(path.join(DIR, 'logs', logName), 'a');
|
|
29
|
+
const child = spawn(cmd, args, { detached: true, stdio: ['ignore', out, out], env: process.env });
|
|
30
|
+
child.once('error', () => resolve(false));
|
|
31
|
+
child.once('spawn', () => { child.unref(); resolve(true); });
|
|
32
|
+
} catch (_) { resolve(false); }
|
|
33
|
+
finally { if (out !== undefined) fs.closeSync(out); }
|
|
34
|
+
});
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
async function main() {
|
|
30
38
|
const state = read(STATE, {});
|
|
31
39
|
const now = Date.now();
|
|
32
|
-
|
|
33
|
-
|
|
40
|
+
const age = now - Date.parse(state.checkedAt);
|
|
41
|
+
if (!Number.isFinite(age) || age < 0 || age > DAY) {
|
|
42
|
+
await detached(process.execPath, [path.join(__dirname, 'probe.js')], 'probe.log');
|
|
34
43
|
}
|
|
35
44
|
const { lines, spawn: args, stateChanges } = decide(state, loadTrust(), now);
|
|
36
|
-
if (args && !detached('npx', args, 'update.log')) delete stateChanges.updatingSince;
|
|
37
45
|
if (Object.keys(stateChanges).length) {
|
|
38
46
|
// Re-read: the probe may have written meanwhile; only our own keys change.
|
|
39
47
|
const fresh = { ...read(STATE, {}), ...stateChanges };
|
|
@@ -42,10 +50,24 @@ try {
|
|
|
42
50
|
fs.writeFileSync(tmp, JSON.stringify(fresh, null, 2));
|
|
43
51
|
fs.renameSync(tmp, STATE);
|
|
44
52
|
}
|
|
53
|
+
// Record the attempt before spawning: a fast update must not finish and then
|
|
54
|
+
// have this hook put the old "running" marker back.
|
|
55
|
+
if (args && !await detached('npx', args, 'update.log')) {
|
|
56
|
+
const fresh = read(STATE, {});
|
|
57
|
+
if (fresh.updatingSince === stateChanges.updatingSince) {
|
|
58
|
+
delete fresh.updatingSince;
|
|
59
|
+
const tmp = `${STATE}.${process.pid}.hook`;
|
|
60
|
+
fs.writeFileSync(tmp, JSON.stringify(fresh, null, 2));
|
|
61
|
+
fs.renameSync(tmp, STATE);
|
|
62
|
+
}
|
|
63
|
+
lines.pop(); // decide appends the update-start notice last when it returns args
|
|
64
|
+
lines.push('[passioncode] could not start the background update; check that npx is on PATH and retry next session.');
|
|
65
|
+
}
|
|
45
66
|
// Claude Code passes the session's directory on stdin; the process cwd is the fallback.
|
|
46
67
|
let cwd = process.cwd();
|
|
47
68
|
try { const input = JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); if (input && typeof input.cwd === 'string') cwd = input.cwd; } catch (_) { /* no stdin */ }
|
|
48
69
|
const rules = rulesLine(cwd);
|
|
49
70
|
if (rules) lines.unshift(rules);
|
|
50
71
|
if (lines.length) process.stdout.write(lines.join('\n') + '\n');
|
|
51
|
-
}
|
|
72
|
+
}
|
|
73
|
+
main().catch(() => { /* never fail a session start */ });
|
|
@@ -47,10 +47,13 @@ function accountName(entry) {
|
|
|
47
47
|
function parseView(stdout, error) {
|
|
48
48
|
let doc = null;
|
|
49
49
|
try { doc = JSON.parse(String(stdout || '').trim()); } catch (_) { /* judged below */ }
|
|
50
|
+
// npm 12 wraps a single-version answer in an array; ambiguous answers stay rejected.
|
|
51
|
+
if (Array.isArray(doc) && doc.length === 1) doc = doc[0];
|
|
50
52
|
if (doc && typeof doc === 'object' && doc.error) {
|
|
51
53
|
if (doc.error.code === 'E404') return { published: false };
|
|
52
54
|
return { checkError: `${doc.error.code || 'npm error'}: ${oneLine(doc.error.summary || '')}`.trim() };
|
|
53
55
|
}
|
|
56
|
+
if (error) return { checkError: oneLine(error.message) };
|
|
54
57
|
if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
|
|
55
58
|
return { checkError: error ? oneLine(error.message) : 'npm answered with something that is not the expected JSON' };
|
|
56
59
|
}
|
|
@@ -117,16 +120,18 @@ function decide(state, trust, now = Date.now()) {
|
|
|
117
120
|
|
|
118
121
|
const latest = typeof state.latest === 'string' && VERSION.test(state.latest) ? state.latest : null;
|
|
119
122
|
if (state.published !== false && latest && state.installed && newer(latest, state.installed)) {
|
|
120
|
-
const maintainers = Array.isArray(state.maintainers) ? state.maintainers
|
|
123
|
+
const maintainers = Array.isArray(state.maintainers) ? state.maintainers : [];
|
|
121
124
|
const trusted = new Set(trust);
|
|
122
125
|
const untrusted = [...maintainers, ...(state.publisher ? [state.publisher] : [])].filter((m) => !trusted.has(m));
|
|
123
|
-
if (!maintainers.length)
|
|
126
|
+
if (!maintainers.length || !maintainers.every((m) => typeof m === 'string' && accountName(m) === m)
|
|
127
|
+
|| typeof state.publisher !== 'string' || !state.publisher || accountName(state.publisher) !== state.publisher) {
|
|
124
128
|
lines.push(`[passioncode] ${PACKAGE}@${latest} is on npm but who published it could not be read — not installing; see SECURITY.md.`);
|
|
125
129
|
} else if (untrusted.length) {
|
|
126
130
|
lines.push(`[passioncode] ${PACKAGE}@${latest} is on npm but published by ${[...new Set(untrusted)].join(', ')}, not a trusted PassionCode publisher — not installing; see SECURITY.md.`);
|
|
127
131
|
} else {
|
|
128
132
|
const auto = (state.config && state.config.auto) !== false;
|
|
129
|
-
const
|
|
133
|
+
const runningAge = now - Date.parse(state.updatingSince);
|
|
134
|
+
const running = Number.isFinite(runningAge) && runningAge >= 0 && runningAge < 10 * 60 * 1000;
|
|
130
135
|
if (auto && !running) {
|
|
131
136
|
spawn = ['--yes', `${PACKAGE}@${latest}`, 'update', '--quiet'];
|
|
132
137
|
stateChanges.updatingSince = new Date(now).toISOString();
|
|
@@ -117,7 +117,7 @@ Also, before the first edit:
|
|
|
117
117
|
- **Ids.** In a repository with `.claude/agent-sync.json`, reserve an id before writing
|
|
118
118
|
it: `agent_sync.py reserve <REGISTER>` (for example `ADR` in fabric). Reading the
|
|
119
119
|
"next free id" line is not reserving it — two agents read the same number.
|
|
120
|
-
- **Leases.**
|
|
120
|
+
- **Leases.** Every enrolled repository guards its declared shared files (its
|
|
121
121
|
`docs/AGENT_SYNC.md` lists them) under a git lease, a ref under
|
|
122
122
|
`refs/agent-sync/leases/` on `origin`, so both machines see it. `acquire` → edit →
|
|
123
123
|
`release`, on every path, failure included.
|
|
@@ -128,6 +128,17 @@ Also, before the first edit:
|
|
|
128
128
|
agent-sync missing: `npx @ssheleg/agent-sync install`. If it cannot be installed, do
|
|
129
129
|
not edit a guarded file — say which file and why you stopped.
|
|
130
130
|
|
|
131
|
+
### The common backlog
|
|
132
|
+
|
|
133
|
+
The workspace's `knowledge/backlog.md` owns the aggregation contract. Each repository's
|
|
134
|
+
`docs/backlog-sources.json` declares the canonical local task sources and their vision goals.
|
|
135
|
+
Read those sources before choosing work. Update status only in the owning source under a lease,
|
|
136
|
+
keep stable IDs and closure receipts, and add a new source to the manifest in the same change.
|
|
137
|
+
A cross-repository task has one owner; other repositories link to it instead of copying status.
|
|
138
|
+
The common view at https://wiki.passioncode.ai/backlog is generated from these sources and their
|
|
139
|
+
commit identities. Never edit generated task status. Land the local change, publish through
|
|
140
|
+
Fabric's workspace sync, and check the published source commit before calling it current.
|
|
141
|
+
|
|
131
142
|
## 5. Secrets
|
|
132
143
|
|
|
133
144
|
- Never in Git, a chat message, an issue, a command argument, a URL query string or a
|
|
@@ -202,8 +213,8 @@ to a public surface.
|
|
|
202
213
|
`project-observatory full update --check` and `--apply`.
|
|
203
214
|
- **Launcher** (`passioncode`): members are tagged first, `family.json` refs point at
|
|
204
215
|
those tags, the payload is vendored from tags only.
|
|
205
|
-
- **Contract pins.** `fabric-agent-contract`
|
|
206
|
-
the
|
|
216
|
+
- **Contract pins.** `fabric-agent-contract` consumers carry explicit commit pins;
|
|
217
|
+
compatibility work is tracked in the owning consumer backlog and the derived workspace view. Move a pin only in a release of the
|
|
207
218
|
consumer, and only when its own validation passes against the new commit.
|
|
208
219
|
|
|
209
220
|
Read [releasing](references/releasing.md) for the step-by-step of each repository's
|
|
@@ -58,7 +58,7 @@ The README section "Validate this repository" is the gate:
|
|
|
58
58
|
```bash
|
|
59
59
|
python3 -m unittest discover -s test -v
|
|
60
60
|
python3 test/validate.py
|
|
61
|
-
node --test 'test/*.test.
|
|
61
|
+
node --test 'test/*.test.mjs'
|
|
62
62
|
claude plugin validate ./plugins/fabric-agent-adapter --strict
|
|
63
63
|
claude plugin validate . --strict
|
|
64
64
|
```
|
|
@@ -78,16 +78,17 @@ manifests. Trigger and scenario evals change before the instructional prose they
|
|
|
78
78
|
|
|
79
79
|
## Contract pins
|
|
80
80
|
|
|
81
|
-
-
|
|
82
|
-
|
|
83
|
-
|
|
81
|
+
- Consumers declare their own explicit contract pins. Read the actual lock or
|
|
82
|
+
submodule commit, then the owning consumer backlog and shared workspace view;
|
|
83
|
+
do not infer compatibility from matching version labels.
|
|
84
84
|
- Moving a pin is a compatibility change of the **consumer**: run the consumer's own
|
|
85
85
|
validation against the new contract commit (for the adapter:
|
|
86
86
|
`adapt_project.py check <bundle> --contract <checkout at the new commit>` for each
|
|
87
87
|
profile), review what changed between the two commits, and ship it as a new consumer
|
|
88
88
|
release. If the validation does not pass, leave the pin and report why.
|
|
89
|
-
- `project-observatory-contract`
|
|
90
|
-
|
|
89
|
+
- `project-observatory-contract` preserves historical immutable `rev-N` tags. Current
|
|
90
|
+
Project Observatory schemas ship with the engine release and its per-file release pins;
|
|
91
|
+
the engine publication checker refuses the retired `--stage` and `--publish` operations.
|
|
91
92
|
|
|
92
93
|
## After a release
|
|
93
94
|
|