patchwork-os 1.2.0-beta.2.canary.655 → 1.2.0-beta.2.canary.657

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.
@@ -87,6 +87,28 @@ export interface ObservedErrandArtifact {
87
87
  * have reported `completed: true` may set this.
88
88
  */
89
89
  stateObserved?: boolean;
90
+ /**
91
+ * When THIS artifact first came under observation.
92
+ *
93
+ * The staleness rule converts silence into a negative, and it is sound only
94
+ * where not-acting is a DECISION. That requires the operator to have been in
95
+ * a loop they knew about. For an errand filed before any observation channel
96
+ * existed, they were never asked and did not know it was tracked — so its
97
+ * age says nothing about their judgement.
98
+ *
99
+ * Without this, the first real ingest is the worst one: every historical
100
+ * errand older than the horizon grades `junk` on day one, and `stateObserved`
101
+ * does NOT catch it, because we genuinely did look. That guard separates
102
+ * "nobody looked" from "we looked"; this one separates "nobody was asked"
103
+ * from "somebody declined to act".
104
+ *
105
+ * So the clock starts at `max(createdAt, watchedSince)`. A 60-day-old errand
106
+ * first seen today has been watched for zero days, and is `open-recent`.
107
+ *
108
+ * Absent ⇒ falls back to `createdAt`, preserving the old behaviour for
109
+ * callers that genuinely have been watching since creation.
110
+ */
111
+ watchedSince?: number;
90
112
  }
91
113
  export interface GradeOptions {
92
114
  /** Clock. Injected so grading is reproducible in replay. */
@@ -76,7 +76,11 @@ export function gradeErrandOutcome(observed, opts) {
76
76
  return { disposition: "unknown", reason: "not-observed" };
77
77
  }
78
78
  const staleAfter = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
79
- const age = opts.now - observed.createdAt;
79
+ // The clock starts when WE started watching, not when the errand was filed.
80
+ // An errand that predates observation has not been "ignored for 60 days" —
81
+ // nobody was asked. See `watchedSince`.
82
+ const openSince = Math.max(observed.createdAt, observed.watchedSince ?? observed.createdAt);
83
+ const age = opts.now - openSince;
80
84
  if (age >= staleAfter) {
81
85
  return { disposition: "junk", reason: "stale-unactioned" };
82
86
  }
@@ -1 +1 @@
1
- {"version":3,"file":"errandOutcomeGrader.js","sourceRoot":"","sources":["../../src/butler/errandOutcomeGrader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAIH,0CAA0C;AAC1C,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAoE/D;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAChC,QAAgC,EAChC,IAAkB;IAElB,yEAAyE;IACzE,6EAA6E;IAC7E,6EAA6E;IAC7E,yCAAyC;IACzC,IAAI,QAAQ,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;QAC9B,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IACpD,CAAC;IACD,IAAI,QAAQ,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;QAChC,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IAC3D,CAAC;IAED,yEAAyE;IACzE,mCAAmC;IACnC,IAAI,QAAQ,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QACrC,2EAA2E;QAC3E,2CAA2C;QAC3C,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAC5D,CAAC;IAED,IAAI,QAAQ,CAAC,aAAa,KAAK,IAAI,EAAE,CAAC;QACpC,uEAAuE;QACvE,2EAA2E;QAC3E,uDAAuD;QACvD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAC5D,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,YAAY,IAAI,sBAAsB,CAAC;IAC/D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAS,CAAC;IAC1C,IAAI,GAAG,IAAI,UAAU,EAAE,CAAC;QACtB,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAC7D,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;AAC3D,CAAC"}
1
+ {"version":3,"file":"errandOutcomeGrader.js","sourceRoot":"","sources":["../../src/butler/errandOutcomeGrader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAIH,0CAA0C;AAC1C,MAAM,CAAC,MAAM,sBAAsB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AA0F/D;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAChC,QAAgC,EAChC,IAAkB;IAElB,yEAAyE;IACzE,6EAA6E;IAC7E,6EAA6E;IAC7E,yCAAyC;IACzC,IAAI,QAAQ,CAAC,OAAO,KAAK,IAAI,EAAE,CAAC;QAC9B,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC;IACpD,CAAC;IACD,IAAI,QAAQ,CAAC,SAAS,KAAK,IAAI,EAAE,CAAC;QAChC,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,CAAC;IAC3D,CAAC;IAED,yEAAyE;IACzE,mCAAmC;IACnC,IAAI,QAAQ,CAAC,SAAS,KAAK,SAAS,EAAE,CAAC;QACrC,2EAA2E;QAC3E,2CAA2C;QAC3C,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAC5D,CAAC;IAED,IAAI,QAAQ,CAAC,aAAa,KAAK,IAAI,EAAE,CAAC;QACpC,uEAAuE;QACvE,2EAA2E;QAC3E,uDAAuD;QACvD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAC5D,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,YAAY,IAAI,sBAAsB,CAAC;IAC/D,4EAA4E;IAC5E,2EAA2E;IAC3E,wCAAwC;IACxC,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,CACxB,QAAQ,CAAC,SAAS,EAClB,QAAQ,CAAC,YAAY,IAAI,QAAQ,CAAC,SAAS,CAC5C,CAAC;IACF,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,GAAG,SAAS,CAAC;IACjC,IAAI,GAAG,IAAI,UAAU,EAAE,CAAC;QACtB,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,EAAE,kBAAkB,EAAE,CAAC;IAC7D,CAAC;IACD,OAAO,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;AAC3D,CAAC"}
@@ -39,7 +39,7 @@
39
39
  * rows the grader has not earned the right to be evidence.
40
40
  */
41
41
  import { gradeErrandOutcome, } from "./errandOutcomeGrader.js";
42
- import { appendShadowOutcome, summariseShadowLog, } from "./outcomeShadowLog.js";
42
+ import { appendShadowOutcome, firstSeenByRef, summariseShadowLog, } from "./outcomeShadowLog.js";
43
43
  /**
44
44
  * Grade every observation and append a shadow row per accepted one.
45
45
  *
@@ -51,6 +51,11 @@ import { appendShadowOutcome, summariseShadowLog, } from "./outcomeShadowLog.js"
51
51
  * an errand going from open to completed.
52
52
  */
53
53
  export function ingestErrandOutcomes(observations, opts) {
54
+ // When each ref came under observation, read from the ledger. An errand
55
+ // first seen in THIS run has been watched for zero time, so the staleness
56
+ // rule cannot yet convert its silence into a negative — the operator was
57
+ // never asked. See `watchedSince` in errandOutcomeGrader.
58
+ const firstSeen = firstSeenByRef({ dir: opts.dir });
54
59
  const skipped = [];
55
60
  const batch = { confirmed: 0, junk: 0, unknown: 0 };
56
61
  const seen = new Set();
@@ -66,7 +71,10 @@ export function ingestErrandOutcomes(observations, opts) {
66
71
  continue;
67
72
  }
68
73
  seen.add(ref);
69
- const { disposition, reason } = gradeErrandOutcome(obs, opts);
74
+ const { disposition, reason } = gradeErrandOutcome({
75
+ ...obs,
76
+ watchedSince: obs.watchedSince ?? firstSeen.get(ref) ?? opts.now,
77
+ }, opts);
70
78
  appendShadowOutcome({
71
79
  ref,
72
80
  disposition,
@@ -1 +1 @@
1
- {"version":3,"file":"outcomeIngester.js","sourceRoot":"","sources":["../../src/butler/outcomeIngester.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AAEH,OAAO,EAEL,kBAAkB,GAEnB,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACL,mBAAmB,EAEnB,kBAAkB,GACnB,MAAM,uBAAuB,CAAC;AAgC/B;;;;;;;;;GASG;AACH,MAAM,UAAU,oBAAoB,CAClC,YAA0C,EAC1C,IAAmB;IAEnB,MAAM,OAAO,GAA4B,EAAE,CAAC;IAC5C,MAAM,KAAK,GAAG,EAAE,SAAS,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IACpD,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,IAAI,MAAM,GAAG,CAAC,CAAC;IAEf,KAAK,MAAM,GAAG,IAAI,YAAY,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,OAAO,GAAG,CAAC,GAAG,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,OAAO,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC;YACxC,SAAS;QACX,CAAC;QACD,IAAI,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;YAClB,OAAO,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,EAAE,eAAe,EAAE,CAAC,CAAC;YAC/C,SAAS;QACX,CAAC;QACD,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAEd,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QAC9D,mBAAmB,CACjB;YACE,GAAG;YACH,WAAW;YACX,MAAM;YACN,QAAQ,EAAE,IAAI,CAAC,GAAG;YAClB,GAAG,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC9C,EACD,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAClB,CAAC;QACF,KAAK,CAAC,WAAW,CAAC,EAAE,CAAC;QACrB,MAAM,EAAE,CAAC;IACX,CAAC;IAED,OAAO;QACL,MAAM;QACN,OAAO;QACP,KAAK;QACL,MAAM,EAAE,kBAAkB,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC;KAC9C,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,CAAgB;IAClD,IAAI,CAAC,CAAC,KAAK,KAAK,CAAC,EAAE,CAAC;QAClB,OAAO;YACL,qCAAqC;YACrC,EAAE;YACF,kEAAkE;YAClE,oDAAoD;SACrD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACf,CAAC;IACD,MAAM,GAAG,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC;IAClE,OAAO;QACL,mBAAmB,CAAC,CAAC,KAAK,iBAAiB;QAC3C,gBAAgB,CAAC,CAAC,SAAS,KAAK,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,GAAG;QACnD,gBAAgB,CAAC,CAAC,IAAI,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG;QACzC,gBAAgB,CAAC,CAAC,OAAO,KAAK,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,0BAA0B;QACtE,EAAE;QACF,KAAK,CAAC,CAAC,UAAU,YAAY,GAAG,CAAC,CAAC,CAAC,UAAU,CAAC,+BAA+B;QAC7E,EAAE;QACF,0EAA0E;QAC1E,sEAAsE;QACtE,yDAAyD;KAC1D,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"outcomeIngester.js","sourceRoot":"","sources":["../../src/butler/outcomeIngester.ts"],"names":[],"mappings":"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"}
@@ -68,6 +68,20 @@ export declare function wouldCountAsEvidence(d: OutcomeDisposition): boolean;
68
68
  export declare function appendShadowOutcome(row: Omit<ShadowOutcomeRow, "wouldCountAsEvidence">, opts?: {
69
69
  dir?: string;
70
70
  }): void;
71
+ /**
72
+ * When each ref was FIRST graded, from the ledger itself.
73
+ *
74
+ * No new file: every shadow row already carries `gradedAt`, so the earliest
75
+ * one for a ref IS the moment it came under observation. A separate
76
+ * "first seen" store would be a second source of truth that can disagree with
77
+ * the ledger it describes — and the ledger is the thing a reviewer reads.
78
+ *
79
+ * Refs absent from the map have never been observed; the caller supplies
80
+ * `now`, which is correct: this run is when watching starts.
81
+ */
82
+ export declare function firstSeenByRef(opts?: {
83
+ dir?: string;
84
+ }): Map<string, number>;
71
85
  export interface ShadowSummary {
72
86
  total: number;
73
87
  confirmed: number;
@@ -62,6 +62,48 @@ export function appendShadowOutcome(row, opts = {}) {
62
62
  // turn into an errand failure.
63
63
  }
64
64
  }
65
+ /**
66
+ * When each ref was FIRST graded, from the ledger itself.
67
+ *
68
+ * No new file: every shadow row already carries `gradedAt`, so the earliest
69
+ * one for a ref IS the moment it came under observation. A separate
70
+ * "first seen" store would be a second source of truth that can disagree with
71
+ * the ledger it describes — and the ledger is the thing a reviewer reads.
72
+ *
73
+ * Refs absent from the map have never been observed; the caller supplies
74
+ * `now`, which is correct: this run is when watching starts.
75
+ */
76
+ export function firstSeenByRef(opts = {}) {
77
+ const out = new Map();
78
+ const p = shadowLogPath(opts.dir);
79
+ if (!existsSync(p))
80
+ return out;
81
+ let text;
82
+ try {
83
+ text = readFileSync(p, "utf-8");
84
+ }
85
+ catch {
86
+ return out;
87
+ }
88
+ for (const line of text.split("\n")) {
89
+ if (!line.trim())
90
+ continue;
91
+ let row;
92
+ try {
93
+ row = JSON.parse(line);
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ if (typeof row.ref !== "string" || typeof row.gradedAt !== "number") {
99
+ continue;
100
+ }
101
+ const prev = out.get(row.ref);
102
+ if (prev === undefined || row.gradedAt < prev)
103
+ out.set(row.ref, row.gradedAt);
104
+ }
105
+ return out;
106
+ }
65
107
  /**
66
108
  * Summarise the shadow ledger — the number this phase exists to produce.
67
109
  *
@@ -1 +1 @@
1
- {"version":3,"file":"outcomeShadowLog.js","sourceRoot":"","sources":["../../src/butler/outcomeShadowLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnE,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIpD;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,6BAA6B,CAAC;AAEjE,MAAM,UAAU,aAAa,CAAC,QAAiB;IAC7C,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,aAAa,EAAE,EAAE,mBAAmB,CAAC,CAAC;AACrE,CAAC;AAwBD,qEAAqE;AACrE,MAAM,UAAU,oBAAoB,CAAC,CAAqB;IACxD,OAAO,CAAC,KAAK,SAAS,CAAC;AACzB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAAmD,EACnD,OAAyB,EAAE;IAE3B,MAAM,IAAI,GAAqB;QAC7B,GAAG,GAAG;QACN,oBAAoB,EAAE,oBAAoB,CAAC,GAAG,CAAC,WAAW,CAAC;KAC5D,CAAC;IACF,IAAI,CAAC;QACH,cAAc,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,wEAAwE;QACxE,+BAA+B;IACjC,CAAC;AACH,CAAC;AAWD;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAyB,EAAE;IAC5D,MAAM,KAAK,GAAkB;QAC3B,KAAK,EAAE,CAAC;QACR,SAAS,EAAE,CAAC;QACZ,IAAI,EAAE,CAAC;QACP,OAAO,EAAE,CAAC;QACV,UAAU,EAAE,CAAC;KACd,CAAC;IACF,MAAM,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACjC,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,GAAG,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC;IACzB,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,SAAS;QAC3B,IAAI,GAAqB,CAAC;QAC1B,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAqB,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,IACE,GAAG,CAAC,WAAW,KAAK,WAAW;YAC/B,GAAG,CAAC,WAAW,KAAK,MAAM;YAC1B,GAAG,CAAC,WAAW,KAAK,SAAS,EAC7B,CAAC;YACD,SAAS;QACX,CAAC;QACD,GAAG,CAAC,KAAK,EAAE,CAAC;QACZ,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,CAAC;QACvB,IAAI,GAAG,CAAC,oBAAoB;YAAE,GAAG,CAAC,UAAU,EAAE,CAAC;IACjD,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"outcomeShadowLog.js","sourceRoot":"","sources":["../../src/butler/outcomeShadowLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnE,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAIpD;;;;GAIG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,6BAA6B,CAAC;AAEjE,MAAM,UAAU,aAAa,CAAC,QAAiB;IAC7C,OAAO,IAAI,CAAC,IAAI,CAAC,QAAQ,IAAI,aAAa,EAAE,EAAE,mBAAmB,CAAC,CAAC;AACrE,CAAC;AAwBD,qEAAqE;AACrE,MAAM,UAAU,oBAAoB,CAAC,CAAqB;IACxD,OAAO,CAAC,KAAK,SAAS,CAAC;AACzB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAAmD,EACnD,OAAyB,EAAE;IAE3B,MAAM,IAAI,GAAqB;QAC7B,GAAG,GAAG;QACN,oBAAoB,EAAE,oBAAoB,CAAC,GAAG,CAAC,WAAW,CAAC;KAC5D,CAAC;IACF,IAAI,CAAC;QACH,cAAc,CAAC,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACvE,CAAC;IAAC,MAAM,CAAC;QACP,wEAAwE;QACxE,+BAA+B;IACjC,CAAC;AACH,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAC5B,OAAyB,EAAE;IAE3B,MAAM,GAAG,GAAG,IAAI,GAAG,EAAkB,CAAC;IACtC,MAAM,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC;IAC/B,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,GAAG,CAAC;IACb,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,SAAS;QAC3B,IAAI,GAAqB,CAAC;QAC1B,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAqB,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,IAAI,OAAO,GAAG,CAAC,GAAG,KAAK,QAAQ,IAAI,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YACpE,SAAS;QACX,CAAC;QACD,MAAM,IAAI,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,IAAI,KAAK,SAAS,IAAI,GAAG,CAAC,QAAQ,GAAG,IAAI;YAC3C,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,GAAG,CAAC,QAAQ,CAAC,CAAC;IACnC,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAWD;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,OAAyB,EAAE;IAC5D,MAAM,KAAK,GAAkB;QAC3B,KAAK,EAAE,CAAC;QACR,SAAS,EAAE,CAAC;QACZ,IAAI,EAAE,CAAC;QACP,OAAO,EAAE,CAAC;QACV,UAAU,EAAE,CAAC;KACd,CAAC;IACF,MAAM,CAAC,GAAG,aAAa,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,KAAK,CAAC;IACjC,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;IAClC,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;IACD,MAAM,GAAG,GAAG,EAAE,GAAG,KAAK,EAAE,CAAC;IACzB,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;YAAE,SAAS;QAC3B,IAAI,GAAqB,CAAC;QAC1B,IAAI,CAAC;YACH,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAqB,CAAC;QAC7C,CAAC;QAAC,MAAM,CAAC;YACP,SAAS;QACX,CAAC;QACD,IACE,GAAG,CAAC,WAAW,KAAK,WAAW;YAC/B,GAAG,CAAC,WAAW,KAAK,MAAM;YAC1B,GAAG,CAAC,WAAW,KAAK,SAAS,EAC7B,CAAC;YACD,SAAS;QACX,CAAC;QACD,GAAG,CAAC,KAAK,EAAE,CAAC;QACZ,GAAG,CAAC,GAAG,CAAC,WAAW,CAAC,EAAE,CAAC;QACvB,IAAI,GAAG,CAAC,oBAAoB;YAAE,GAAG,CAAC,UAAU,EAAE,CAAC;IACjD,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Where per-member password hashes live — ADR-0020 Phase A.
3
+ *
4
+ * `~/.patchwork/credentials.json`, mode 0600, keyed by member id. A separate
5
+ * file from `members.json`, and that separation is the decision worth
6
+ * recording, because two other homes were considered and are worse.
7
+ *
8
+ * ## Not on the `Member` object
9
+ *
10
+ * `Member` is what `actorSnapshot` copies from into decision records. A hash
11
+ * hanging off it is one careless spread away from an audit log. That is why
12
+ * `LocalPasswordProvider` takes `credentialFor` injected rather than reading a
13
+ * field — this module supplies that function and nothing else.
14
+ *
15
+ * ## Not in `members.json`
16
+ *
17
+ * The roster is a describable, reviewable document: the sort of file somebody
18
+ * eventually pastes into an issue or commits to share a configuration. The
19
+ * moment it carries hashes, that is a credential leak by ordinary helpfulness.
20
+ * Separate files let the roster stay boring.
21
+ *
22
+ * A member deactivated rather than deleted (the roster's rule) leaves an
23
+ * orphaned credential here. That is correct and deliberate: the history stays,
24
+ * the login does not.
25
+ *
26
+ * ## NOT in the connector token store
27
+ *
28
+ * `PATCHWORK_TOKEN_DIR` already has 0600 handling and a keychain backend, and
29
+ * it is the wrong home twice over. Conceptually it holds credentials the
30
+ * bridge uses to call OUT; these are credentials people use to call IN.
31
+ * Practically, that path reaches `deleteSecretJsonSync` → an unconditional
32
+ * `unlink`, which is why `audit-connector-test-isolation` exists at all: in
33
+ * #1345 a disconnect test deleted a real credential off a developer's machine.
34
+ * Putting member logins behind that same unlink means a stray `clearTokens()`
35
+ * in a test can delete them.
36
+ *
37
+ * ## Fail CLOSED — the opposite of the roster
38
+ *
39
+ * A missing roster yields one implicit owner, so a single-user machine keeps
40
+ * working: "who may act on your own machine" defaults to the status quo. A
41
+ * missing credential file yields NOTHING, so nobody authenticates: "who are
42
+ * you" defaults to nobody. Those defaults are deliberately opposite, and the
43
+ * asymmetry is the same one ADR-0016 draws — a decision about whether an
44
+ * action happens defaults to no.
45
+ *
46
+ * Concretely: no file, unreadable file, malformed JSON, or an entry that is
47
+ * not a well-formed scrypt record all produce "no credential for this member",
48
+ * and `LocalPasswordProvider` then returns UNATTRIBUTED. None of them throw,
49
+ * because a thrown lookup is something a caller might catch and treat as a
50
+ * pass.
51
+ */
52
+ export declare const CREDENTIALS_BASENAME = "credentials.json";
53
+ /** `~/.patchwork/credentials.json`, honouring `PATCHWORK_HOME`. */
54
+ export declare function defaultCredentialsPath(): string;
55
+ export interface CredentialStore {
56
+ /** The function `LocalPasswordProvider` wants. */
57
+ credentialFor(memberId: string): string | undefined;
58
+ /** Ids with a usable credential — for `patchwork members` to report. */
59
+ ids(): string[];
60
+ /** Entries that were present but unusable, so nobody is told "no such user"
61
+ * when the truth is "your record is corrupt". */
62
+ malformed: string[];
63
+ /** True when the file exists but is readable by group or others. */
64
+ overlyPermissive: boolean;
65
+ }
66
+ /**
67
+ * Load the credential file.
68
+ *
69
+ * Read ONCE at startup, like the roster, and deliberately not re-read per
70
+ * request: a file re-read on every authentication is a file whose permissions
71
+ * and contents can change under a running process, and the resulting behaviour
72
+ * is untestable. Rotation takes a restart, same as the roster.
73
+ */
74
+ export declare function loadCredentials(path?: string): CredentialStore;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Where per-member password hashes live — ADR-0020 Phase A.
3
+ *
4
+ * `~/.patchwork/credentials.json`, mode 0600, keyed by member id. A separate
5
+ * file from `members.json`, and that separation is the decision worth
6
+ * recording, because two other homes were considered and are worse.
7
+ *
8
+ * ## Not on the `Member` object
9
+ *
10
+ * `Member` is what `actorSnapshot` copies from into decision records. A hash
11
+ * hanging off it is one careless spread away from an audit log. That is why
12
+ * `LocalPasswordProvider` takes `credentialFor` injected rather than reading a
13
+ * field — this module supplies that function and nothing else.
14
+ *
15
+ * ## Not in `members.json`
16
+ *
17
+ * The roster is a describable, reviewable document: the sort of file somebody
18
+ * eventually pastes into an issue or commits to share a configuration. The
19
+ * moment it carries hashes, that is a credential leak by ordinary helpfulness.
20
+ * Separate files let the roster stay boring.
21
+ *
22
+ * A member deactivated rather than deleted (the roster's rule) leaves an
23
+ * orphaned credential here. That is correct and deliberate: the history stays,
24
+ * the login does not.
25
+ *
26
+ * ## NOT in the connector token store
27
+ *
28
+ * `PATCHWORK_TOKEN_DIR` already has 0600 handling and a keychain backend, and
29
+ * it is the wrong home twice over. Conceptually it holds credentials the
30
+ * bridge uses to call OUT; these are credentials people use to call IN.
31
+ * Practically, that path reaches `deleteSecretJsonSync` → an unconditional
32
+ * `unlink`, which is why `audit-connector-test-isolation` exists at all: in
33
+ * #1345 a disconnect test deleted a real credential off a developer's machine.
34
+ * Putting member logins behind that same unlink means a stray `clearTokens()`
35
+ * in a test can delete them.
36
+ *
37
+ * ## Fail CLOSED — the opposite of the roster
38
+ *
39
+ * A missing roster yields one implicit owner, so a single-user machine keeps
40
+ * working: "who may act on your own machine" defaults to the status quo. A
41
+ * missing credential file yields NOTHING, so nobody authenticates: "who are
42
+ * you" defaults to nobody. Those defaults are deliberately opposite, and the
43
+ * asymmetry is the same one ADR-0016 draws — a decision about whether an
44
+ * action happens defaults to no.
45
+ *
46
+ * Concretely: no file, unreadable file, malformed JSON, or an entry that is
47
+ * not a well-formed scrypt record all produce "no credential for this member",
48
+ * and `LocalPasswordProvider` then returns UNATTRIBUTED. None of them throw,
49
+ * because a thrown lookup is something a caller might catch and treat as a
50
+ * pass.
51
+ */
52
+ import { chmodSync, existsSync, readFileSync, statSync } from "node:fs";
53
+ import { join } from "node:path";
54
+ import { patchworkHome } from "../patchworkHome.js";
55
+ import { isCredentialRecord } from "./credentials.js";
56
+ export const CREDENTIALS_BASENAME = "credentials.json";
57
+ /** `~/.patchwork/credentials.json`, honouring `PATCHWORK_HOME`. */
58
+ export function defaultCredentialsPath() {
59
+ const home = process.env.PATCHWORK_HOME?.trim();
60
+ return join(home && home !== "" ? home : patchworkHome(), CREDENTIALS_BASENAME);
61
+ }
62
+ const EMPTY = {
63
+ credentialFor: () => undefined,
64
+ ids: () => [],
65
+ malformed: [],
66
+ overlyPermissive: false,
67
+ };
68
+ /**
69
+ * Load the credential file.
70
+ *
71
+ * Read ONCE at startup, like the roster, and deliberately not re-read per
72
+ * request: a file re-read on every authentication is a file whose permissions
73
+ * and contents can change under a running process, and the resulting behaviour
74
+ * is untestable. Rotation takes a restart, same as the roster.
75
+ */
76
+ export function loadCredentials(path = defaultCredentialsPath()) {
77
+ if (!existsSync(path))
78
+ return EMPTY;
79
+ let overlyPermissive = false;
80
+ try {
81
+ const mode = statSync(path).mode & 0o077;
82
+ if (mode !== 0) {
83
+ overlyPermissive = true;
84
+ // Tighten rather than merely complain. A world-readable password file is
85
+ // a live problem, and an operator who is told about it at startup has
86
+ // already been running that way for however long.
87
+ try {
88
+ chmodSync(path, 0o600);
89
+ }
90
+ catch {
91
+ /* reported via overlyPermissive; nothing else to do */
92
+ }
93
+ }
94
+ }
95
+ catch {
96
+ /* stat failed — fall through; the read below decides */
97
+ }
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(readFileSync(path, "utf-8"));
101
+ }
102
+ catch {
103
+ // Unreadable or malformed ⇒ no credentials ⇒ nobody authenticates.
104
+ return { ...EMPTY, overlyPermissive };
105
+ }
106
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
107
+ return { ...EMPTY, overlyPermissive };
108
+ }
109
+ const usable = new Map();
110
+ const malformed = [];
111
+ for (const [id, value] of Object.entries(parsed)) {
112
+ if (isCredentialRecord(value))
113
+ usable.set(id, value);
114
+ else
115
+ malformed.push(id);
116
+ }
117
+ return {
118
+ credentialFor: (memberId) => usable.get(memberId),
119
+ ids: () => [...usable.keys()],
120
+ malformed,
121
+ overlyPermissive,
122
+ };
123
+ }
124
+ //# sourceMappingURL=credentialStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentialStore.js","sourceRoot":"","sources":["../../src/identity/credentialStore.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -216,6 +216,7 @@ const KNOWN_SUBCOMMANDS = [
216
216
  "gate",
217
217
  "outcomes",
218
218
  "butler",
219
+ "members",
219
220
  // Dispatched at `process.argv[2] === "tools"` (and `help`) but absent from
220
221
  // this array until 2026-08. The comment below calls this "the dispatch
221
222
  // source", and it was not: `patchwork tool` could never be corrected to
@@ -4231,6 +4232,116 @@ if (process.argv[2] === "gate") {
4231
4232
  }
4232
4233
  })();
4233
4234
  }
4235
+ // Handle members subcommand — the roster and per-member credentials
4236
+ // (ADR-0020 Phase A).
4237
+ //
4238
+ // `set-password` prompts on a TTY and never takes the secret from argv:
4239
+ // argv lands in shell history and in `ps` output for every user on the box.
4240
+ if (process.argv[2] === "members") {
4241
+ const args = process.argv.slice(3);
4242
+ (async () => {
4243
+ try {
4244
+ const { loadRoster, describeRoster } = await import("./identity/roster.js");
4245
+ const { loadCredentials, defaultCredentialsPath } = await import("./identity/credentialStore.js");
4246
+ if (args[0] === "list" || args.length === 0) {
4247
+ const roster = loadRoster();
4248
+ const creds = loadCredentials();
4249
+ process.stdout.write(`${describeRoster(roster)}\n`);
4250
+ const withCreds = new Set(creds.ids());
4251
+ for (const m of roster.members) {
4252
+ process.stdout.write(` ${m.id} ${withCreds.has(m.id) ? "has a password" : "NO password — cannot authenticate"}\n`);
4253
+ }
4254
+ for (const bad of creds.malformed) {
4255
+ // "your record is corrupt" and "no such user" are different facts.
4256
+ process.stdout.write(` ${bad} credential present but UNREADABLE — treated as absent\n`);
4257
+ }
4258
+ if (creds.overlyPermissive) {
4259
+ process.stdout.write(`\n[members] ${defaultCredentialsPath()} was group/world-readable; tightened to 0600.\n`);
4260
+ }
4261
+ process.exit(0);
4262
+ }
4263
+ if (args[0] === "set-password") {
4264
+ const memberId = args[1];
4265
+ if (!memberId) {
4266
+ process.stderr.write("Usage: patchwork members set-password <memberId>\n");
4267
+ process.exit(2);
4268
+ }
4269
+ if (!process.stdin.isTTY) {
4270
+ // Refused rather than read from a pipe. A password arriving on stdin
4271
+ // from a script is a password in that script.
4272
+ process.stderr.write("[members] set-password needs an interactive terminal.\n");
4273
+ process.exit(2);
4274
+ }
4275
+ const { createInterface } = await import("node:readline");
4276
+ const rl = createInterface({
4277
+ input: process.stdin,
4278
+ output: process.stdout,
4279
+ });
4280
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
4281
+ // Node's readline cannot suppress echo portably; muting the output
4282
+ // stream is the standard trick and is why this is written by hand.
4283
+ const mute = (on) => {
4284
+ rl.output.write = on
4285
+ ? () => { }
4286
+ : process.stdout.write.bind(process.stdout);
4287
+ };
4288
+ process.stdout.write(`Password for ${memberId}: `);
4289
+ mute(true);
4290
+ const pw = (await ask("")).trim();
4291
+ mute(false);
4292
+ process.stdout.write("\nConfirm: ");
4293
+ mute(true);
4294
+ const again = (await ask("")).trim();
4295
+ mute(false);
4296
+ process.stdout.write("\n");
4297
+ rl.close();
4298
+ if (!pw) {
4299
+ process.stderr.write("[members] empty password refused.\n");
4300
+ process.exit(2);
4301
+ }
4302
+ if (pw !== again) {
4303
+ process.stderr.write("[members] passwords did not match.\n");
4304
+ process.exit(2);
4305
+ }
4306
+ const { hashPassword } = await import("./identity/credentials.js");
4307
+ const { readFileSync, writeFileSync, existsSync, chmodSync } = await import("node:fs");
4308
+ const file = defaultCredentialsPath();
4309
+ let existing = {};
4310
+ if (existsSync(file)) {
4311
+ try {
4312
+ const parsed = JSON.parse(readFileSync(file, "utf-8"));
4313
+ if (parsed &&
4314
+ typeof parsed === "object" &&
4315
+ !Array.isArray(parsed)) {
4316
+ existing = parsed;
4317
+ }
4318
+ }
4319
+ catch {
4320
+ // A corrupt file must not be silently replaced — that would
4321
+ // delete every other member's credential to store one.
4322
+ process.stderr.write(`[members] ${file} exists but is not readable JSON. Refusing to overwrite it.\n`);
4323
+ process.exit(2);
4324
+ }
4325
+ }
4326
+ existing[memberId] = await hashPassword(pw);
4327
+ writeFileSync(file, `${JSON.stringify(existing, null, 2)}\n`, {
4328
+ mode: 0o600,
4329
+ });
4330
+ // `mode` on writeFileSync only applies at CREATION, so an existing
4331
+ // file keeps its old permissions unless chmod'd explicitly.
4332
+ chmodSync(file, 0o600);
4333
+ process.stdout.write(`[members] password set for ${memberId}.\n`);
4334
+ process.exit(0);
4335
+ }
4336
+ process.stderr.write("Usage: patchwork members <list|set-password <memberId>>\n");
4337
+ process.exit(2);
4338
+ }
4339
+ catch (err) {
4340
+ process.stderr.write(`[members] ${err.message}\n`);
4341
+ process.exit(2);
4342
+ }
4343
+ })();
4344
+ }
4234
4345
  // Handle butler subcommand — SHADOW-only errand outcome grading.
4235
4346
  //
4236
4347
  // Operator path, like `outcomes` above and for the same reason: a recipe step