@adam0white/human-framework 2.8.0 → 2.9.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.
@@ -108,13 +108,21 @@ export declare const violatesAbstention: (c: Commitment, action: string, start:
108
108
  * keeps by the same rule, so a pull never leads to an activity that cannot keep the commitment.
109
109
  */
110
110
  export declare const spanMeetsWindow: (c: Commitment, start: Minute, end: Minute) => boolean;
111
+ /**
112
+ * The agenda's utility terms for one offer: `commitment:<id>` toward a pending commitment it serves,
113
+ * `abstain:<id>` against an act that would break an open abstention, and `goal:<id>` toward an active goal it
114
+ * advances. Goal terms are never negative, by design: a goal draws a person toward what advances it and is silent
115
+ * about everything else, so it cannot weigh against a temptation. A host that wants an act opposed links it to a
116
+ * held norm (`norms: violates`), an `abstain` commitment, or a commitment kept by the alternative.
117
+ */
111
118
  export declare function agendaTerms(p: Person, aff: Affordance, now: Minute): Term[];
112
119
  /**
113
120
  * Apply a finished activity. Only completed outcomes count. A completed violating action breaks every open
114
121
  * abstention whose window its span overlaps (returned as `broken`; the composite routes them like missed
115
122
  * commitments, with a breach when a norm is linked). One completion keeps the earliest-ending
116
123
  * matching pending commitment whose window the activity's span [startedAt, outcome.at] overlaps (see
117
- * `spanMeetsWindow`; `startedAt` defaults to `outcome.at`); goals advance by their listed amount.
124
+ * `spanMeetsWindow`; `startedAt` defaults to `outcome.at`), except that when `outcome.fulfills` lists two or more
125
+ * ids, every listed commitment that qualifies is kept and no other (2.9.0); goals advance by their listed amount.
118
126
  * Matching is by action (and target), or explicitly by `outcome.fulfills` / `outcome.advances` (the composite
119
127
  * defaults these to the activity's `Affordance.fulfills` / `advances`), mirroring `agendaTerms`.
120
128
  */
@@ -504,6 +504,13 @@ export function temporalGoalValue(p, g, aff, now, completion) {
504
504
  dpow(0.5, Math.max(0, completion - now) / g.temporal.halfLifeMinutes)
505
505
  : base;
506
506
  }
507
+ /**
508
+ * The agenda's utility terms for one offer: `commitment:<id>` toward a pending commitment it serves,
509
+ * `abstain:<id>` against an act that would break an open abstention, and `goal:<id>` toward an active goal it
510
+ * advances. Goal terms are never negative, by design: a goal draws a person toward what advances it and is silent
511
+ * about everything else, so it cannot weigh against a temptation. A host that wants an act opposed links it to a
512
+ * held norm (`norms: violates`), an `abstain` commitment, or a commitment kept by the alternative.
513
+ */
507
514
  export function agendaTerms(p, aff, now) {
508
515
  return agendaTermsWithGoalEstimates(p, aff, now);
509
516
  }
@@ -599,7 +606,8 @@ function excuseUnderNecessity(p, c, at, catalog) {
599
606
  * abstention whose window its span overlaps (returned as `broken`; the composite routes them like missed
600
607
  * commitments, with a breach when a norm is linked). One completion keeps the earliest-ending
601
608
  * matching pending commitment whose window the activity's span [startedAt, outcome.at] overlaps (see
602
- * `spanMeetsWindow`; `startedAt` defaults to `outcome.at`); goals advance by their listed amount.
609
+ * `spanMeetsWindow`; `startedAt` defaults to `outcome.at`), except that when `outcome.fulfills` lists two or more
610
+ * ids, every listed commitment that qualifies is kept and no other (2.9.0); goals advance by their listed amount.
603
611
  * Matching is by action (and target), or explicitly by `outcome.fulfills` / `outcome.advances` (the composite
604
612
  * defaults these to the activity's `Affordance.fulfills` / `advances`), mirroring `agendaTerms`.
605
613
  */
@@ -627,21 +635,34 @@ export function onFinished(p, outcome, startedAt = outcome.at, opts = {}) {
627
635
  broken.push(c);
628
636
  oweFor(p, c, 'broken', outcome.at);
629
637
  }
630
- let best;
631
- for (const c of p.agenda.commitments) {
632
- if (c.status !== 'pending' || c.kind === 'abstain')
633
- continue;
634
- const explicit = outcome.fulfills?.includes(c.id) ?? false;
635
- if (!explicit && !matchesCommitment(c, outcome.action, outcome.targetId))
636
- continue;
637
- if (!spanMeetsWindow(c, Math.min(startedAt, outcome.at), outcome.at))
638
- continue;
639
- if (!best || c.until < best.until)
640
- best = c;
638
+ // Commitments named explicitly (2.9.0): when `fulfills` lists two or more ids, every listed commitment that is
639
+ // eligible is kept and no other (one dinner keeps a word to a friend and the daily call). With one id listed, or
640
+ // none of several eligible, one completion keeps the earliest-ending match, explicit or by action, as before.
641
+ const eligible = (c) => c.status === 'pending' && c.kind !== 'abstain' && spanMeetsWindow(c, start, outcome.at);
642
+ const named = outcome.fulfills
643
+ ? p.agenda.commitments.filter((c) => (outcome.fulfills?.includes(c.id) ?? false) && eligible(c))
644
+ : [];
645
+ if (new Set(outcome.fulfills ?? []).size > 1 && named.length > 0) {
646
+ for (const c of named) {
647
+ c.status = 'kept';
648
+ kept.push(c);
649
+ }
641
650
  }
642
- if (best) {
643
- best.status = 'kept';
644
- kept.push(best);
651
+ else {
652
+ let best;
653
+ for (const c of p.agenda.commitments) {
654
+ if (!eligible(c))
655
+ continue;
656
+ const explicit = outcome.fulfills?.includes(c.id) ?? false;
657
+ if (!explicit && !matchesCommitment(c, outcome.action, outcome.targetId))
658
+ continue;
659
+ if (!best || c.until < best.until)
660
+ best = c;
661
+ }
662
+ if (best) {
663
+ best.status = 'kept';
664
+ kept.push(best);
665
+ }
645
666
  }
646
667
  for (const g of p.agenda.goals) {
647
668
  if (g.status !== 'active')
@@ -254,11 +254,11 @@ export function consider(p, aff, ctx) {
254
254
  const K = COGNITION_DEFAULTS;
255
255
  const scarcity = clamp01(Number.isFinite(ctx.scarcity) ? ctx.scarcity : 0);
256
256
  const terms = [];
257
- const push = (source, value) => {
257
+ const push = (source, value, ref) => {
258
258
  const v = round(value);
259
259
  // A non-finite term (bad host number) is dropped; the will vetoes the offer as invalid.
260
260
  if (v !== 0 && Number.isFinite(v))
261
- terms.push({ source, value: v });
261
+ terms.push(ref === undefined ? { source, value: v } : { source, value: v, ref });
262
262
  };
263
263
  // Needs: urgency × believed gain × believed success.
264
264
  const expected = expectedEffect(p, aff);
@@ -459,7 +459,7 @@ export function consider(p, aff, ctx) {
459
459
  // Evening review (2.7.0, opt-in): a resolve formed after a lapse leans on that commitment's own term.
460
460
  if (p.review)
461
461
  for (const t of reviewTerms(p, terms))
462
- push(t.source, t.value);
462
+ push(t.source, t.value, t.ref);
463
463
  let utility = 0;
464
464
  for (const t of terms)
465
465
  utility += t.value;
@@ -541,12 +541,14 @@ export function decide(p, affordances, ctx) {
541
541
  const prompts = [];
542
542
  const tagsOf = new Map(sorted.map((a) => [a.id, a.tags]));
543
543
  for (const c of res.considered) {
544
- const seen = promptIn(c.terms, tagsOf.get(c.affordanceId));
544
+ const tags = tagsOf.get(c.affordanceId);
545
+ const seen = promptIn(c.terms, tags);
545
546
  if (seen)
546
547
  prompts.push({
547
548
  affordanceId: c.affordanceId,
548
549
  action: c.action,
549
550
  ...seen,
551
+ ...(tags !== undefined ? { tags } : {}),
550
552
  // A vetoed option was not turned from: the will blocked it, so the response earns no practice.
551
553
  turnApplied: c.vetoed === undefined && c.terms.some((t) => t.source === 'promptings:turn'),
552
554
  });
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework v1 public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.8.0";
2
+ export declare const FRAMEWORK_VERSION = "2.9.0";
3
3
  export type { CrisisEvent, PracticeKind } from './affect/index.ts';
4
4
  export { AFFECT_DEFAULTS, actionTendencies, advanceAffect, appraise, breakAllows, breakBehaviour, breakHazard, CRISIS_DEFAULTS, checkCrisis, createAffect, easeBreak, emotionLevel, enableBreaks, feel, inBreak, readAffect, regulate, release as releaseEmotion, skipCrisis, strain, tendencyEmotions, } from './affect/index.ts';
5
5
  export type { GoalTemplate, PrayerCalendar, PrayerSchedule, PrayerTimes, Retimer, } from './agenda/index.ts';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework v1 public surface. */
2
- export const FRAMEWORK_VERSION = '2.8.0';
2
+ export const FRAMEWORK_VERSION = '2.9.0';
3
3
  export { AFFECT_DEFAULTS, actionTendencies, advanceAffect, appraise, breakAllows, breakBehaviour, breakHazard, CRISIS_DEFAULTS, checkCrisis, createAffect, easeBreak, emotionLevel, enableBreaks, feel, inBreak, readAffect, regulate, release as releaseEmotion, skipCrisis, strain, tendencyEmotions, } from './affect/index.js';
4
4
  // `agenda.release` collides with `affect.release`; the agenda one is re-exported under a clearer name.
5
5
  export { AGENDA_DEFAULTS, abandonGoal, adoptGoal, advanceAgenda, agendaTerms, applyExemptions, CARE_DEFAULTS, calendarRetimer, careDuty, commitmentPressure, createAgenda, DEFAULT_PRAYER_TIMES, EID_PRAYER_IMPORTANCE, EXEMPTION_DEFAULTS, eidPrayer, eidWindow, fastWindow, iftarWindow, inMakruhTime, isUnderWay, MAKRUH_DEFAULTS, makruhWindows, missedExcuse, onFinished, owedMakeUps, PRAYER_IMPORTANCE, PURPOSE_DEFAULTS, prayerWindows, pressureReachedAt, promise, proposeGoals, RAMADAN_DEFAULTS, ramadanFast, ramadanMeals, release as releaseCommitment, retimeCommitments, revisePurposes, scheduleMakeUp, spanMeetsWindow, suhoorWindow, timesFor, violatesAbstention, } from './agenda/index.js';
package/dist/migrate.js CHANGED
@@ -58,6 +58,11 @@ export const MIGRATIONS = {
58
58
  note: 'the surroundings field `ambient.now` is renamed `ambient.percept`; per-person retention is optional and starts absent; behaviour unchanged',
59
59
  apply: renameAmbient,
60
60
  },
61
+ '2.8.0': {
62
+ to: '2.9.0',
63
+ note: 'prompting responses may carry a cue and the slice a practice half-life (both optional, absent as before); review terms may carry a ref; an outcome naming two or more commitments in fulfills now keeps each of them, where 2.8.0 kept the earliest-ending one',
64
+ apply: stamp,
65
+ },
61
66
  '2.7.0': {
62
67
  to: '2.8.0',
63
68
  note: 'fullness (body.fullness) is optional and starts absent; offers may name alongside partners and decisions may reconsider; existing hosts retain exact behavior',
@@ -26,8 +26,17 @@ export declare const PROMPTINGS_DEFAULTS: {
26
26
  * keeps its practice and last use (the spec's `practice` only seeds new responses). Returns the state.
27
27
  */
28
28
  export declare function enablePromptings(p: Person, spec?: PromptingsSpec): PromptingsState;
29
- /** Practice of a response at `now`, after disuse decay. Pure. */
30
- export declare function practiceAt(r: PromptingResponse, now: Minute): Unit;
29
+ /** Practice of a response at `now`, after disuse decay (`halfLifeDays` defaults to 60, as before 2.9.0). Pure. */
30
+ export declare function practiceAt(r: PromptingResponse, now: Minute, halfLifeDays?: number): Unit;
31
+ /** What a prompt offers a cue to match (2.9.0). */
32
+ export interface PromptContext {
33
+ action: string;
34
+ tags?: readonly string[] | undefined;
35
+ /** The opposing term's source: 'norm:x' or 'goal:g'. */
36
+ opposedBy: string;
37
+ }
38
+ /** Whether a response's cue matches a prompt: its action, one of its tags, or the opposing norm or goal. Pure. */
39
+ export declare function responseCueMatches(cue: string, at: PromptContext): boolean;
31
40
  /** Whether an option's terms carry a noticed prompt: its strongest pull and the opposing term. Pure. */
32
41
  export declare function promptIn(terms: readonly Term[], tags: readonly string[] | undefined): {
33
42
  source: string;
@@ -46,6 +55,8 @@ export interface PromptingSeen {
46
55
  opposedBy: string;
47
56
  /** Whether a turning-away term was applied. */
48
57
  turnApplied: boolean;
58
+ /** The offer's tags (2.9.0), for matching a response's cue. */
59
+ tags?: readonly string[] | undefined;
49
60
  }
50
61
  /** What `notePromptings` did, for the composite (the `fault` guilt goes through affect). */
51
62
  export interface PromptingsNoted {
@@ -68,10 +79,12 @@ export interface PromptingsReadingView {
68
79
  turned: number;
69
80
  passed: number;
70
81
  assented: number;
82
+ /** `cue` only when the response has one (2.9.0). */
71
83
  responses: {
72
84
  id: string;
73
85
  kind: PromptingResponseKind;
74
86
  practice: Unit;
87
+ cue?: string;
75
88
  }[];
76
89
  /** Vocabulary for the last few prompts, newest last. */
77
90
  lines: string[];
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Detection reads an option's traced terms and changes none of them: the pull is the sum of positive `habit`,
10
10
  * `emotion:*`, `need:*` and `promptings:echo` terms (every positive term on an offer tagged `temptation`); it is a
11
- * prompt when the pull reaches `noticeAt` while a `norm:*` or `goal:*` term on the same option is negative.
11
+ * prompt when the pull reaches `noticeAt` while a `norm:*` or `goal:*` term on the same option is negative (goal
12
+ * terms are never negative, see `agendaTerms`, so in practice the opposition is a held norm).
12
13
  * Abstention windows are not detected (they veto, and carry no term). A person with a response gets one extra
13
14
  * term, `promptings:turn` = -turnMax × practice, on that option; it is bounded and absent at survival desperation,
14
15
  * so a desperate bodily need still wins (2:286). Each prompt is recorded once per decision point (a review that
@@ -16,6 +17,16 @@
16
17
  * or `passed` (not chosen, no response). Practice grows with diminishing gains once per decision point where the
17
18
  * response was used (more when it held; a vetoed option earns none, since the will blocked it), and halves over `practiceHalfLifeDays` of disuse.
18
19
  *
20
+ * Cued responses and host time (2.9.0, both opt-in): a response may carry a `cue` (an action, an offer tag, or the
21
+ * opposing norm or goal), for a phrase learned at one pull. At a prompt the person uses the most practised response
22
+ * whose cue matches it; when none matches, the most practised response without a cue. A cued response never serves
23
+ * a prompt it does not match, so a phrase learned at the alarm does not also turn the person from gossip (the
24
+ * literal "global best" fallback would bring that back). Each response used at a decision point gains once. Without
25
+ * cues every response is uncued and this is the 2.4.0 rule exactly. `practiceHalfLifeDays` in `enablePromptings`
26
+ * replaces the 60-day half-life for the whole person (a one-week game can let a phrase fade); it is one setting per
27
+ * person, not per response, because the need is the host's time scale and no source gives phrases different decay.
28
+ * Practice is decayed lazily from `lastUsedAt`, so a new half-life also applies to disuse already elapsed.
29
+ *
19
30
  * Held readings (reconciliation.md §5): `whisper`, `inclination`, `intrusion` and `none` give identical decisions
20
31
  * and differ only in the vocabulary of `promptingLine`. `fault` (the scrupulous reading, which both the tradition
21
32
  * and clinicians reject) adds a small guilt emotion at the prompt itself, from the person's own understanding;
@@ -71,7 +82,14 @@ export function enablePromptings(p, spec = {}) {
71
82
  continue;
72
83
  const held = prev?.responses.find((x) => x.id === r.id);
73
84
  const seed = Number.isFinite(r.practice) ? clamp01(r.practice) : 0.1;
74
- responses.push(held ? { ...held, kind: r.kind } : { id: r.id, kind: r.kind, practice: seed, lastUsedAt: p.now });
85
+ const next = held
86
+ ? { ...held, kind: r.kind }
87
+ : { id: r.id, kind: r.kind, practice: seed, lastUsedAt: p.now };
88
+ // The spec's cue wins; a response re-enabled without one loses its old cue.
89
+ delete next.cue;
90
+ if (cueOk(r.cue))
91
+ next.cue = r.cue;
92
+ responses.push(next);
75
93
  }
76
94
  const state = {
77
95
  reading: spec.reading && READINGS.includes(spec.reading) ? spec.reading : 'none',
@@ -82,22 +100,44 @@ export function enablePromptings(p, spec = {}) {
82
100
  };
83
101
  if (spec.readingSource !== undefined)
84
102
  state.readingSource = spec.readingSource;
103
+ if (halfLifeOk(spec.practiceHalfLifeDays))
104
+ state.practiceHalfLifeDays = spec.practiceHalfLifeDays;
85
105
  p.promptings = state;
86
106
  return state;
87
107
  }
88
- /** Practice of a response at `now`, after disuse decay. Pure. */
89
- export function practiceAt(r, now) {
90
- return clamp01(halve(r.practice, now - r.lastUsedAt, PROMPTINGS_DEFAULTS.practiceHalfLifeDays));
108
+ const cueOk = (x) => typeof x === 'string' && x !== '' && x.length <= 200;
109
+ const halfLifeOk = (x) => typeof x === 'number' && Number.isFinite(x) && x > 0 && x <= 36500;
110
+ const halfLifeOf = (s) => s.practiceHalfLifeDays ?? PROMPTINGS_DEFAULTS.practiceHalfLifeDays;
111
+ /** Practice of a response at `now`, after disuse decay (`halfLifeDays` defaults to 60, as before 2.9.0). Pure. */
112
+ export function practiceAt(r, now, halfLifeDays = PROMPTINGS_DEFAULTS.practiceHalfLifeDays) {
113
+ return clamp01(halve(r.practice, now - r.lastUsedAt, halfLifeDays));
114
+ }
115
+ /** Whether a response's cue matches a prompt: its action, one of its tags, or the opposing norm or goal. Pure. */
116
+ export function responseCueMatches(cue, at) {
117
+ if (cue === at.action || cue === at.opposedBy || (at.tags?.includes(cue) ?? false))
118
+ return true;
119
+ const colon = at.opposedBy.indexOf(':');
120
+ return colon >= 0 && cue === at.opposedBy.slice(colon + 1);
91
121
  }
92
- /** The response a person would use now: highest practice, ties by id. */
93
- function bestResponse(s, now) {
94
- let best;
122
+ /**
123
+ * The response a person would use now at prompt `at`: the most practised whose cue matches, else the most
124
+ * practised without a cue; ties by id. Without cues this is the most practised response (2.4.0).
125
+ */
126
+ function bestResponse(s, now, at) {
127
+ let cued;
128
+ let plain;
129
+ const better = (b, r, x) => !b || x > b.practice || (x === b.practice && r.id < b.r.id);
130
+ const half = halfLifeOf(s);
95
131
  for (const r of s.responses) {
96
- const practice = practiceAt(r, now);
97
- if (!best || practice > best.practice || (practice === best.practice && r.id < best.r.id))
98
- best = { r, practice };
132
+ const practice = practiceAt(r, now, half);
133
+ if (r.cue === undefined) {
134
+ if (better(plain, r, practice))
135
+ plain = { r, practice };
136
+ }
137
+ else if (responseCueMatches(r.cue, at) && better(cued, r, practice))
138
+ cued = { r, practice };
99
139
  }
100
- return best;
140
+ return cued ?? plain;
101
141
  }
102
142
  const isPull = (source, tagged) => tagged ||
103
143
  source === 'habit' ||
@@ -145,10 +185,13 @@ export function promptingTerms(p, action, terms, tags, now, desperation) {
145
185
  }
146
186
  if (desperation >= PROMPTINGS_DEFAULTS.survivalDesperation)
147
187
  return out;
148
- const best = bestResponse(s, now);
149
- if (!best || best.practice <= 0)
188
+ if (s.responses.length === 0)
150
189
  return out;
151
- if (!promptIn([...terms, ...out], tags))
190
+ const seen = promptIn([...terms, ...out], tags);
191
+ if (!seen)
192
+ return out;
193
+ const best = bestResponse(s, now, { action, tags, opposedBy: seen.opposedBy });
194
+ if (!best || best.practice <= 0)
152
195
  return out;
153
196
  const v = round(-PROMPTINGS_DEFAULTS.turnMax * best.practice);
154
197
  if (v < 0)
@@ -165,11 +208,12 @@ export function notePromptings(p, seen, chosenId, now) {
165
208
  if (!s || seen.length === 0)
166
209
  return noted;
167
210
  const D = PROMPTINGS_DEFAULTS;
168
- const best = bestResponse(s, now);
169
- // One practice gain per decision point, however many options carried a prompt: the larger one if any held.
170
- let gain = 0;
211
+ // One practice gain per response per decision point, however many options it served: the larger one if any held.
212
+ // Responses are chosen before any practice moves, as `promptingTerms` chose them.
213
+ const gains = new Map();
171
214
  for (const x of seen) {
172
215
  const assented = x.affordanceId === chosenId;
216
+ const best = bestResponse(s, now, x);
173
217
  const used = x.turnApplied && best !== undefined;
174
218
  const rec = {
175
219
  at: now,
@@ -181,7 +225,7 @@ export function notePromptings(p, seen, chosenId, now) {
181
225
  };
182
226
  if (used && best) {
183
227
  rec.responseId = best.r.id;
184
- gain = Math.max(gain, assented ? D.gainAttempt : D.gainTurned);
228
+ gains.set(best.r, Math.max(gains.get(best.r) ?? 0, assented ? D.gainAttempt : D.gainTurned));
185
229
  }
186
230
  s.recent.push(rec);
187
231
  noted.records.push(rec);
@@ -194,10 +238,12 @@ export function notePromptings(p, seen, chosenId, now) {
194
238
  }
195
239
  }
196
240
  }
197
- if (best && gain > 0) {
198
- const practice = practiceAt(best.r, now);
199
- best.r.practice = round(clamp01(practice + gain * (1 - practice)));
200
- best.r.lastUsedAt = now;
241
+ for (const [r, gain] of gains) {
242
+ if (gain <= 0)
243
+ continue;
244
+ const practice = practiceAt(r, now, halfLifeOf(s));
245
+ r.practice = round(clamp01(practice + gain * (1 - practice)));
246
+ r.lastUsedAt = now;
201
247
  }
202
248
  if (s.recent.length > D.maxRecent)
203
249
  s.recent.splice(0, s.recent.length - D.maxRecent);
@@ -249,7 +295,12 @@ export function promptingsReading(p, now = p.now) {
249
295
  turned: count('turned'),
250
296
  passed: count('passed'),
251
297
  assented: count('assented'),
252
- responses: s.responses.map((r) => ({ id: r.id, kind: r.kind, practice: round(practiceAt(r, now)) })),
298
+ responses: s.responses.map((r) => ({
299
+ id: r.id,
300
+ kind: r.kind,
301
+ practice: round(practiceAt(r, now, halfLifeOf(s))),
302
+ ...(r.cue !== undefined ? { cue: r.cue } : {}),
303
+ })),
253
304
  lines: s.recent.slice(-5).map((r) => promptingLine(s.reading, r.outcome, kindOf(r.responseId))),
254
305
  };
255
306
  if (s.readingSource !== undefined)
@@ -298,12 +349,15 @@ export function sanitizePromptings(input, now) {
298
349
  continue;
299
350
  if (!minute(r.lastUsedAt) || r.lastUsedAt > now || responses.some((x) => x.id === r.id))
300
351
  continue;
301
- responses.push({
352
+ const kept = {
302
353
  id: r.id,
303
354
  kind: r.kind,
304
355
  practice: r.practice,
305
356
  lastUsedAt: r.lastUsedAt,
306
- });
357
+ };
358
+ if (cueOk(r.cue))
359
+ kept.cue = r.cue;
360
+ responses.push(kept);
307
361
  }
308
362
  const recent = [];
309
363
  for (const r of input.recent.slice(-D.maxRecent)) {
@@ -344,5 +398,7 @@ export function sanitizePromptings(input, now) {
344
398
  };
345
399
  if (str(input.readingSource))
346
400
  out.readingSource = input.readingSource;
401
+ if (halfLifeOk(input.practiceHalfLifeDays))
402
+ out.practiceHalfLifeDays = input.practiceHalfLifeDays;
347
403
  return out;
348
404
  }
@@ -3,7 +3,8 @@
3
3
  * the commitments the chronicle recorded as kept or broken since their last review, and for each one they broke
4
4
  * forms or renews a *resolve*: an if–then intention for that commitment's next occasion (Gollwitzer & Sheeran
5
5
  * 2006, research/reconciliation.md E1). While a matching commitment is pending, the resolve adds one bounded term,
6
- * `review:resolve` (each resolve counted once per option, the sum clamped to ±`resolveScale`), with the sign of the commitment's own term (toward a duty, away from an abstention's breaking
6
+ * `review:resolve` (each resolve counted once per option, the sum clamped to ±`resolveScale`; 2.9.0: the term's
7
+ * `ref` is the resolve's key when exactly one resolve weighed in), with the sign of the commitment's own term (toward a duty, away from an abstention's breaking
7
8
  * act), scaled by its strength. Strength grows with diminishing gains at each further lapse, relaxes once the
8
9
  * commitment is kept, and fades over `halfLifeDays` of disuse. Released and excused commitments (exemptions,
9
10
  * sleep, necessity) form no resolve: what was beyond capacity is not held against the person (2:286). The review
@@ -23,6 +24,11 @@
23
24
  * piety, repentance as a ruling (tawba conditions are not modelled), effects on sleep onset (Harvey's model, E6, is
24
25
  * not evidence enough), or that review improves outcomes beyond the borrowed if–then shape. It draws no randomness;
25
26
  * without `enableReview` nothing here runs and existing runs replay byte for byte.
27
+ *
28
+ * Host notes (2.9.0, docs only). `reviewLine` sets commitment labels into "I didn't keep …" and "Today I kept …",
29
+ * so a label should be a noun phrase ("my call to Mum", "the evening prayer"), not a verb phrase ("calling Mum").
30
+ * A recurring commitment lapses on any day its window passes unlived: a host that skips a day (no service, a
31
+ * fast-forward) should still live, or release, that day's occasions, or the review counts each one as missed.
26
32
  */
27
33
  import { clamp01, dexp, isObj, minuteOfDay, round } from '../core/index.js';
28
34
  export const REVIEW_DEFAULTS = {
@@ -72,6 +78,7 @@ export function reviewTerms(p, terms) {
72
78
  return [];
73
79
  let value = 0;
74
80
  const used = new Set();
81
+ let only;
75
82
  for (const t of terms) {
76
83
  const m = /^(commitment|abstain):(.+)$/.exec(t.source);
77
84
  if (!m || t.value === 0)
@@ -83,11 +90,18 @@ export function reviewTerms(p, terms) {
83
90
  if (!r || used.has(r.key))
84
91
  continue;
85
92
  used.add(r.key);
93
+ only = r.key;
86
94
  value += Math.sign(t.value) * REVIEW_DEFAULTS.resolveScale * r.strength;
87
95
  }
88
96
  const max = REVIEW_DEFAULTS.resolveScale;
89
97
  value = Math.max(-max, Math.min(max, value));
90
- return value === 0 ? [] : [{ source: 'review:resolve', value: round(value) }];
98
+ if (value === 0)
99
+ return [];
100
+ const term = { source: 'review:resolve', value: round(value) };
101
+ // Name the resolve when exactly one weighed in (2.9.0, The Pass): "he turned from the jar, as he said he would".
102
+ if (used.size === 1 && only !== undefined)
103
+ term.ref = only;
104
+ return [term];
91
105
  }
92
106
  const fade = (s, days) => days <= 0 ? s : s * dexp((-Math.LN2 * days) / REVIEW_DEFAULTS.halfLifeDays);
93
107
  /**
package/dist/types.d.ts CHANGED
@@ -511,7 +511,10 @@ export interface Commitment {
511
511
  status: 'pending' | 'kept' | 'broken' | 'released';
512
512
  /** Optional recurrence: re-create after it closes (e.g. daily prayer windows). */
513
513
  recurEvery?: number;
514
- /** Optional display name, e.g. 'Maghrib'; used in counter-offers ("after I pray Maghrib"). */
514
+ /**
515
+ * Optional display name, e.g. 'Maghrib'; used in counter-offers ("after I pray Maghrib") and in `reviewLine`
516
+ * ("I didn't keep my call to Mum"), so write it as a noun phrase.
517
+ */
515
518
  label?: string;
516
519
  /** For kind 'abstain': action ids whose completion inside the window breaks it. */
517
520
  violatedBy?: string[];
@@ -837,7 +840,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
837
840
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
838
841
  * `dutyReviewAt`). Person shape unchanged.
839
842
  */
840
- export declare const ENGINE_VERSION = "2.8.0";
843
+ export declare const ENGINE_VERSION = "2.9.0";
841
844
  /** A pregnancy carried by this person (host-driven; see `family/`). */
842
845
  export interface Pregnancy {
843
846
  fatherId: PersonId;
@@ -1100,7 +1103,11 @@ export interface Affordance {
1100
1103
  /** Free tags: 'work', 'leisure', 'social', 'worship', 'risky', 'outdoors', ... */
1101
1104
  tags?: string[];
1102
1105
  norms?: NormTag[];
1103
- /** Commitment ids this would fulfil if completed. */
1106
+ /**
1107
+ * Commitment ids this would fulfil if completed. Listing two or more keeps every listed one that is pending and
1108
+ * in window, and no other (2.9.0), not even an earlier-ending match by action; listing one keeps the
1109
+ * earliest-ending match, explicit or by action, as before.
1110
+ */
1104
1111
  fulfills?: string[];
1105
1112
  /** Goal ids this advances. */
1106
1113
  advances?: string[];
@@ -1249,7 +1256,11 @@ export interface Outcome {
1249
1256
  * under protest and 1 otherwise. Lower quality lowers felt valence and therefore learned expectations.
1250
1257
  */
1251
1258
  quality?: Unit;
1252
- /** Commitment ids this outcome keeps (defaults to the activity's `Affordance.fulfills`). */
1259
+ /**
1260
+ * Commitment ids this outcome keeps (defaults to the activity's `Affordance.fulfills`). With two or more listed,
1261
+ * every listed one pending and in window is kept, and no other (2.9.0); with one, one completion keeps the
1262
+ * earliest-ending match.
1263
+ */
1253
1264
  fulfills?: string[];
1254
1265
  /** Goal ids this outcome advances (defaults to the activity's `Affordance.advances`). */
1255
1266
  advances?: string[];
@@ -1333,9 +1344,16 @@ export interface Term {
1333
1344
  * 'goal:g1', 'habit', 'emotion:fear:risky' (emotion id, then the tag it acts through), 'effort', 'risk',
1334
1345
  * 'suggestion:player', 'social:p2', 'expectation', 'autonomy' (reactance to a pushing voice),
1335
1346
  * 'promptings:turn' (a practised turning-away), 'forecast:body' (expected
1336
- * bodily state during `followedBy` spans), 'promptings:echo' (recurrence under the `fault` reading) */
1347
+ * bodily state during `followedBy` spans), 'promptings:echo' (recurrence under the `fault` reading),
1348
+ * 'review:resolve' (a resolve from the evening review) */
1337
1349
  source: string;
1338
1350
  value: number;
1351
+ /**
1352
+ * What a generic source refers to (2.9.0), so `source` stays stable for hosts that match it exactly. Set on
1353
+ * `review:resolve` to the resolve's key (`Resolve.key`) when exactly one resolve weighed in; absent when several
1354
+ * did. Absent on every other term.
1355
+ */
1356
+ ref?: string;
1339
1357
  }
1340
1358
  export interface Considered {
1341
1359
  affordanceId: string;
@@ -1455,6 +1473,13 @@ export interface PromptingResponse {
1455
1473
  /** Skill-like practice 0..1 as of `lastUsedAt`; it decays slowly with disuse. */
1456
1474
  practice: Unit;
1457
1475
  lastUsedAt: Minute;
1476
+ /**
1477
+ * What this response was learned for (2.9.0): an action, an offer tag, or the opposing norm or goal (`n1`,
1478
+ * `norm:n1` or `goal:g1`). A cued response serves only prompts it matches; a response without a cue serves every
1479
+ * prompt that no cued response matches. A norm or goal cue matches only the prompt's strongest opposing term.
1480
+ * Cues longer than 200 characters are ignored.
1481
+ */
1482
+ cue?: string;
1458
1483
  }
1459
1484
  export interface PromptingRecord {
1460
1485
  at: Minute;
@@ -1482,6 +1507,8 @@ export interface PromptingsState {
1482
1507
  }>;
1483
1508
  /** Echo gained per prompt under `fault`. Default 0: the recurrence term is a host-set assumption (P3 contested). */
1484
1509
  echoRate: number;
1510
+ /** Days over which disused practice halves (2.9.0, host-set); absent = `PROMPTINGS_DEFAULTS.practiceHalfLifeDays`. */
1511
+ practiceHalfLifeDays?: number;
1485
1512
  }
1486
1513
  /**
1487
1514
  * How a person reviews a day (2.7.0, a held reading): `accounting` (muhasaba: name the lapse and resolve for its
@@ -1528,12 +1555,21 @@ export interface ReviewSpec {
1528
1555
  export interface PromptingsSpec {
1529
1556
  reading?: PromptingReading;
1530
1557
  readingSource?: string;
1558
+ /** `cue` (2.9.0): what the response was learned for; see `PromptingResponse.cue`. */
1531
1559
  responses?: {
1532
1560
  id: string;
1533
1561
  kind: PromptingResponseKind;
1534
1562
  practice?: Unit;
1563
+ cue?: string;
1535
1564
  }[];
1536
1565
  echoRate?: number;
1566
+ /**
1567
+ * Days over which disused practice halves (2.9.0), for hosts whose time runs short (a one-week game). Applies to
1568
+ * every response of the person, including disuse since a response's last use. Absent = 60 days, as before.
1569
+ * Read on every `enablePromptings` call: re-enabling without it returns the person to the default. Values outside
1570
+ * (0, 36500] are ignored.
1571
+ */
1572
+ practiceHalfLifeDays?: number;
1537
1573
  }
1538
1574
  /** What the body is doing over an interval. */
1539
1575
  export interface BodyLoad {
package/dist/types.js CHANGED
@@ -120,4 +120,4 @@ export const PERSON_SCHEMA = 'human/person@1';
120
120
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
121
121
  * `dutyReviewAt`). Person shape unchanged.
122
122
  */
123
- export const ENGINE_VERSION = '2.8.0';
123
+ export const ENGINE_VERSION = '2.9.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Deterministic, explainable simulated people: body, needs, affect, memory, beliefs, skills, habits, relationships, values and a will that can refuse a suggestion",
5
5
  "keywords": [
6
6
  "simulation",