@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.
@@ -253,14 +253,45 @@ function evidenceOf(state) {
253
253
  return Array.isArray(state?.evidence) ? state.evidence : [];
254
254
  }
255
255
 
256
+ /**
257
+ * The requirement one evidence row belongs to.
258
+ *
259
+ * Attribution is written when the row is observed, not guessed when it is read:
260
+ * a commit knows its branch and a command knows the directory it ran in, and the
261
+ * Host stamps both with the lane it resolved. Reading it back as an id is what
262
+ * keeps two lanes' runs apart — matching by command text alone let one lane's
263
+ * check green a criterion in another.
264
+ *
265
+ * @param {object} row - evidence row.
266
+ * @returns {string | null} requirement id, or null for evidence recorded while
267
+ * no lane was open.
268
+ */
269
+ export function requirementOfEvidence(row) {
270
+ return typeof row?.requirementId === 'string' ? row.requirementId : null;
271
+ }
272
+
273
+ /**
274
+ * The evidence rows that belong to one lane.
275
+ *
276
+ * @param {object} state - ledger state.
277
+ * @param {object | undefined} requirement - lane; defaults to the focused one.
278
+ * @returns {object[]} rows of that lane.
279
+ */
280
+ function scopedRows(state, requirement) {
281
+ const id = requirement?.id ?? state?.activeRequirementId ?? null;
282
+ const rows = evidenceOf(state);
283
+ if (id === null) return rows;
284
+ return rows.filter((row) => requirementOfEvidence(row) === id);
285
+ }
286
+
256
287
  /**
257
288
  * The position of the newest observed commit, or -1 when none exists.
258
289
  *
259
290
  * @param {object} state - ledger state.
260
291
  * @returns {number} evidence array index.
261
292
  */
262
- function newestCommitIndex(state) {
263
- const rows = evidenceOf(state);
293
+ function newestCommitIndex(state, requirement) {
294
+ const rows = scopedRows(state, requirement);
264
295
  for (let index = rows.length - 1; index >= 0; index -= 1) {
265
296
  if (rows[index]?.kind === 'commit') return index;
266
297
  }
@@ -303,10 +334,11 @@ export function runVerdict(command, check) {
303
334
  *
304
335
  * @param {object} state - ledger state.
305
336
  * @param {{ check?: string }} criterion - acceptance criterion.
337
+ * @param {object} [requirement] - lane whose run must count; defaults to the focused one.
306
338
  * @returns {object[]} matching command evidence.
307
339
  */
308
- export function runsOf(state, criterion) {
309
- return runsOfVerdict(state, criterion, 'attributable');
340
+ export function runsOf(state, criterion, requirement) {
341
+ return runsOfVerdict(state, criterion, 'attributable', requirement);
310
342
  }
311
343
 
312
344
  /**
@@ -318,22 +350,24 @@ export function runsOf(state, criterion) {
318
350
  *
319
351
  * @param {object} state - ledger state.
320
352
  * @param {{ check?: string }} criterion - acceptance criterion.
353
+ * @param {object} [requirement] - lane whose run must count; defaults to the focused one.
321
354
  * @returns {object[]} matching command evidence.
322
355
  */
323
- export function maskedRunsOf(state, criterion) {
324
- return runsOfVerdict(state, criterion, 'masked');
356
+ export function maskedRunsOf(state, criterion, requirement) {
357
+ return runsOfVerdict(state, criterion, 'masked', requirement);
325
358
  }
326
359
 
327
360
  /**
328
361
  * @param {object} state - ledger state.
329
362
  * @param {{ check?: string }} criterion - acceptance criterion.
330
363
  * @param {'attributable' | 'masked'} wanted - verdict to keep.
364
+ * @param {object} [requirement] - lane whose runs count.
331
365
  * @returns {object[]} matching command evidence.
332
366
  */
333
- function runsOfVerdict(state, criterion, wanted) {
367
+ function runsOfVerdict(state, criterion, wanted, requirement) {
334
368
  const check = normalizeCommand(criterion?.check);
335
369
  if (check === '') return [];
336
- const rows = evidenceOf(state).filter((row) => row.kind === 'command');
370
+ const rows = scopedRows(state, requirement).filter((row) => row.kind === 'command');
337
371
  const verdicts = commandVerdicts(rows, check);
338
372
  return rows.filter((row, index) => verdicts[index] === wanted);
339
373
  }
@@ -398,12 +432,13 @@ function commandVerdicts(rows, check) {
398
432
  *
399
433
  * @param {object} state - ledger state.
400
434
  * @param {{ check?: string }} criterion - acceptance criterion.
435
+ * @param {object} [requirement] - lane whose evidence decides this criterion.
401
436
  * @returns {{ passed: boolean, reason?: string, exitCode?: number, workaround?: boolean, neverFailed?: boolean, staleAgainst?: CommitFacts | null, masked?: string }} status.
402
437
  */
403
- export function criterionStatus(state, criterion) {
404
- const runs = runsOf(state, criterion);
438
+ export function criterionStatus(state, criterion, requirement) {
439
+ const runs = runsOf(state, criterion, requirement);
405
440
  if (runs.length === 0) {
406
- const masked = maskedRunsOf(state, criterion);
441
+ const masked = maskedRunsOf(state, criterion, requirement);
407
442
  if (masked.length === 0) return { passed: false, reason: 'never-run' };
408
443
  return { passed: false, reason: 'masked-run', masked: String(masked.at(-1).detail ?? '') };
409
444
  }
@@ -413,10 +448,9 @@ export function criterionStatus(state, criterion) {
413
448
  }
414
449
  // Whether a commit landed after this run is a fact about the tree, not about
415
450
  // the check: it travels beside the pass so the reader can judge how old the
416
- // evidence is, and never turns a green run into a refusal.
417
- const rows = evidenceOf(state);
418
- const commitIndex = isLocalOnly(state) ? -1 : newestCommitIndex(state);
419
- const stale = commitIndex !== -1 && commitIndex > rows.indexOf(latest);
451
+ // evidence is, and never turns a green run into a refusal. Both sides are
452
+ // read inside the lane, so another lane's commit never ages this pass.
453
+ const staleAgainst = isLocalOnly(state) ? null : commitAfter(state, requirement, latest);
420
454
  // A failing run says the check exercises the problem — but it does not matter
421
455
  // *when* it ran, and it is not required at all. Requiring it asked the team to
422
456
  // manufacture the order (commit the broken state, run, then commit the fix),
@@ -428,10 +462,31 @@ export function criterionStatus(state, criterion) {
428
462
  passed: true,
429
463
  workaround: latest.workaround === true,
430
464
  neverFailed: !failed,
431
- ...(stale ? { staleAgainst: commitFacts(rows[commitIndex]) } : {}),
465
+ ...(staleAgainst === null ? {} : { staleAgainst }),
432
466
  };
433
467
  }
434
468
 
469
+ /**
470
+ * The newest commit on one lane when it was observed after a given row.
471
+ *
472
+ * Ordering is the order the Host recorded the rows in, not the clock: a commit
473
+ * and the run it followed can carry the same second, and a run recorded before
474
+ * a commit stays older than that commit. Both rows are read inside the lane, so
475
+ * another lane's commit never ages this pass.
476
+ *
477
+ * @param {object} state - ledger state.
478
+ * @param {object} requirement - lane to read.
479
+ * @param {object} row - the run being judged.
480
+ * @returns {CommitFacts | null} the newer commit, or null.
481
+ */
482
+ function commitAfter(state, requirement, row) {
483
+ const index = newestCommitIndex(state, requirement);
484
+ if (index === -1) return null;
485
+ const newestRow = scopedRows(state, requirement)[index];
486
+ const all = evidenceOf(state);
487
+ return all.indexOf(newestRow) > all.indexOf(row) ? commitFacts(newestRow) : null;
488
+ }
489
+
435
490
  /**
436
491
  * Short branch name for one evidence row, or null when the Host did not record
437
492
  * one (evidence written before branches were captured).
@@ -476,9 +531,9 @@ function commitFacts(row) {
476
531
  * @param {object} state - ledger state.
477
532
  * @returns {CommitFacts | null} newest commit, or null when none was observed.
478
533
  */
479
- export function newestCommit(state) {
480
- const index = newestCommitIndex(state);
481
- return index === -1 ? null : commitFacts(evidenceOf(state)[index]);
534
+ export function newestCommit(state, requirement) {
535
+ const index = newestCommitIndex(state, requirement);
536
+ return index === -1 ? null : commitFacts(scopedRows(state, requirement)[index]);
482
537
  }
483
538
 
484
539
  /**
@@ -512,7 +567,7 @@ export function describeCommit(facts) {
512
567
  */
513
568
  export function productDelta(state, requirement) {
514
569
  const since = typeof requirement?.createdAt === 'string' ? requirement.createdAt : '';
515
- const commits = evidenceOf(state).filter(
570
+ const commits = scopedRows(state, requirement).filter(
516
571
  (row) => row?.kind === 'commit' && String(row.at ?? '') >= since,
517
572
  );
518
573
  const mainline = commits.filter((row) => MAINLINE_BRANCHES.includes(branchName(row)));
@@ -541,12 +596,10 @@ export function productDelta(state, requirement) {
541
596
  * question about the instrument.
542
597
  *
543
598
  * @param {object} state - ledger state.
599
+ * @param {object} [requirement] - lane to report; defaults to the focused one.
544
600
  * @returns {{ resolved: boolean, requirementId: string | null, total: number, passed: number, workarounds: number[], neverFailed: number[], missing: object[], delta: ProductDelta, newest: CommitFacts | null, checks: { total: number, ran: number, neverRan: number, masked: number } }} status.
545
601
  */
546
- export function problemStatus(state) {
547
- const requirement = (state.requirements ?? []).find(
548
- (candidate) => candidate.id === state.activeRequirementId,
549
- );
602
+ export function problemStatus(state, requirement = activeRequirement(state)) {
550
603
  if (requirement === undefined) {
551
604
  return {
552
605
  resolved: false,
@@ -569,16 +622,18 @@ export function problemStatus(state) {
569
622
  }
570
623
  const rows = criteriaOf(requirement).map((criterion) => ({
571
624
  ...criterion,
572
- ...criterionStatus(state, criterion),
625
+ ...criterionStatus(state, criterion, requirement),
573
626
  }));
574
627
  const missing = rows.filter((row) => row.passed !== true);
575
628
  // Three states, disjoint by construction: a check whose run carried its own
576
629
  // exit code, a check that ran but lost the exit code to a later command, and
577
630
  // a check never invoked at all. Calling the middle one "never ran" is what
578
631
  // told the team its finished work had not been attempted.
579
- const ran = rows.filter((row) => runsOf(state, row).length > 0).length;
632
+ const ran = rows.filter((row) => runsOf(state, row, requirement).length > 0).length;
580
633
  const masked = rows.filter(
581
- (row) => runsOf(state, row).length === 0 && maskedRunsOf(state, row).length > 0,
634
+ (row) =>
635
+ runsOf(state, row, requirement).length === 0 &&
636
+ maskedRunsOf(state, row, requirement).length > 0,
582
637
  ).length;
583
638
  return {
584
639
  resolved: rows.length > 0 && missing.length === 0,
@@ -589,7 +644,7 @@ export function problemStatus(state) {
589
644
  neverFailed: rows.filter((row) => row.neverFailed === true).map((row) => row.index),
590
645
  missing,
591
646
  delta: productDelta(state, requirement),
592
- newest: newestCommit(state),
647
+ newest: newestCommit(state, requirement),
593
648
  checks: { total: rows.length, ran, masked, neverRan: rows.length - ran - masked },
594
649
  };
595
650
  }
@@ -613,10 +668,10 @@ export function problemStatus(state) {
613
668
  * With no active requirement nothing is in flight, so nothing is gated.
614
669
  *
615
670
  * @param {object} state - ledger state.
671
+ * @param {object} [requirement] - lane whose criteria are being closed over.
616
672
  * @returns {{ gated: boolean, resolved: boolean, requirementId: string | null, total: number, ran: number, passed: number, failed: object[], missing: object[], newest: CommitFacts | null }} status.
617
673
  */
618
- export function closureStatus(state) {
619
- const requirement = activeRequirement(state);
674
+ export function closureStatus(state, requirement = activeRequirement(state)) {
620
675
  if (requirement === undefined) {
621
676
  return {
622
677
  gated: false,
@@ -627,7 +682,7 @@ export function closureStatus(state) {
627
682
  passed: 0,
628
683
  failed: [],
629
684
  missing: [],
630
- newest: newestCommit(state),
685
+ newest: newestCommit(state, requirement),
631
686
  };
632
687
  }
633
688
  const criteria = criteriaOf(requirement).filter(
@@ -635,7 +690,7 @@ export function closureStatus(state) {
635
690
  );
636
691
  const rows = criteria.map((criterion) => ({
637
692
  ...criterion,
638
- ...criterionStatus(state, criterion),
693
+ ...criterionStatus(state, criterion, requirement),
639
694
  }));
640
695
  const missing = rows.filter((row) => row.passed !== true);
641
696
  const failed = rows.filter((row) => row.reason === 'failed');
@@ -650,34 +705,92 @@ export function closureStatus(state) {
650
705
  passed: rows.length - missing.length,
651
706
  failed,
652
707
  missing,
653
- newest: newestCommit(state),
708
+ newest: newestCommit(state, requirement),
654
709
  };
655
710
  }
656
711
 
657
712
  /**
658
- * Whether a closing target is the ticket the ledger opened for the active
659
- * requirement.
713
+ * The open lane whose ticket a closing action targets.
660
714
  *
661
- * Only that ticket stands for the problem this cycle claims to solve. Meeting
662
- * tickets, decision tickets and tickets from other work are discussion
663
- * bookkeeping: the gate must not hold them open until the cycle ends.
715
+ * Only a lane's own ticket stands for the problem that lane claims to solve, so
716
+ * the bar is read from that lane rather than from whatever happens to hold the
717
+ * focus. Meeting tickets, decision tickets and tickets from other work are
718
+ * discussion bookkeeping: the gate must not hold them open until a lane ends.
719
+ *
720
+ * @param {object} state - ledger state.
721
+ * @param {{ verb: string, project: string, iid?: number }} target - artifact action.
722
+ * @returns {object | undefined} the lane that owns the ticket.
723
+ */
724
+ export function laneOwningIssue(state, target) {
725
+ if (target?.verb !== 'close' || typeof target.project !== 'string') return undefined;
726
+ const iid = Number(target.iid);
727
+ if (!Number.isInteger(iid)) return undefined;
728
+ const wanted = target.project.trim().toLowerCase();
729
+ return (state.requirements ?? []).find((requirement) => {
730
+ if (requirement.status !== undefined && requirement.status !== 'open') return false;
731
+ const tracking = requirement.tracking;
732
+ if (tracking === null || tracking === undefined) return false;
733
+ if (typeof tracking.project !== 'string' || !Number.isInteger(tracking.issue)) return false;
734
+ return tracking.issue === iid && tracking.project.trim().toLowerCase() === wanted;
735
+ });
736
+ }
737
+
738
+ /**
739
+ * The open lane whose merge request a merging action targets.
740
+ *
741
+ * A merge names a project and an iid, never a requirement, so the lane has to
742
+ * be bound to its request (`action "lane"` with `mr`). Without a binding the
743
+ * gate leaves the merge alone rather than checking the focused lane: holding an
744
+ * unrelated request to another lane's bar is worse than not gating it.
745
+ *
746
+ * @param {object} state - ledger state.
747
+ * @param {{ verb: string, project: string, iid?: number }} target - artifact action.
748
+ * @returns {object | undefined} the lane that owns the request.
749
+ */
750
+ export function laneOwningMerge(state, target) {
751
+ if (target?.verb !== 'merge' || typeof target.project !== 'string') return undefined;
752
+ const iid = Number(target.iid);
753
+ if (!Number.isInteger(iid)) return undefined;
754
+ const wanted = target.project.trim().toLowerCase();
755
+ return (state.requirements ?? []).find((requirement) => {
756
+ if (requirement.status !== undefined && requirement.status !== 'open') return false;
757
+ const request = requirement.mergeRequest;
758
+ if (request === undefined || request === null) return false;
759
+ return request.iid === iid && String(request.project).trim().toLowerCase() === wanted;
760
+ });
761
+ }
762
+
763
+ /**
764
+ * Why a merge must wait for the queue.
765
+ *
766
+ * Deliveries land one at a time so `master` builds at every step. A request
767
+ * that is not in the queue is none of this rule's business (a hotfix, a release
768
+ * branch); one that is queued but not at the head waits, and the refusal names
769
+ * who goes first.
770
+ *
771
+ * @param {object} state - ledger state.
772
+ * @param {object | undefined} lane - the lane the request belongs to.
773
+ * @param {{ ref: string }} target - merge action.
774
+ * @returns {string | undefined} refusal reason.
775
+ */
776
+ export function mergeQueueRefusal(state, lane, target) {
777
+ if (lane === undefined) return undefined;
778
+ const queue = Array.isArray(state.mergeQueue) ? state.mergeQueue : [];
779
+ const position = queue.findIndex((row) => row.requirementId === lane.id);
780
+ if (position <= 0) return undefined;
781
+ const head = queue[0].requirementId;
782
+ return `merge-queued: ${target.ref} belongs to ${lane.id}, and ${head} is ahead of it in the merge queue. Deliveries land one at a time so master builds at every step: wait for ${head} to land, then merge this one.`;
783
+ }
784
+
785
+ /**
786
+ * Whether a closing target is a ticket the ledger opened for an open lane.
664
787
  *
665
788
  * @param {object} state - ledger state.
666
789
  * @param {{ verb: string, project: string, iid?: number }} target - artifact action.
667
790
  * @returns {boolean} true when the ledger owns the ticket.
668
791
  */
669
792
  export function ownsActiveIssue(state, target) {
670
- if (target?.verb !== 'close') return false;
671
- const tracking = activeRequirement(state)?.tracking;
672
- if (tracking === null || tracking === undefined) return false;
673
- if (typeof tracking.project !== 'string' || !Number.isInteger(tracking.issue)) return false;
674
- if (typeof target.project !== 'string') return false;
675
- const iid = Number(target.iid);
676
- return (
677
- Number.isInteger(iid) &&
678
- iid === tracking.issue &&
679
- tracking.project.trim().toLowerCase() === target.project.trim().toLowerCase()
680
- );
793
+ return laneOwningIssue(state, target) !== undefined;
681
794
  }
682
795
 
683
796
  /**
@@ -705,11 +818,12 @@ function missingText(row) {
705
818
  *
706
819
  * @param {object} state - ledger state.
707
820
  * @param {string} action - blocked action name for the message.
821
+ * @param {object} [requirement] - lane making the claim.
708
822
  * @returns {void}
709
823
  * @throws {TeamError} when no criterion has been attempted.
710
824
  */
711
- export function assertProblemResolved(state, action) {
712
- const status = closureStatus(state);
825
+ export function assertProblemResolved(state, action, requirement) {
826
+ const status = closureStatus(state, requirement);
713
827
  if (status.resolved) return;
714
828
  const unobserved = status.missing.filter(
715
829
  (row) => row.reason === 'never-run' || row.reason === 'masked-run',
@@ -732,17 +846,18 @@ export function assertProblemResolved(state, action) {
732
846
  *
733
847
  * @param {object} state - ledger state.
734
848
  * @param {string} target - target stage.
849
+ * @param {object} [requirement] - lane being advanced.
735
850
  * @returns {void}
736
851
  * @throws {TeamError} when the observed work does not support the stage.
737
852
  */
738
- export function assertStageProof(state, target) {
853
+ export function assertStageProof(state, target, requirement = activeRequirement(state)) {
739
854
  if (!STAGES_USING_PROOF.includes(target)) return;
740
855
  if (target === 'implementation') {
741
856
  // A local-only workspace has no remote to publish to; the commit bar would
742
857
  // only make the team commit for the gate. The approved decision, enforced
743
858
  // by the caller, is the whole bar there.
744
859
  if (isLocalOnly(state)) return;
745
- if (newestCommitIndex(state) === -1) {
860
+ if (newestCommitIndex(state, requirement) === -1) {
746
861
  throw new TeamError(
747
862
  'stage "implementation" requires a commit recorded by infinity_git; the gate reads the commit, not a ticket or an evidence id',
748
863
  'gate-unsatisfied',
@@ -751,14 +866,14 @@ export function assertStageProof(state, target) {
751
866
  return;
752
867
  }
753
868
  if (target === 'test') {
754
- const missing = criteriaOf(activeRequirement(state)).filter(
755
- (criterion) => runsOf(state, criterion).length === 0,
869
+ const missing = criteriaOf(requirement).filter(
870
+ (criterion) => runsOf(state, criterion, requirement).length === 0,
756
871
  );
757
872
  if (missing.length > 0) {
758
873
  throw new TeamError(
759
874
  `stage "test" requires every check to have run at least once, as the last command on its line. Not attributable yet: ${missing
760
875
  .map((criterion) => {
761
- const masked = maskedRunsOf(state, criterion).length > 0;
876
+ const masked = maskedRunsOf(state, criterion, requirement).length > 0;
762
877
  return `#${criterion.index} ${criterion.check ?? '(no check)'}${masked ? ' (ran, but a later command in the same line took its exit code)' : ''}`;
763
878
  })
764
879
  .join('; ')}`,
@@ -767,7 +882,7 @@ export function assertStageProof(state, target) {
767
882
  }
768
883
  return;
769
884
  }
770
- assertProblemResolved(state, target);
885
+ assertProblemResolved(state, target, requirement);
771
886
  }
772
887
 
773
888
  /**
@@ -788,7 +903,12 @@ export function activeRequirement(state) {
788
903
  * published?" from facts rather than from prose.
789
904
  *
790
905
  * @param {object} state - ledger state.
791
- * @param {{ kind: string, detail: string, tool: string, member: string, role?: string, branch?: string, files?: string[], exitCode?: number, workaround?: boolean, ref?: string, noteId?: number, now: string }} input - evidence.
906
+ * Every row is stamped with the lane it was observed in. That stamp is what
907
+ * keeps two lanes' runs apart when they run the same command, and what lets the
908
+ * gate read one lane's work while another is still editing.
909
+ *
910
+ * @param {object} state - ledger state.
911
+ * @param {{ kind: string, detail: string, tool: string, member: string, role?: string, requirementId?: string, branch?: string, files?: string[], exitCode?: number, workaround?: boolean, ref?: string, noteId?: number, now: string }} input - evidence.
792
912
  * @returns {object} recorded evidence.
793
913
  */
794
914
  export function recordEvidence(state, input) {
@@ -801,6 +921,7 @@ export function recordEvidence(state, input) {
801
921
  detail: String(input.detail).slice(0, 500),
802
922
  tool: input.tool,
803
923
  member: input.member,
924
+ requirementId: input.requirementId ?? state.activeRequirementId ?? null,
804
925
  ...optionalEvidence(input),
805
926
  at: input.now,
806
927
  };
@@ -18,10 +18,96 @@ import {
18
18
  artifactRefusal,
19
19
  closureStatus,
20
20
  isWorkaroundRun,
21
- ownsActiveIssue,
21
+ laneOwningIssue,
22
+ laneOwningMerge,
23
+ mergeQueueRefusal,
22
24
  recordEvidence,
23
25
  } from './problem-gate.js';
24
26
 
27
+ /**
28
+ * Normalize a branch name for comparison.
29
+ *
30
+ * @param {unknown} value - branch text.
31
+ * @returns {string} short branch name.
32
+ */
33
+ function shortBranch(value) {
34
+ return String(value ?? '').trim().replace(/^refs\/heads\//, '');
35
+ }
36
+
37
+ /**
38
+ * The open lanes still doing work.
39
+ *
40
+ * @param {object} state - ledger state.
41
+ * @returns {object[]} lanes.
42
+ */
43
+ function runningLanes(state) {
44
+ return (state.requirements ?? []).filter(
45
+ (requirement) => requirement.status === undefined || requirement.status === 'open',
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Whether one directory sits inside a lane's worktree.
51
+ *
52
+ * @param {string | null} cwd - session directory.
53
+ * @param {unknown} worktree - lane worktree.
54
+ * @returns {boolean} true when the directory belongs to the lane.
55
+ */
56
+ function insideWorktree(cwd, worktree) {
57
+ if (typeof cwd !== 'string' || typeof worktree !== 'string' || worktree === '') return false;
58
+ const base = worktree.replace(/[\\/]+$/, '');
59
+ return cwd === base || cwd.startsWith(`${base}/`) || cwd.startsWith(`${base}\\`);
60
+ }
61
+
62
+ /**
63
+ * Which lane a piece of evidence was observed in.
64
+ *
65
+ * Attribution is read from the fact the tool produced: a commit names its
66
+ * branch, a command names the directory it ran in, a note names the thread it
67
+ * landed on. Each of those is set before the model speaks, so two lanes editing
68
+ * at once cannot claim each other's work. When nothing matches — a commit on
69
+ * `master`, a command run outside every worktree — the focused lane owns it,
70
+ * which is what a single-lane team meant anyway.
71
+ *
72
+ * @param {object} state - ledger state.
73
+ * @param {{ kind: string, branch?: string, ref?: string }} evidence - observed evidence.
74
+ * @param {string | null} cwd - directory the call ran in.
75
+ * @returns {string | null} requirement id.
76
+ */
77
+ export function laneForEvidence(state, evidence, cwd) {
78
+ const match = runningLanes(state).find(matcher(evidence, cwd));
79
+ return match?.id ?? state.activeRequirementId ?? null;
80
+ }
81
+
82
+ /**
83
+ * The predicate that reads one kind of evidence against a lane.
84
+ *
85
+ * @param {{ kind: string, branch?: string, ref?: string }} evidence - observed evidence.
86
+ * @param {string | null} cwd - directory the call ran in.
87
+ * @returns {(lane: object) => boolean} lane predicate.
88
+ */
89
+ function matcher(evidence, cwd) {
90
+ if (evidence.kind === 'commit') {
91
+ const branch = shortBranch(evidence.branch);
92
+ return (lane) => branch !== '' && shortBranch(lane.branch) === branch;
93
+ }
94
+ if (evidence.kind === 'command') return (lane) => insideWorktree(cwd, lane.worktree);
95
+ const ref = String(evidence.ref ?? '').trim().toLowerCase();
96
+ return (lane) => threadRef(lane) === ref;
97
+ }
98
+
99
+ /**
100
+ * The ticket reference of one lane, lowercased.
101
+ *
102
+ * @param {object} lane - requirement record.
103
+ * @returns {string} `project#iid`, or an empty string.
104
+ */
105
+ function threadRef(lane) {
106
+ const tracking = lane.tracking;
107
+ if (typeof tracking?.project !== 'string' || !Number.isInteger(tracking?.issue)) return '';
108
+ return `${tracking.project.trim().toLowerCase()}#${tracking.issue}`;
109
+ }
110
+
25
111
  /**
26
112
  * Commit evidence from a successful `infinity_git commit` result.
27
113
  *
@@ -134,6 +220,7 @@ export function createProblemController({
134
220
  loadState,
135
221
  memberOf,
136
222
  isTeamAgent,
223
+ cwdOf = () => null,
137
224
  withTeam = (_agent, run) => run(),
138
225
  }) {
139
226
  /**
@@ -150,8 +237,10 @@ export function createProblemController({
150
237
  await store.mutate((draft, now) => {
151
238
  const member = memberOf(draft, execution.agent);
152
239
  if (member === undefined) return undefined;
240
+ const requirementId = laneForEvidence(draft, evidence, cwdOf(execution.agent));
153
241
  return recordEvidence(draft, {
154
242
  ...evidence,
243
+ ...(requirementId === null ? {} : { requirementId }),
155
244
  member: member.name,
156
245
  role: member.role,
157
246
  now,
@@ -186,8 +275,13 @@ export function createProblemController({
186
275
  ? undefined
187
276
  : `problem-unresolved: the team ledger is unreadable, so the verification state is unknown; refusing to merge ${target.ref}`;
188
277
  }
189
- if (target.verb === 'close' && !ownsActiveIssue(state, target)) return undefined;
190
- const status = closureStatus(state);
278
+ const lane =
279
+ target.verb === 'close' ? laneOwningIssue(state, target) : laneOwningMerge(state, target);
280
+ if (target.verb === 'close' && lane === undefined) return undefined;
281
+ const queued = mergeQueueRefusal(state, lane, target);
282
+ if (queued !== undefined) return queued;
283
+ if (target.verb === 'merge' && lane === undefined) return undefined;
284
+ const status = closureStatus(state, lane);
191
285
  return status.resolved ? undefined : artifactRefusal(target, status);
192
286
  }
193
287
 
@@ -1,4 +1,4 @@
1
- import { TeamError, requireSeeded } from './team-core.js';
1
+ import { TeamError, laneFor, requireSeeded } from './team-core.js';
2
2
  import { activeRequirement, problemStatus, recordEvidence } from './problem-gate.js';
3
3
  import { trackedThread, workRecordBody } from './work-record-gate.js';
4
4
 
@@ -18,12 +18,14 @@ import { trackedThread, workRecordBody } from './work-record-gate.js';
18
18
  * @param {(ctx: object, operation: () => Promise<unknown>, action: string) => Promise<unknown>} deps.tracked - GitFox error mapper.
19
19
  * @param {object} deps.ctx - Host context with the DevOps service.
20
20
  * @param {{ member: string, role: string }} deps.caller - caller.
21
+ * @param {string} [deps.requirementId] - lane to publish, defaulting to the focused one.
21
22
  * @returns {Promise<object>} published record summary.
22
23
  */
23
- export async function publishWorkRecord({ store, loadState, tracked, ctx, caller }) {
24
+ export async function publishWorkRecord({ store, loadState, tracked, ctx, caller, requirementId }) {
24
25
  const state = await loadState(store);
25
26
  requireSeeded(state);
26
- const requirement = activeRequirement(state);
27
+ const requirement =
28
+ requirementId === undefined ? activeRequirement(state) : laneFor(state, requirementId);
27
29
  if (requirement === undefined) {
28
30
  throw new TeamError(
29
31
  'there is no active requirement, so there is no cycle whose work record could be published',
@@ -31,7 +33,7 @@ export async function publishWorkRecord({ store, loadState, tracked, ctx, caller
31
33
  { status: 409 },
32
34
  );
33
35
  }
34
- const thread = trackedThread(state);
36
+ const thread = trackedThread(state, requirement);
35
37
  if (thread === undefined) {
36
38
  throw new TeamError(
37
39
  'the active requirement has no tracked ticket, and a work record belongs to a thread: open tracked intake against the GitFox project, then publish',
@@ -58,11 +60,12 @@ export async function publishWorkRecord({ store, loadState, tracked, ctx, caller
58
60
  member: caller.member,
59
61
  role: caller.role,
60
62
  ref: thread.ref,
63
+ requirementId: requirement.id,
61
64
  ...(Number.isInteger(noteId) ? { noteId } : {}),
62
65
  now,
63
66
  });
64
67
  });
65
- const status = problemStatus(state);
68
+ const status = problemStatus(state, requirement);
66
69
  return {
67
70
  action: 'publish',
68
71
  ref: thread.ref,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The directory a session is working in right now.
3
+ *
4
+ * `session.header.cwd` keeps the directory the session was opened with, so a
5
+ * worker that moved into a worktree still reports the project root there. The
6
+ * live directory lives on the `workingDirectory` service; the header is only the
7
+ * fallback for a Host without that service.
8
+ *
9
+ * @param {object} ctx - Host context.
10
+ * @param {object} agent - live Agent.
11
+ * @returns {string | null} directory, or null when it is unknown.
12
+ */
13
+ export function currentDirectory(ctx, agent) {
14
+ const session = agent?.session;
15
+ if (session === undefined) return null;
16
+ try {
17
+ return ctx.workingDirectory?.get?.(session) ?? session.header?.cwd ?? null;
18
+ } catch {
19
+ return session.header?.cwd ?? null;
20
+ }
21
+ }