@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.
Files changed (26) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +22 -6
  3. package/SECURITY.md +24 -8
  4. package/bin/passioncode.js +32 -5
  5. package/family.json +1 -1
  6. package/lib/launcher.js +143 -39
  7. package/package.json +4 -4
  8. package/payload/.claude-plugin/marketplace.json +2 -2
  9. package/payload/manifest.json +8 -8
  10. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  11. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -1
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +92 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +86 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -3
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +92 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -2
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +86 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +13 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +92 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +86 -0
  21. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
  22. package/payload/plugins/passioncode/hooks/repo-rules.js +1 -1
  23. package/payload/plugins/passioncode/hooks/session-start.js +34 -12
  24. package/payload/plugins/passioncode/hooks/update-check.js +8 -3
  25. package/payload/plugins/passioncode/skills/working-in-passioncode/SKILL.md +14 -3
  26. 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.14",
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) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return 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
- try {
22
- fs.mkdirSync(path.join(DIR, 'logs'), { recursive: true });
23
- const out = fs.openSync(path.join(DIR, 'logs', logName), 'a');
24
- spawn(cmd, args, { detached: true, stdio: ['ignore', out, out], env: process.env }).unref();
25
- return true;
26
- } catch (_) { return false; /* a missing npm must not break the session */ }
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
- try {
37
+ async function main() {
30
38
  const state = read(STATE, {});
31
39
  const now = Date.now();
32
- if (!state.checkedAt || !(now - Date.parse(state.checkedAt) <= DAY)) {
33
- detached(process.execPath, [path.join(__dirname, 'probe.js')], 'probe.log');
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
- } catch (_) { /* never fail a session start */ }
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.filter((m) => typeof m === 'string') : [];
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 running = state.updatingSince && now - Date.parse(state.updatingSince) < 10 * 60 * 1000;
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.** `fabric` and `fabric-agent-contract` guard shared files (their
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` has no tags, so consumers pin commits, and
206
- the pins disagree (org-index `BACKLOG.md` X-4). Move a pin only in a release of 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.js'
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
- - `fabric-agent-contract` publishes no tags; consumers (fabric, the adapter, the
82
- Observatory) pin a commit. The pins disagree today — org-index `BACKLOG.md` X-4 tracks
83
- it.
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` publishes immutable `rev-N` tags; a schema `$id` change
90
- needs a new `rev-N`.
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