@passioncode-ai/passioncode 0.2.1 → 0.2.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.
Files changed (28) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +35 -0
  3. package/family.json +1 -1
  4. package/package.json +1 -1
  5. package/payload/.claude-plugin/marketplace.json +2 -2
  6. package/payload/manifest.json +8 -8
  7. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -5
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +58 -16
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +144 -11
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -3
  22. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  23. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  24. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +24 -3
  25. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
  26. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
  27. package/payload/plugins/passioncode/hooks/repo-rules.js +9 -4
  28. package/payload/plugins/passioncode/skills/working-in-passioncode/SKILL.md +9 -0
@@ -39,11 +39,20 @@ export const RESPOND_TOOLS = Object.freeze(new Set(['com.status', 'com.list', 'c
39
39
  export const REFUSAL_CODES = Object.freeze(new Set(['invalid_arguments', 'body_too_large', 'not_authorized', 'authority_changed',
40
40
  'unsupported_capability', 'unsupported_operation', 'not_available', 'idempotency_conflict', 'idempotency_window_expired',
41
41
  'capacity_exceeded', 'digest_conflict', 'revision_conflict', 'fenced', 'lease_expired', 'invalid_transition', 'reconcile_required',
42
- 'observation_required', 'cursor_reset_required', 'immutable_participants']));
42
+ 'observation_required', 'cursor_reset_required', 'immutable_participants', 'held']));
43
43
  export const STOP_CODES = Object.freeze(new Set(['fenced', 'lease_expired', 'authority_changed']));
44
44
  const REQUEST_STATES = ['queued', 'claimed', 'accepted', 'in_progress', 'completed', 'failed_known', 'cancelled', 'expired', 'outcome_unknown'];
45
45
  const EFFECT_STATES = ['not_started', 'started', 'succeeded_observed', 'failed_observed', 'unknown'];
46
46
  const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
47
+ /**
48
+ * DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
49
+ * `held`: it settles, reports on or keeps alive started work, cancels or reads. The contract's HOLD_SCOPES.
50
+ */
51
+ export const HOLD_SCOPES = Object.freeze({ 'com.submit': ['thread'], 'com.reply': ['thread'], 'com.enroll': ['project', 'slot'],
52
+ 'com.responder_replace': ['project', 'slot'], 'com.claim': ['thread', 'project', 'slot'], 'com.accept': ['thread', 'project', 'slot'],
53
+ 'com.effect_begin': ['thread', 'project', 'slot'] });
54
+ /** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
55
+ export const HOLD_RETRY_MAX_SECONDS = 900;
47
56
  const OPAQUE = /^[A-Za-z0-9_-]{8,128}$/;
48
57
  const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
49
58
  const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/;
@@ -138,11 +147,16 @@ export function receiptProblems(value, op) {
138
147
  if ('request' in value) {
139
148
  const r = value.request;
140
149
  const keys = ['id', 'state', 'effect', 'revision'];
141
- if (closed(r, '/request', keys, keys, problems)) {
150
+ if (closed(r, '/request', keys, [...keys, 'settledBy'], problems)) {
142
151
  if (!str(r.id, OPAQUE)) problems.push('/request/id is a board id');
143
152
  if (!REQUEST_STATES.includes(r.state)) problems.push('/request/state is a request state');
144
153
  if (!EFFECT_STATES.includes(r.effect)) problems.push('/request/effect is an effect state');
145
154
  if (!isInt(r.revision, 1)) problems.push('/request/revision is an integer ≥ 1');
155
+ if ('settledBy' in r) {
156
+ // DEC-0081: present only when the estate owner settled it; absent, the responder did.
157
+ if (r.settledBy !== 'owner') problems.push('/request/settledBy is owner');
158
+ if (!['completed', 'failed_known'].includes(r.state)) problems.push('/request/settledBy names a settled request (completed or failed_known)');
159
+ }
146
160
  }
147
161
  }
148
162
  if ('slot' in value) {
@@ -187,6 +201,7 @@ export function refusalProblems(value) {
187
201
  if (!REFUSAL_CODES.has(e.code)) problems.push('/error/code is a refusal code the contract names');
188
202
  if (typeof e.message !== 'string' || e.message.length < 1 || e.message.length > 300) problems.push('/error/message is 1 to 300 characters');
189
203
  if (typeof e.retryable !== 'boolean') problems.push('/error/retryable is a boolean');
204
+ else if (e.code === 'held' && e.retryable !== true) problems.push('/error/retryable is true for held: the operator lifts a hold (DEC-0079)');
190
205
  }
191
206
  }
192
207
  return problems;
@@ -236,14 +251,40 @@ export function directoryProblems(page) {
236
251
  }
237
252
  project.capabilities.forEach((entry, c) => {
238
253
  const cat = `${at}/capabilities/${c}`;
239
- if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder'], problems)) return;
254
+ if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder', 'held'], problems)) return;
240
255
  if (!str(entry.capability, CAPABILITY)) problems.push(`${cat}/capability is a capability name`);
241
256
  if (!['active', 'stale', 'absent'].includes(entry.responder)) problems.push(`${cat}/responder is active, stale or absent`);
257
+ if ('held' in entry && typeof entry.held !== 'boolean') problems.push(`${cat}/held is a boolean (DEC-0079)`);
242
258
  });
243
259
  });
244
260
  return [...problems, ...directoryFindings(page).map((f) => `${f.instancePath} ${f.message}`)];
245
261
  }
246
262
 
263
+ /**
264
+ * DEC-0079, the contract's holdRefuses: true when a board refuses `tool` on `target` with `held` under `holds`. `target`
265
+ * is what the board resolved ({thread, project, capability, fenced, repeat}); `holds` is {threads, projects, slots:
266
+ * [{project, capability}]}. Ids compare as written; an inherited or mistyped member counts as absent; never throws.
267
+ */
268
+ export function holdRefuses(tool, target, holds) {
269
+ const own = (value, key) => (isObj(value) && Object.hasOwn(value, key) ? value[key] : undefined);
270
+ const scopes = typeof tool === 'string' && Object.hasOwn(HOLD_SCOPES, tool) ? HOLD_SCOPES[tool] : undefined;
271
+ if (!scopes || !isObj(target)) return false;
272
+ if (own(target, 'repeat') === true) return false;
273
+ if (tool === 'com.reply' && own(target, 'fenced') === true) return false;
274
+ const text = (v) => (typeof v === 'string' ? v : undefined);
275
+ const strings = (v) => new Set(Array.isArray(v) ? v.filter((item) => typeof item === 'string') : []);
276
+ const thread = text(own(target, 'thread'));
277
+ const project = text(own(target, 'project'));
278
+ const capability = text(own(target, 'capability'));
279
+ if (scopes.includes('thread') && thread !== undefined && strings(own(holds, 'threads')).has(thread)) return true;
280
+ if (scopes.includes('project') && project !== undefined && strings(own(holds, 'projects')).has(project)) return true;
281
+ const slots = own(holds, 'slots');
282
+ if (scopes.includes('slot') && project !== undefined && capability !== undefined && Array.isArray(slots)) {
283
+ return slots.some((slot) => own(slot, 'project') === project && own(slot, 'capability') === capability);
284
+ }
285
+ return false;
286
+ }
287
+
247
288
  /** Look a Project up in one com.directory page a Project session read; the page is checked first (FAC-SEM-107). */
248
289
  export function findProject(page, projectId, capability) {
249
290
  const problems = directoryProblems(page);
@@ -493,6 +534,9 @@ export class Responder {
493
534
  this.said = {};
494
535
  this.rr = 0;
495
536
  this.reconcileAt = 0;
537
+ /** The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS. */
538
+ this.holdDelay = POLL_SECONDS;
539
+ this.onHold = new Set();
496
540
  }
497
541
 
498
542
  // -- events --
@@ -568,6 +612,10 @@ export class Responder {
568
612
  try {
569
613
  receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
570
614
  } catch (e) {
615
+ if (e.code === 'held') {
616
+ this.pause(capability);
617
+ return;
618
+ }
571
619
  if (e.code === 'fenced') {
572
620
  this.slots[capability] = { state: 'contested', renewedAt: this.now() };
573
621
  this.emit('board.slot.contested', 'warn', `Another responder holds the ${capability} slot on this Project; this service waits for it to lapse and never takes it over by itself.`, { notify: true, episode: capability, capability });
@@ -577,6 +625,27 @@ export class Responder {
577
625
  }
578
626
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
579
627
  delete this.said['board.slot.contested'];
628
+ this.lifted(capability);
629
+ }
630
+
631
+ /**
632
+ * The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and never act
633
+ * around the hold. Accepted work in progress continues; only new work waits.
634
+ */
635
+ pause(capability) {
636
+ const slot = this.slots[capability] ?? {};
637
+ const pauses = slot.state === 'paused' ? (slot.pauses ?? 0) + 1 : 1;
638
+ const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (pauses - 1));
639
+ this.slots[capability] = { state: 'paused', pauses, retryAt: this.now() + wait, renewedAt: this.now() };
640
+ if (!this.onHold.has(capability)) { // once per hold, per capability
641
+ this.onHold.add(capability);
642
+ this.emit('board.slot.held', 'info', `The operator holds new work for ${capability} on this Project; the service waits and asks again later. Work already accepted continues.`, { capability });
643
+ }
644
+ }
645
+
646
+ lifted(capability) {
647
+ if (!this.onHold.delete(capability)) return;
648
+ this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
580
649
  }
581
650
 
582
651
  // -- restart: reconcile and transfer --
@@ -607,9 +676,17 @@ export class Responder {
607
676
  else if (state === 'accepted') (transfer[entry.capability] ??= []).push(entry);
608
677
  else reconcile.push(entry);
609
678
  }
610
- const generations = Object.fromEntries((status.responders ?? []).filter(isObj).map((r) => [r.capability, r.generation ?? 0]));
679
+ const responders = (status.responders ?? []).filter(isObj);
680
+ const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
681
+ const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
611
682
  for (const capability of capabilities) {
612
- if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
683
+ if (onHold.has(capability)) {
684
+ for (const entry of transfer[capability] ?? []) {
685
+ entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
686
+ this.checkpoint.put(entry);
687
+ }
688
+ this.pause(capability);
689
+ } else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
613
690
  else await this.enroll(capability);
614
691
  }
615
692
  for (const entry of reconcile) if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
@@ -618,8 +695,20 @@ export class Responder {
618
695
  async replace(capability, entries, generation) {
619
696
  const args = { idempotency: this.idem(this.key('replace')), capability, expectedGeneration: isInt(generation, 0) ? generation : 0,
620
697
  transfer: { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } } };
621
- const receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
698
+ let receipt;
699
+ try {
700
+ receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
701
+ } catch (e) {
702
+ if (e.code !== 'held') throw e;
703
+ for (const entry of entries) {
704
+ entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
705
+ this.checkpoint.put(entry);
706
+ }
707
+ this.pause(capability);
708
+ return;
709
+ }
622
710
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
711
+ this.lifted(capability);
623
712
  const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
624
713
  for (const entry of entries) {
625
714
  const item = moved.get(entry.request);
@@ -697,6 +786,9 @@ export class Responder {
697
786
  this.state = 'not-authorized';
698
787
  await this.dropSession();
699
788
  this.emit('board.access.needed', 'warn', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Agents & access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
789
+ } else if (code === 'held') {
790
+ this.state = 'paused';
791
+ this.emit('board.held', 'info', 'The operator holds new work on this Project; the responder waits and asks again with a growing pause.', { episode: 'held', reason: code });
700
792
  } else if (['no-epoch', 'schema-unsupported', 'checkpoint', 'receipt-shape'].includes(code)) {
701
793
  this.state = 'down';
702
794
  await this.dropSession();
@@ -736,6 +828,11 @@ export class Responder {
736
828
  }
737
829
  const heldByWork = work && this.current ? work.entry.capability : null;
738
830
  for (const [capability, slot] of Object.entries(this.slots)) {
831
+ if (slot.state === 'paused') {
832
+ // still held: a longer pause; lifted: the slot is ours again
833
+ if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
834
+ continue;
835
+ }
739
836
  if (capability !== heldByWork && this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.enroll(capability);
740
837
  }
741
838
  if (!this.current && !this.stopping) await this.continueLapsed();
@@ -790,6 +887,10 @@ export class Responder {
790
887
  this.slots[capability] = { state: 'contested', renewedAt: this.now() };
791
888
  continue;
792
889
  }
890
+ if (e.code === 'held') {
891
+ this.pause(capability);
892
+ continue;
893
+ }
793
894
  throw e;
794
895
  }
795
896
  if (!('request' in answer)) continue; // nothing claimable; the same key polls again
@@ -843,7 +944,17 @@ export class Responder {
843
944
  entry.keys.accept ??= this.key('accept');
844
945
  entry.step = 'accepting';
845
946
  this.checkpoint.put(entry);
846
- const receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
947
+ let receipt;
948
+ try {
949
+ receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
950
+ } catch (e) {
951
+ if (e.code !== 'held') throw e;
952
+ // A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request waits.
953
+ this.checkpoint.drop(entry.request);
954
+ this.emit('board.request.waiting', 'info', 'A claimed request was not accepted: the operator holds it. Nothing was done; it waits on the board.', { request: entry.request, capability: entry.capability });
955
+ this.pause(entry.capability);
956
+ return false;
957
+ }
847
958
  if (!receipt) return false; // the claim was lost before it was accepted; nothing was done
848
959
  entry.revision = receipt.request.revision;
849
960
  entry.step = 'accepted';
@@ -916,20 +1027,42 @@ export class Responder {
916
1027
  if (!(await this.fenced('com.progress', entry, args))) throw new Stopped();
917
1028
  }
918
1029
 
919
- async effectBegin(entry, summary) {
1030
+ async effectBegin(entry, summary, ctx) {
920
1031
  entry.keys.effect_begin ??= this.key('effect');
921
1032
  entry.step = 'effect-beginning';
922
1033
  entry.effect = true; // from here a lost attempt is an unknown outcome, never "nothing happened"
923
1034
  this.checkpoint.put(entry);
924
1035
  const args = { fence: this.fence(entry, entry.keys.effect_begin), ...(summary ? { summary: clean(summary) } : {}) };
925
1036
  let receipt = null;
926
- for (let attempt = 0; attempt < 3; attempt += 1) {
1037
+ let lost = 0;
1038
+ let delay = this.holdDelay;
1039
+ for (;;) { // a lost answer: the same key again tells whether it was stored
927
1040
  try {
928
1041
  receipt = await this.fenced('com.effect_begin', entry, args);
929
1042
  break;
930
1043
  } catch (e) {
931
- if (e.code !== 'unreachable' || attempt === 2) throw new Stopped();
932
- await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 1000));
1044
+ if (e.code === 'held') {
1045
+ // The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick renews it;
1046
+ // com.renew is never held), and ask again with the same key.
1047
+ entry.effect = false;
1048
+ entry.step = 'effect-held';
1049
+ this.checkpoint.put(entry);
1050
+ this.emit('board.request.waiting', 'info', 'The operator holds this request before its effect: the service waits and does nothing outside it until the hold is lifted.', { episode: entry.request, request: entry.request, capability: entry.capability });
1051
+ const until = Date.now() + delay * 1000;
1052
+ while (Date.now() < until) {
1053
+ if (ctx?.stopped) throw new Stopped();
1054
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, until - Date.now()))));
1055
+ }
1056
+ if (ctx?.stopped) throw new Stopped();
1057
+ delay = Math.min(HOLD_RETRY_MAX_SECONDS, delay * 2);
1058
+ entry.effect = true;
1059
+ entry.step = 'effect-beginning';
1060
+ this.checkpoint.put(entry);
1061
+ continue;
1062
+ }
1063
+ if (e.code !== 'unreachable' || lost === 2) throw new Stopped();
1064
+ lost += 1;
1065
+ await new Promise((resolve) => setTimeout(resolve, lost * 1000));
933
1066
  }
934
1067
  }
935
1068
  if (!receipt) throw new Stopped();