@passioncode-ai/passioncode 0.2.0 → 0.2.2
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 +18 -0
- package/family.json +1 -1
- package/package.json +1 -1
- 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 +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +25 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +26 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +144 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +182 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +20 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
932
|
-
|
|
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();
|