@infinisys/is-fabric-team 0.1.0 → 0.2.0

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/lib/team-core.js CHANGED
@@ -20,18 +20,38 @@ import {
20
20
  } from './team-model.js';
21
21
  import { assertStageProof, parseCriteria, problemStatus } from './problem-gate.js';
22
22
  import { TeamError } from './team-error.js';
23
+ import { optionalBounded, requireBounded, requireSeeded } from './ledger-guards.js';
23
24
  import {
24
25
  assertReopenAllowed,
25
- cycleInFlight,
26
26
  expireCycleAttentions,
27
27
  expireCycleReviews,
28
28
  priorAttempts,
29
- promoteQueued,
30
29
  } from './cycle-close.js';
31
30
  import { boundedDecision, boundedProblem, stageName, tally, watchdogOf } from './status-view.js';
31
+ import {
32
+ backfillLanes,
33
+ bindLane,
34
+ dropFromMergeQueue,
35
+ enqueueMerge,
36
+ focusLane,
37
+ focusOldestRunning,
38
+ focusedLane,
39
+ laneFor,
40
+ laneInFlight,
41
+ laneIsOpen,
42
+ laneStage,
43
+ laneSummaries,
44
+ mergeQueueRows,
45
+ openLanes,
46
+ requirementIn,
47
+ } from './lanes.js';
32
48
 
33
49
  export { tally };
34
50
 
51
+ export { bindLane, focusLane, focusedLane, laneFor, laneInFlight, laneIsOpen, laneStage, laneSummaries, openLanes };
52
+
53
+ export { requireBounded, requireSeeded };
54
+
35
55
  export { TeamError };
36
56
 
37
57
  /** Role ids that are always seeded, in roster order. */
@@ -83,31 +103,6 @@ function bounded(value, field, max) {
83
103
  return text;
84
104
  }
85
105
 
86
- /**
87
- * Trim an optional string and enforce its bound.
88
- *
89
- * @param {unknown} value - candidate text.
90
- * @param {string} field - field name for the error.
91
- * @param {number} max - maximum accepted characters.
92
- * @returns {string | undefined} trimmed text or undefined.
93
- */
94
- function optionalBounded(value, field, max) {
95
- if (value === undefined || value === null) return undefined;
96
- if (typeof value !== 'string') {
97
- throw new TeamError(`"${field}" must be a string`, 'invalid-arguments', { status: 400 });
98
- }
99
- const text = value.trim();
100
- if (text === '') return undefined;
101
- if (text.length > max) {
102
- throw new TeamError(
103
- `"${field}" exceeds ${max} characters`,
104
- 'invalid-arguments',
105
- { status: 400 },
106
- );
107
- }
108
- return text;
109
- }
110
-
111
106
  /**
112
107
  * Create a fresh, unseeded ledger.
113
108
  *
@@ -141,6 +136,7 @@ export function createTeamState(now = new Date().toISOString()) {
141
136
  workflow: [],
142
137
  reviews: [],
143
138
  evidence: [],
139
+ mergeQueue: [],
144
140
  counters: {
145
141
  identity: 0,
146
142
  requirement: 0,
@@ -153,22 +149,6 @@ export function createTeamState(now = new Date().toISOString()) {
153
149
  };
154
150
  }
155
151
 
156
- /**
157
- * Trim a required string and enforce its bound.
158
- *
159
- * @param {unknown} value - candidate text.
160
- * @param {string} field - field name for the error.
161
- * @param {number} max - maximum accepted characters.
162
- * @returns {string} trimmed text.
163
- */
164
- function requireBounded(value, field, max) {
165
- const text = optionalBounded(value, field, max);
166
- if (text === undefined) {
167
- throw new TeamError(`"${field}" is required`, 'invalid-arguments', { status: 400 });
168
- }
169
- return text;
170
- }
171
-
172
152
  /**
173
153
  * Format the next sequence id for one record kind.
174
154
  *
@@ -374,15 +354,6 @@ export function retireRole(state, { roleId, by, now, reason }) {
374
354
  * @param {object} state - ledger state.
375
355
  * @returns {void}
376
356
  */
377
- export function requireSeeded(state) {
378
- if (state.seeded !== true) {
379
- throw new TeamError(
380
- 'The team is not seeded. The Lead must call infinity_team action "bootstrap" with the GitFox project first.',
381
- 'not-seeded',
382
- );
383
- }
384
- }
385
-
386
357
  /**
387
358
  * Resolve the long-lived identity owned by one Agent member name.
388
359
  *
@@ -685,12 +656,16 @@ export function retireIdentity(state, { employeeId, by, now, reason }) {
685
656
  return identity;
686
657
  }
687
658
 
659
+
688
660
  /**
689
- * Open one tracked requirement.
661
+ * Open one tracked requirement as its own lane.
690
662
  *
691
- * The requirement is always recorded: when a cycle is in flight it is queued
692
- * instead of taking the stage (see `promoteQueued`), because refusing intake
693
- * lost the ask. One-cycle-at-a-time is about what runs, not what is written.
663
+ * Every requirement owns a stage pointer, so intake no longer waits behind a
664
+ * running cycle: opening work beside running work is what makes the team able
665
+ * to use more than one pair of hands. What used to be the cycle's exclusivity
666
+ * lives where it belongs — in the vote (one approved decision per lane) and in
667
+ * the merge queue, which keeps `master` buildable while lanes land one at a
668
+ * time.
694
669
  *
695
670
  * @param {object} state - ledger state.
696
671
  * @param {object} input - title, request, acceptance, checks, tracking, member, roles, now.
@@ -719,43 +694,26 @@ export function recordRequirement(state, input) {
719
694
  acceptance,
720
695
  criteria,
721
696
  ...(boundaries === undefined ? {} : { boundaries }),
697
+ status: 'open',
698
+ stage: 'intake',
699
+ decisionId: null,
722
700
  tracking: input.tracking,
723
701
  openedBy: input.member,
724
702
  createdAt: input.now,
725
703
  ...(reopens === 0 ? {} : { reopens, reopenReason }),
726
704
  };
727
705
  state.requirements.push(requirement);
728
- // A queued requirement is not active: the cycle that owns the stage keeps it
729
- // until it ends (abandon or retro), and an older queued ask keeps its place.
730
- const older = (state.requirements ?? []).find(
731
- (candidate) => candidate.status === 'queued' && candidate.id !== requirement.id,
732
- );
733
- if (cycleInFlight(state) || older !== undefined) {
734
- requirement.status = 'queued';
735
- requirement.queuedAt = input.now;
736
- state.updatedAt = input.now;
737
- state.workflow.push({
738
- stage: state.stage,
739
- at: input.now,
740
- by: input.member,
741
- note: `${requirement.id} queued behind ${older?.id ?? state.activeRequirementId}; it takes the stage when the cycle ends`,
742
- });
743
- promoteQueued(state, { at: input.now, by: input.member });
744
- return requirement;
745
- }
746
- // A new requirement after the final stage starts the next cycle instead of
747
- // leaving the ledger stuck at "retro" forever.
748
- if (state.stage === 'retro') {
749
- state.stage = 'intake';
750
- state.workflow.push({
751
- stage: 'intake',
752
- at: input.now,
753
- by: input.member,
754
- note: `New cycle opened by ${requirement.id}`,
755
- });
756
- }
757
- state.activeRequirementId = requirement.id;
758
- state.activeDecisionId = null;
706
+ // A lane takes the focus only when nothing else is running: opening work
707
+ // must not move the reported state out from under the seat reading it.
708
+ const focused = focusedLane(state);
709
+ if (focused === undefined || !laneInFlight(state, focused)) focusLane(state, requirement.id);
710
+ state.workflow.push({
711
+ stage: 'intake',
712
+ at: input.now,
713
+ by: input.member,
714
+ requirementId: requirement.id,
715
+ note: `${requirement.id} opened as a lane at intake`,
716
+ });
759
717
  state.updatedAt = input.now;
760
718
  return requirement;
761
719
  }
@@ -786,39 +744,44 @@ export function abandonCycle(state, input) {
786
744
  { status: 403 },
787
745
  );
788
746
  }
789
- const requirement = (state.requirements ?? []).find(
790
- (candidate) => candidate.id === state.activeRequirementId,
791
- );
747
+ const wanted = input.requirementId ?? state.activeRequirementId;
748
+ const requirement = (state.requirements ?? []).find((candidate) => candidate.id === wanted);
792
749
  if (requirement === undefined) {
793
750
  throw new TeamError(
794
- 'there is no active requirement to close; the ledger is already waiting at intake',
751
+ 'there is no open lane to close; pass "requirement_id" to name the lane, or the ledger has no work in flight',
795
752
  'no-active-requirement',
796
753
  );
797
754
  }
755
+ if (!laneIsOpen(requirement)) {
756
+ throw new TeamError(
757
+ `requirement ${requirement.id} is already ${requirement.status ?? 'closed'}`,
758
+ 'lane-closed',
759
+ { status: 409 },
760
+ );
761
+ }
762
+ const stage = laneStage(state, requirement);
798
763
  const reason = requireBounded(input.reason, 'reason', 2_000);
799
764
  requirement.status = 'abandoned';
800
765
  requirement.abandonedAt = input.now;
801
766
  requirement.abandonedBy = input.member;
802
767
  requirement.abandonReason = reason;
803
- requirement.abandonedStage = state.stage;
804
- requirement.abandonedDecisionId = state.activeDecisionId ?? null;
805
- // Closing the cycle also closes the work that belonged to it. Leaving its
768
+ requirement.abandonedStage = stage;
769
+ requirement.abandonedDecisionId = requirement.decisionId ?? state.activeDecisionId ?? null;
770
+ // Closing the lane also closes the work that belonged to it. Leaving its
806
771
  // pending reviews behind turns an abandoned cycle into a permanent backlog
807
772
  // that re-arms the team goal every round.
808
773
  expireCycleReviews(state, requirement, input.now);
809
774
  expireCycleAttentions(state, requirement, input.now);
810
- state.activeRequirementId = null;
811
- state.activeDecisionId = null;
812
- state.stage = 'intake';
813
775
  state.workflow.push({
814
- stage: 'intake',
776
+ stage,
815
777
  at: input.now,
816
778
  by: input.member,
817
- note: `Cycle closed without completion: ${requirement.id} — ${reason}`,
779
+ requirementId: requirement.id,
780
+ note: `Lane closed without completion: ${requirement.id} — ${reason}`,
818
781
  });
819
- // The stage is free, so whatever waited behind this cycle starts now instead
820
- // of waiting for someone to remember it.
821
- promoteQueued(state, { at: input.now, by: input.member });
782
+ // Only the lane that held the focus moves the reported state; the other
783
+ // lanes keep running, which is the whole point of having them.
784
+ if (state.activeRequirementId === requirement.id) focusOldestRunning(state);
822
785
  state.updatedAt = input.now;
823
786
  return requirement;
824
787
  }
@@ -870,7 +833,7 @@ export function recordMeeting(state, input) {
870
833
  attendees: [...input.attendees],
871
834
  minutes,
872
835
  tracking: input.tracking,
873
- requirementId: state.activeRequirementId ?? null,
836
+ requirementId: input.requirementId ?? state.activeRequirementId ?? null,
874
837
  openedBy: input.member,
875
838
  createdAt: input.now,
876
839
  };
@@ -925,8 +888,11 @@ export function proposeDecision(state, input) {
925
888
  decidedBy: null,
926
889
  };
927
890
  state.decisions.push(decision);
928
- state.activeRequirementId = requirement.id;
929
- state.activeDecisionId = decision.id;
891
+ // The decision belongs to one lane; recording it there is what lets the vote
892
+ // stage read the right proposal when several lanes are in flight.
893
+ requirement.decisionId = decision.id;
894
+ if (focusedLane(state) === undefined) focusLane(state, requirement.id);
895
+ if (state.activeRequirementId === requirement.id) state.activeDecisionId = decision.id;
930
896
  state.updatedAt = input.now;
931
897
  return decision;
932
898
  }
@@ -974,10 +940,12 @@ export function castVote(state, input) {
974
940
  );
975
941
  }
976
942
  // Votes belong to the vote stage: it is where the decision was opened and
977
- // the only stage whose gate reads an open decision. Voting earlier used to
978
- // end with an approved decision outside the vote stage — a cycle that could
979
- // never advance again, then blamed on the process.
980
- if (state.stage !== 'vote') throw new TeamError(`votes are cast in the vote stage; the active requirement is in "${state.stage}" — advance to "vote" first`, 'workflow-order', { status: 409 });
943
+ // the only stage whose gate reads an open decision. The stage is the one the
944
+ // decision's own lane stands at, so a vote cannot be cast on a lane that is
945
+ // still planning while another lane votes.
946
+ const lane = decisionLane(state, decision);
947
+ const stage = laneStage(state, lane);
948
+ if (stage !== 'vote') throw new TeamError(`votes are cast in the vote stage; requirement ${decision.requirementId} is in "${stage}" — advance that lane to "vote" first`, 'workflow-order', { status: 409 });
981
949
  if (!VOTES.includes(input.vote)) {
982
950
  throw new TeamError(`vote must be one of: ${VOTES.join(', ')}`, 'invalid-vote', { status: 400 });
983
951
  }
@@ -1045,10 +1013,12 @@ export function finalizeDecision(state, input) {
1045
1013
  if (decision.status === 'approved') {
1046
1014
  throw new TeamError(`decision ${decision.id} is already approved`, 'decision-approved');
1047
1015
  }
1048
- // A decision takes effect inside the vote stage, where its votes were cast.
1016
+ // A decision takes effect inside the vote stage of the lane that owns it.
1049
1017
  // Finalizing it earlier left an approved decision with the vote stage still
1050
1018
  // ahead: the gate could never open again and the cycle looked stuck.
1051
- if (state.stage !== 'vote') throw new TeamError(`a decision is finalized in the vote stage; the active requirement is in "${state.stage}" — advance to "vote" first`, 'workflow-order', { status: 409 });
1019
+ const lane = decisionLane(state, decision);
1020
+ const stage = laneStage(state, lane);
1021
+ if (stage !== 'vote') throw new TeamError(`a decision is finalized in the vote stage; requirement ${decision.requirementId} is in "${stage}" — advance that lane to "vote" first`, 'workflow-order', { status: 409 });
1052
1022
  assertUnanimous(decision);
1053
1023
  decision.status = 'approved';
1054
1024
  decision.decidedAt = input.now;
@@ -1226,21 +1196,34 @@ export function castReviewVerdict(state, input) {
1226
1196
  }
1227
1197
 
1228
1198
  /**
1229
- * Validate one forward workflow transition and apply it.
1199
+ * Validate one forward workflow transition on one lane and apply it.
1200
+ *
1201
+ * The lane is named by `requirement_id`; without it the focused lane moves,
1202
+ * which keeps a single-lane caller working unchanged. Several lanes may stand
1203
+ * at different stages at once — that is what lets the team run intake, a vote
1204
+ * and an implementation side by side instead of queueing them.
1230
1205
  *
1231
1206
  * @param {object} state - ledger state.
1232
- * @param {{ target: string, note?: string, tracking: object, roles: string[], member: string, now: string }} input - transition.
1207
+ * @param {{ target: string, requirementId?: string, note?: string, tracking: object, roles: string[], member: string, now: string }} input - transition.
1233
1208
  * @returns {object} the appended workflow entry.
1234
1209
  */
1235
1210
  export function advanceWorkflow(state, input) {
1236
1211
  requireSeeded(state);
1237
- const expected = nextStage(state.stage);
1212
+ const requirement = requirementIn(state, input.requirementId);
1213
+ const stage = laneStage(state, requirement);
1214
+ if (!laneIsOpen(requirement)) {
1215
+ throw new TeamError(
1216
+ `requirement ${requirement.id} is ${requirement.status ?? 'closed'}; nothing is left to advance — open a new lane with intake`,
1217
+ 'workflow-complete',
1218
+ );
1219
+ }
1220
+ const expected = nextStage(stage);
1238
1221
  if (expected === undefined) {
1239
- throw new TeamError(`workflow is already at its final stage "${state.stage}"`, 'workflow-complete');
1222
+ throw new TeamError(`requirement ${requirement.id} is already at its final stage "${stage}"`, 'workflow-complete');
1240
1223
  }
1241
1224
  if (input.target !== expected) {
1242
1225
  throw new TeamError(
1243
- `workflow must advance exactly one stage: next is "${expected}", not "${input.target}"`,
1226
+ `workflow must advance exactly one stage: ${requirement.id} stands at "${stage}", so next is "${expected}", not "${input.target}"`,
1244
1227
  'workflow-order',
1245
1228
  );
1246
1229
  }
@@ -1253,22 +1236,46 @@ export function advanceWorkflow(state, input) {
1253
1236
  );
1254
1237
  }
1255
1238
  const note = optionalBounded(input.note, 'note', 4000);
1256
- gate(state, input.target);
1239
+ gate(state, input.target, requirement);
1257
1240
  const entry = {
1258
1241
  stage: input.target,
1259
1242
  at: input.now,
1260
1243
  by: input.member,
1244
+ requirementId: requirement.id,
1261
1245
  ...(note === undefined ? {} : { note }),
1262
1246
  tracking: input.tracking,
1263
1247
  };
1264
1248
  state.workflow.push(entry);
1265
- state.stage = input.target;
1249
+ requirement.stage = input.target;
1250
+ if (input.target === 'delivery') enqueueMerge(state, requirement, input.now);
1251
+ if (input.target === 'retro') {
1252
+ // Retro is the last stage, so reaching it finishes the lane: the branch is
1253
+ // no longer waiting for `master`, and the focus moves to whatever is still
1254
+ // running. A delivered lane keeps its history but stops being work.
1255
+ requirement.status = 'delivered';
1256
+ requirement.deliveredAt = input.now;
1257
+ dropFromMergeQueue(state, requirement.id);
1258
+ if (state.activeRequirementId === requirement.id) focusOldestRunning(state);
1259
+ } else if (state.activeRequirementId === requirement.id) {
1260
+ state.stage = input.target;
1261
+ }
1266
1262
  state.updatedAt = input.now;
1267
1263
  return entry;
1268
1264
  }
1269
1265
 
1270
1266
  /**
1271
- * Enforce one stage gate against the active requirement and decision.
1267
+ * The lane a decision belongs to.
1268
+ *
1269
+ * @param {object} state - ledger state.
1270
+ * @param {object} decision - decision record.
1271
+ * @returns {object | undefined} lane record.
1272
+ */
1273
+ function decisionLane(state, decision) {
1274
+ return (state.requirements ?? []).find((candidate) => candidate.id === decision.requirementId);
1275
+ }
1276
+
1277
+ /**
1278
+ * Enforce one stage gate against one lane's requirement and decision.
1272
1279
  *
1273
1280
  * plan needs a tracked requirement, vote needs a decision for it, implementation
1274
1281
  * needs that decision approved, retro needs minutes. `vote` used to require an
@@ -1281,30 +1288,24 @@ export function advanceWorkflow(state, input) {
1281
1288
  *
1282
1289
  * @param {object} state - ledger state.
1283
1290
  * @param {string} target - target stage.
1291
+ * @param {object} requirement - lane the stage belongs to.
1284
1292
  * @returns {void}
1285
1293
  */
1286
- function gate(state, target) {
1287
- assertStageProof(state, target);
1288
- if (
1289
- target === 'plan' &&
1290
- (state.activeRequirementId === null ||
1291
- !state.requirements.some((requirement) => requirement.id === state.activeRequirementId))
1292
- ) {
1293
- throw new TeamError('stage "plan" requires an active tracked requirement', 'gate-unsatisfied');
1294
- }
1294
+ function gate(state, target, requirement) {
1295
+ assertStageProof(state, target, requirement);
1295
1296
  if (target === 'vote') {
1296
- const decision = activeDecision(state);
1297
- if (decision === undefined) throw new TeamError('stage "vote" requires a decision for the active requirement; the Lead proposes one, then all four voting seats vote on it', 'gate-unsatisfied');
1297
+ const decision = decisionOf(state, requirement);
1298
+ if (decision === undefined) throw new TeamError(`stage "vote" requires a decision for ${requirement.id}; the Lead proposes one, then all four voting seats vote on it`, 'gate-unsatisfied');
1298
1299
  // Approved: nothing is left to vote, so the stage that records the settled
1299
1300
  // direction opens. Rejected with every seat heard: the vote is over and
1300
1301
  // re-entering it would only relitigate the same proposal.
1301
- if (decision.status === 'rejected' && tally(decision).missing === 0) throw new TeamError(`stage "vote" cannot settle ${decision.id}: all four seats have voted to reject it; the Lead proposes a different decision (there is no cap) or the cycle closes with abandon`, 'gate-unsatisfied');
1302
+ if (decision.status === 'rejected' && tally(decision).missing === 0) throw new TeamError(`stage "vote" cannot settle ${decision.id}: all four seats have voted to reject it; the Lead proposes a different decision (there is no cap) or the lane closes with abandon`, 'gate-unsatisfied');
1302
1303
  }
1303
1304
  if (target === 'implementation') {
1304
- const decision = activeDecision(state);
1305
+ const decision = decisionOf(state, requirement);
1305
1306
  if (decision === undefined || decision.status !== 'approved') {
1306
1307
  throw new TeamError(
1307
- 'stage "implementation" requires the active decision to be approved by all four seats',
1308
+ `stage "implementation" requires ${requirement.decisionId ?? 'the lane\'s decision'} to be approved by all four seats`,
1308
1309
  'gate-unsatisfied',
1309
1310
  );
1310
1311
  }
@@ -1314,16 +1315,28 @@ function gate(state, target) {
1314
1315
  !state.meetings.some(
1315
1316
  (meeting) =>
1316
1317
  meeting.kind === 'retro' &&
1317
- (meeting.requirementId ?? null) === (state.activeRequirementId ?? null),
1318
+ (meeting.requirementId ?? null) === requirement.id,
1318
1319
  )
1319
1320
  ) {
1320
1321
  throw new TeamError(
1321
- 'stage "retro" requires a tracked retro meeting for the active requirement',
1322
+ `stage "retro" requires a tracked retro meeting for ${requirement.id}`,
1322
1323
  'gate-unsatisfied',
1323
1324
  );
1324
1325
  }
1325
1326
  }
1326
1327
 
1328
+ /**
1329
+ * The decision one lane is working on.
1330
+ *
1331
+ * @param {object} state - ledger state.
1332
+ * @param {object} requirement - lane record.
1333
+ * @returns {object | undefined} decision record.
1334
+ */
1335
+ export function decisionOf(state, requirement) {
1336
+ if (requirement?.decisionId === null || requirement?.decisionId === undefined) return undefined;
1337
+ return (state.decisions ?? []).find((candidate) => candidate.id === requirement.decisionId);
1338
+ }
1339
+
1327
1340
  /**
1328
1341
  * One seat's public view, including its human-owned model route.
1329
1342
  *
@@ -1364,25 +1377,6 @@ function roleSnapshot(state, role, roleModels) {
1364
1377
  * @param {{ tracking?: object, runtime?: object }} [extra] - connection and runtime facts.
1365
1378
  * @returns {object} public snapshot.
1366
1379
  */
1367
- /**
1368
- * Work that arrived while a cycle was running.
1369
- *
1370
- * Recorded, never refused, and it takes the stage when the cycle ends — the
1371
- * status view lists it so a queued ask is visible instead of looking lost.
1372
- *
1373
- * @param {object} state - ledger state.
1374
- * @returns {{ id: string, title: string, queuedAt: string | null }[]} queued requirements.
1375
- */
1376
- function queuedRequirements(state) {
1377
- return (state.requirements ?? [])
1378
- .filter((candidate) => candidate.status === 'queued')
1379
- .map((candidate) => ({
1380
- id: candidate.id,
1381
- title: candidate.title,
1382
- queuedAt: candidate.queuedAt ?? null,
1383
- }));
1384
- }
1385
-
1386
1380
  export function publicSnapshot(state, extra = {}) {
1387
1381
  const roleModels = state.roleModels ?? {};
1388
1382
  const roles = catalogRoles(state).map((role) => roleSnapshot(state, role, roleModels));
@@ -1401,7 +1395,8 @@ export function publicSnapshot(state, extra = {}) {
1401
1395
  stageName: stageName(state.stage),
1402
1396
  activeRequirementId: state.activeRequirementId ?? null,
1403
1397
  activeDecisionId: state.activeDecisionId ?? null,
1404
- queue: queuedRequirements(state),
1398
+ lanes: laneSummaries(state),
1399
+ mergeQueue: mergeQueueRows(state),
1405
1400
  problem: problemStatus(state),
1406
1401
  evidence: (state.evidence ?? []).slice(-50),
1407
1402
  createdAt: state.createdAt,
@@ -1593,10 +1588,13 @@ export function statusReport(state, options = {}) {
1593
1588
  activeRequirementId: view.activeRequirementId,
1594
1589
  activeDecisionId: view.activeDecisionId,
1595
1590
  activeDecision: current === undefined ? null : boundedDecision(current),
1591
+ lanes: view.lanes,
1592
+ mergeQueue: view.mergeQueue,
1596
1593
  problem: boundedProblem(view.problem),
1597
1594
  counts: {
1598
1595
  identities: state.identities.length,
1599
1596
  requirements: state.requirements.length,
1597
+ lanes: view.lanes.length,
1600
1598
  decisions: state.decisions.length,
1601
1599
  meetings: state.meetings.length,
1602
1600
  reviews: (state.reviews ?? []).length,
@@ -1743,4 +1741,6 @@ function normalizeTeamDocument(value) {
1743
1741
  if (value.activeRequirementId === undefined) value.activeRequirementId = null;
1744
1742
  if (value.activeDecisionId === undefined) value.activeDecisionId = null;
1745
1743
  if (value.watchdog === undefined) value.watchdog = null;
1744
+ backfillLanes(value);
1746
1745
  }
1746
+
package/lib/team-model.js CHANGED
@@ -407,9 +407,9 @@ export const PRINCIPLES = Object.freeze([
407
407
  text: 'Missing hardware, a kernel, a dependency or a remote peer is not a blocker by itself. Cross-compile or JIT for an absent target device, build maintenance-support dependencies from source, and measure a transport on loopback before waiting for a peer. The Host accepts a blocked report once it observes an attempt; before that, the only honest move is to run the attempt. Workaround runs are detected and surfaced by the Host, never passed off as a fix.',
408
408
  }),
409
409
  Object.freeze({
410
- id: 'one-cycle',
411
- title: 'One deliverable, one cycle — fast dev is refused',
412
- text: 'This workflow carries one whole deliverable from intake to retro in a single pass: one requirement, one plan, one vote, one implementation, one acceptance, one delivery. It is not a container for small steps. A second requirement is refused while the open one has not passed all of its criteria, and a smaller requirement cut out of an ask that is already open is refused with it. Fast dev is a trap for an agent, not for a human: an agent is already fast, so a smaller unit buys no time and pays the whole process again. Put the whole ask into one requirement, list every criterion at intake, implement it once, accept it once.',
410
+ id: 'parallel-lanes',
411
+ title: 'Many lanes at once, one master',
412
+ text: 'Several requirements run side by side: each lane carries its own stage, its own decision and its own evidence, so a second ask starts work while the first is still being implemented. Intake still takes the whole ask, every criterion listed at once, because splitting one ask into several lanes pays the vote and the acceptance round once per slice and hides the real work. What stays serial is the merge: deliveries join a queue and land on master one at a time, so master builds at every step. Proof is per lane: a commit names its branch, a command names its worktree, and another lane’s green run never satisfies this one.',
413
413
  }),
414
414
  Object.freeze({
415
415
  id: 'fast-fail',
@@ -523,7 +523,7 @@ You are the Assistant (assistant) of the Infinity Fabric AI Team. You are the se
523
523
  2. Intake and handoff
524
524
  - Extract the user's goal, acceptance criteria, boundaries, non-goals and risks.
525
525
  - Call infinity_team action "intake". It creates the tracked GitFox requirement. If no project is pinned yet, ask the Lead (or call action "configure" as lead) to pin one first; without tracking, no planning or implementation may exist.
526
- - One cycle at a time. Intake carries the whole ask: every criterion the user named, listed at once. A second intake while a requirement is open is recorded and queued behind it (never refused), and takes the stage when that cycle ends, so nothing the user asked for is lost and no case-by-case loop opens. If a check is wrong, use "amend"; if the ask grows, it belongs in the open requirement.
526
+ - One lane per requirement, several lanes at once. Intake carries the whole ask: every criterion the user named, listed at once. A second intake opens its own lane at intake and starts work immediately instead of waiting behind the first; nothing the user asked for is lost and no case-by-case loop opens. Pass "requirement_id" whenever an action should land on a lane other than the focused one. Give each lane its own tree: the worker calls create_worktree for that lane (name it after the requirement id) and reports the branch and path back; you bind it with action "lane", and only you and the assistant can bind, because the binding decides whose evidence a commit is. From then on the worker runs every command inside that directory, so the Host attributes commits and runs to the right lane. If create_worktree is not available in this profile, run one lane at a time in the shared checkout: two workers in one tree cannot be told apart, and the evidence would land on the wrong lane. Delivery queues the lane for merge; master takes one branch at a time. If a check is wrong, use "amend"; if the ask grows, it belongs in the open requirement.
527
527
  - Then send the requirement id to lead with team_send_message. The Lead owns planning and execution.
528
528
 
529
529
  3. Identities and HR
@@ -17,6 +17,7 @@ const ACTIONS = [
17
17
  'configure',
18
18
  'intake',
19
19
  'abandon',
20
+ 'lane',
20
21
  'amend',
21
22
  'meeting',
22
23
  'propose_decision',
@@ -36,7 +37,7 @@ const ACTIONS = [
36
37
 
37
38
  /** Model-facing description of the `infinity_team` tool. */
38
39
  export const TOOL_DESCRIPTION =
39
- 'Infinity Fabric AI Team governance. "status" is a bounded read-only view: it leads with the active decision and the problem block and appends only the tables named in "include"; "roster" is read-only; "configure" pins the GitFox project locally (no issue; normally auto-discovered from the workspace remote); "intake" opens a tracked requirement with acceptance criteria plus one check per criterion (a criterion without a check is an opinion; the gate reads whether the check actually ran, not whether its wording looks like a command), and it carries the whole ask: while a requirement is open a new intake is recorded as queued and takes the stage when that cycle ends, so one cycle still runs at a time, nothing the user asked for is lost, and no case-by-case loop opens; "abandon" closes the active cycle without completion (reason required: the requirement is marked abandoned, its issue is annotated, the stage returns to intake and nothing is reported as done); "amend" swaps the command behind one criterion in place, keeping the criterion, the previous command and every run already recorded, so a check written before the flag existed does not cost a whole cycle; reopening the same request after an abandon needs "reopenReason", and a fourth attempt at the same request is refused; "meeting" records tracked minutes; "propose_decision"/"vote"/"decide" enforce the fixed four-seat unanimity, accept as many decisions for one requirement as it needs (there is no cap: every proposal still costs a fresh vote by all four seats, which is what bounds the loop), refuse "vote" and "decide" outside the vote stage so a decision takes effect where its votes were cast, and keep a rejected decision open until every seat has recorded its vote: a reject decides the outcome at once but never silences the remaining seats; "workflow" advances exactly one stage whose gate is read from the work the Host observed (a real commit, real check runs), never from a ticket or a label; a workspace that stays local is exempt from the commit bars: no stage waits for a commit and a check need not follow one, while the runs remain the evidence; "reviews" returns one newest-first page of the shared-state review queue (per_page 1-100, default 20; pass the returned "next_cursor" as "cursor" to drain it) and "review" records a required seat verdict; "publish" composes the cycle work record from the runs and commits the Host observed and posts it as a comment on the requirement ticket (the model does not write the record). Closing an issue or merging a merge request only needs every check to have been attempted once (a red or old run is reported to the seats, not held against the action), and the cycle ticket also needs its record published plus a docs+qa review before it closes. A process markdown file committed to the repository (evidence, gap reports, notes, retros) is refused: work is recorded on the ticket, the repository records the product. HR-only "define_role" creates a position the charter never had (id, name, mandate and the exact tool surface as a "capabilities" pair), "retire_role" closes one permanently, and "enroll"/"retire"/"spawn_member" manage the local roster. A defined position never holds a voting seat, and assistant/lead/hr can never be defined or retired. Model routing belongs exclusively to the human operator: the AI Team panel sets one team default and an optional pin per seat, and no Agent action can choose or change a model — HR included. Teams are per workspace; setup, charter and identities are local. A workspace that stays local still runs the team but opens GitFox issues/MR only when the user asks.';
40
+ 'Infinity Fabric AI Team governance. "status" is a bounded read-only view: it leads with the active decision and the problem block, always lists the open lanes and the merge queue, and appends only the history tables named in "include"; "roster" is read-only; "configure" pins the GitFox project locally (no issue; normally auto-discovered from the workspace remote); "intake" opens a tracked requirement with acceptance criteria plus one check per criterion (a criterion without a check is an opinion; the gate reads whether the check actually ran, not whether its wording looks like a command), and it opens a lane: every requirement carries its own stage, its own decision and its own evidence, so several asks run side by side instead of waiting in a queue and nothing the user asked for is lost; "abandon" closes one lane without completion (pass "requirement_id", or the focused lane closes; reason required: the requirement is marked abandoned, its issue is annotated, nothing is reported as done, and the other lanes keep running); "lane" reports one lane and, when "branch" and "worktree" are given (assistant or Lead only), records where its work lives so its commits and commands are attributed to it and never to another lane; "amend" swaps the command behind one criterion in place, keeping the criterion, the previous command and every run already recorded, so a check written before the flag existed does not cost a whole cycle; reopening the same request after an abandon needs "reopenReason", and a fourth attempt at the same request is refused; "meeting" records tracked minutes; "propose_decision"/"vote"/"decide" enforce the fixed four-seat unanimity, accept as many decisions for one requirement as it needs (there is no cap: every proposal still costs a fresh vote by all four seats, which is what bounds the loop), refuse "vote" and "decide" outside the vote stage so a decision takes effect where its votes were cast, and keep a rejected decision open until every seat has recorded its vote: a reject decides the outcome at once but never silences the remaining seats; "workflow" advances one lane exactly one stage — pass "requirement_id" to move a lane that is not in focus — and its gate is read from the work the Host observed in that lane (a real commit on that lane’s branch, that lane’s own check runs), never from a ticket or a label; a workspace that stays local is exempt from the commit bars: no stage waits for a commit and a check need not follow one, while the runs remain the evidence; "reviews" returns one newest-first page of the shared-state review queue (per_page 1-100, default 20; pass the returned "next_cursor" as "cursor" to drain it) and "review" records a required seat verdict; "publish" composes the cycle work record from the runs and commits the Host observed and posts it as a comment on the requirement ticket (the model does not write the record). Closing a ticket or merging a merge request only needs that lane’s checks to have been attempted once (a red or old run is reported to the seats, not held against the action), and a lane ticket also needs its record published plus a docs+qa review before it closes. A process markdown file committed to the repository (evidence, gap reports, notes, retros) is refused: work is recorded on the ticket, the repository records the product. HR-only "define_role" creates a position the charter never had (id, name, mandate and the exact tool surface as a "capabilities" pair), "retire_role" closes one permanently, and "enroll"/"retire"/"spawn_member" manage the local roster. A defined position never holds a voting seat, and assistant/lead/hr can never be defined or retired. Model routing belongs exclusively to the human operator: the AI Team panel sets one team default and an optional pin per seat, and no Agent action can choose or change a model — HR included. Teams are per workspace; setup, charter and identities are local. A workspace that stays local still runs the team but opens GitFox issues/MR only when the user asks.';
40
41
 
41
42
  /** JSON-schema parameters of the `infinity_team` tool. */
42
43
  export const TOOL_PARAMETERS = {
@@ -45,7 +46,11 @@ export const TOOL_PARAMETERS = {
45
46
  properties: {
46
47
  action: { type: 'string', enum: ACTIONS, description: 'Team operation.' },
47
48
  project: { type: 'string', description: 'GitFox project in "namespace/name" form for "configure"; omit to keep the auto-discovered binding.' },
48
- requirement_id: { type: 'string', description: 'Tracked requirement id for "propose_decision".' },
49
+ requirement_id: {
50
+ type: 'string',
51
+ description:
52
+ 'Tracked requirement id. Names the lane for "lane", "workflow" and "abandon"; for "propose_decision" it names the requirement the decision is for.',
53
+ },
49
54
  decision_id: { type: 'string', description: 'Decision id for "vote"/"decide".' },
50
55
  title: { type: 'string', description: 'Requirement or decision title.' },
51
56
  request: { type: 'string', description: 'Verbatim user request for "intake".' },
@@ -90,6 +95,17 @@ export const TOOL_PARAMETERS = {
90
95
  description: 'Immediate next workflow stage.',
91
96
  },
92
97
  note: { type: 'string', description: 'Transition note.' },
98
+ branch: { type: 'string', description: 'For "lane": the branch this lane commits to.' },
99
+ mr: {
100
+ type: 'string',
101
+ description:
102
+ 'For "lane": the merge request that will land this lane, as "project#iid". The merge gate then checks this lane, not whichever lane is focused.',
103
+ },
104
+ worktree: {
105
+ type: 'string',
106
+ description:
107
+ 'For "lane": the absolute directory this lane works in (the path "create_worktree" returned). Commands run there are attributed to this lane.',
108
+ },
93
109
  review_id: { type: 'string', description: 'Review id for "review" (RV-0001 style).' },
94
110
  verdict: { type: 'string', enum: REVIEW_VERDICTS, description: 'Required-seat verdict for "review".' },
95
111
  op: {