@adam0white/human-framework 2.6.0 → 2.7.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.
@@ -36,6 +36,7 @@ import { urgencyOf } from '../needs/index.js';
36
36
  import { partneringTerms } from '../partnering/index.js';
37
37
  import { promptIn, promptingTerms } from '../promptings/index.js';
38
38
  import { bodyForecastTerm, receivedGoalRoute } from '../prospection/index.js';
39
+ import { reviewTerms } from '../review/index.js';
39
40
  import { companionSteadiness, socialTerms } from '../social/index.js';
40
41
  import { PHYSIOLOGICAL_NEEDS, VALUE_KEYS } from '../types.js';
41
42
  import { adviceWeight, resolveChoice, safeUtility, standingAdvice, suggestionTargets, voicesIn, } from '../will/index.js';
@@ -409,6 +410,10 @@ export function consider(p, aff, ctx) {
409
410
  if (p.promptings)
410
411
  for (const t of promptingTerms(p, aff.action, terms, aff.tags, ctx.now, ctx.desperation))
411
412
  push(t.source, t.value);
413
+ // Evening review (2.7.0, opt-in): a resolve formed after a lapse leans on that commitment's own term.
414
+ if (p.review)
415
+ for (const t of reviewTerms(p, terms))
416
+ push(t.source, t.value);
412
417
  let utility = 0;
413
418
  for (const t of terms)
414
419
  utility += t.value;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework v1 public surface. */
2
- export declare const FRAMEWORK_VERSION = "2.6.0";
2
+ export declare const FRAMEWORK_VERSION = "2.7.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';
@@ -32,6 +32,7 @@ export * from './promptings/index.ts';
32
32
  export type { ConsequenceEvidence } from './prospection/index.ts';
33
33
  export { bodyForecastFor, bodyForecastTerm, consequenceEvidence, FORECAST_DEFAULTS, forecastLine, PROSPECTION_DEFAULTS, } from './prospection/index.ts';
34
34
  export * from './restore.ts';
35
+ export * from './review/index.ts';
35
36
  export * from './scenarios/index.ts';
36
37
  export * from './sim/index.ts';
37
38
  export * from './skills/index.ts';
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Human Framework v1 public surface. */
2
- export const FRAMEWORK_VERSION = '2.6.0';
2
+ export const FRAMEWORK_VERSION = '2.7.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';
@@ -28,6 +28,7 @@ export * from './person.js';
28
28
  export * from './promptings/index.js';
29
29
  export { bodyForecastFor, bodyForecastTerm, consequenceEvidence, FORECAST_DEFAULTS, forecastLine, PROSPECTION_DEFAULTS, } from './prospection/index.js';
30
30
  export * from './restore.js';
31
+ export * from './review/index.js';
31
32
  export * from './scenarios/index.js';
32
33
  export * from './sim/index.js';
33
34
  export * from './skills/index.js';
package/dist/migrate.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * field of the optional surroundings slice (`ambient.now` → `ambient.percept`) and changes no behaviour (the optional `social.impressions` and `social.reserve`, like `retention`, are optional and start
9
9
  * absent in a migrated save); `restore` then fills and sanitizes
10
10
  * the slices as for any save. 2.0.0 to 2.1.0 adds optional conditional method state, absent
11
- * until explicitly used, so existing hosts retain their previous behavior; that step stamps the version. 2.1.0 to 2.2.0 preserves optional method records and stamps the version; successful alternatives now retain independent support. 2.2.0 to 2.3.0 stamps the version; received consequences and temporal goals remain absent unless explicitly supplied. 2.3.0 to 2.4.0 stamps the version; promptings remain absent unless enabled. 2.4.0 to 2.5.0 stamps the version; the bodily forecast reads only offers that declare `followedBy`. 2.5.0 to 2.6.0 stamps the version; the fatigue store stays absent unless enabled. A migrated save continues under the current engine's rules: it restores and runs,
11
+ * until explicitly used, so existing hosts retain their previous behavior; that step stamps the version. 2.1.0 to 2.2.0 preserves optional method records and stamps the version; successful alternatives now retain independent support. 2.2.0 to 2.3.0 stamps the version; received consequences and temporal goals remain absent unless explicitly supplied. 2.3.0 to 2.4.0 stamps the version; promptings remain absent unless enabled. 2.4.0 to 2.5.0 stamps the version; the bodily forecast reads only offers that declare `followedBy`. 2.5.0 to 2.6.0 stamps the version; the fatigue store stays absent unless enabled. 2.6.0 to 2.7.0 stamps the version; the evening review stays absent unless enabled. A migrated save continues under the current engine's rules: it restores and runs,
12
12
  * but it does not reproduce what the old engine would have done next where the rules changed (1.4.0 to 1.5.0
13
13
  * changed how standing advice is heard; 1.8.0 to 1.9.0 changed the omission rule). Saves older than 1.4.0 and unknown versions are refused with an error.
14
14
  *
@@ -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.6.0': {
62
+ to: '2.7.0',
63
+ note: 'the evening review (review) is optional and starts absent; existing hosts retain exact behavior',
64
+ apply: stamp,
65
+ },
61
66
  '2.5.0': {
62
67
  to: '2.6.0',
63
68
  note: "the fatigue store (body.depletion) is optional and starts absent; the bodily forecast now applies an open fast's hunger damping, which changes only offers that declare followedBy",
package/dist/person.js CHANGED
@@ -38,6 +38,7 @@ import { advanceNeeds, createNeeds, levelOf, meanSatisfaction, readNeeds, satisf
38
38
  import { widow } from './partnering/index.js';
39
39
  import { enablePromptings, nafsPatterns, notePromptings } from './promptings/index.js';
40
40
  import { bodyForecastFor, consumeConsequences, forecastLine, pruneConsequences, receiveConsequences, } from './prospection/index.js';
41
+ import { enableReview, reviewIfDue } from './review/index.js';
41
42
  import { enableSkillRetention, learnByWatching, practise, seedSkills, } from './skills/index.js';
42
43
  import { advanceSocial, applyReputationBelief, careFor, closeness, judge, markDeceased, relationshipWith, seedRelationships, socialEvent, threatAppraisal, } from './social/index.js';
43
44
  import { ENGINE_VERSION, MAX_MINUTE, PERSON_SCHEMA, PHYSIOLOGICAL_NEEDS, PSYCHOLOGICAL_NEEDS, TRAIT_KEYS, VALUE_KEYS, } from './types.js';
@@ -158,6 +159,8 @@ export function createPerson(spec) {
158
159
  enableFatigueStore(p);
159
160
  if (on?.promptings)
160
161
  enablePromptings(p, on.promptings);
162
+ if (on?.review)
163
+ enableReview(p, on.review);
161
164
  return p;
162
165
  }
163
166
  /**
@@ -418,6 +421,12 @@ export function tick(p, now) {
418
421
  closed = closeDay(p);
419
422
  applyRecall(p, recallByCue(p, { at: end }));
420
423
  }
424
+ // Evening review (2.7.0, opt-in): from its hour, or at the close of a day that passed without one.
425
+ if (p.review) {
426
+ const done = reviewIfDue(p, end, (p.chronicle ?? []).slice(-2), closed);
427
+ if (done && done.distress > 0)
428
+ feel(p, 'distress', done.distress, 'review', end);
429
+ }
421
430
  if (minuteOfDay(end) === 0) {
422
431
  // Lasting gists (1.8.0, opt-in): at midnight, episodes past the horizon fold into gists.
423
432
  if (p.memory.gists)
package/dist/restore.js CHANGED
@@ -22,6 +22,7 @@ import { sanitizeBonds } from './partnering/index.js';
22
22
  import { cleanRetention, createPerson, traceLimit } from './person.js';
23
23
  import { sanitizePromptings } from './promptings/index.js';
24
24
  import { sanitizeProspection } from './prospection/index.js';
25
+ import { sanitizeReview } from './review/index.js';
25
26
  import { sanitizeSkillRetention } from './skills/index.js';
26
27
  import { sanitizeGroups, sanitizeImpressions } from './social/index.js';
27
28
  import { ENGINE_VERSION, MAX_MINUTE, PERSON_SCHEMA } from './types.js';
@@ -63,6 +64,7 @@ const OPTIONAL_KEYS = new Set([
63
64
  'methods',
64
65
  'prospection',
65
66
  'promptings',
67
+ 'review',
66
68
  ]);
67
69
  /** Kind of a JSON value for the per-field type check in `restore`. */
68
70
  const kindOf = (x) => (Array.isArray(x) ? 'array' : x === null ? 'null' : typeof x);
@@ -177,6 +179,7 @@ export function restore(input) {
177
179
  keepValid(out, 'methods', sanitizeMethods);
178
180
  keepValid(out, 'prospection', (x) => sanitizeProspection(x, out.now, out.id));
179
181
  keepValid(out, 'promptings', (x) => sanitizePromptings(x, out.now));
182
+ keepValid(out, 'review', (x) => sanitizeReview(x, out.now));
180
183
  boundLists(out);
181
184
  return out;
182
185
  }
@@ -0,0 +1 @@
1
+ export * from './review.ts';
@@ -0,0 +1 @@
1
+ export * from './review.js';
@@ -0,0 +1,47 @@
1
+ import type { DayRecord, Minute, Person, ReviewRecord, ReviewSpec, ReviewState, ReviewStyle, Term } from '../types.ts';
2
+ export declare const REVIEW_DEFAULTS: {
3
+ /** Default evening review time, minute of day. */
4
+ at: number;
5
+ /** Largest `review:resolve` term at full strength. */
6
+ resolveScale: number;
7
+ /** Share of the remaining headroom a lapse adds, by style. */
8
+ gain: Record<ReviewStyle, number>;
9
+ /** Strength kept after the commitment is kept. */
10
+ keptKeep: number;
11
+ halfLifeDays: number;
12
+ /** Resolves weaker than this are dropped. */
13
+ dropBelow: number;
14
+ maxResolves: number;
15
+ maxSeen: number;
16
+ /** Distress felt per lapse by a ruminating reviewer, capped at `ruminateMax`. */
17
+ ruminatePer: number;
18
+ ruminateMax: number;
19
+ };
20
+ /** Turn on the evening review. Idempotent; a second call updates style, source and time but keeps resolves. */
21
+ export declare function enableReview(p: Person, spec?: ReviewSpec): ReviewState;
22
+ /** The key a commitment and its chronicle note share: kind, owed-to, and label (else first action). */
23
+ export declare const resolveKey: (c: {
24
+ label?: string | undefined;
25
+ kind: string;
26
+ action?: string | undefined;
27
+ actions?: readonly string[];
28
+ toId?: string | undefined;
29
+ }) => string;
30
+ /**
31
+ * The `review:resolve` terms for an option, read from its already-traced `commitment:*` and `abstain:*` terms.
32
+ * Pure; empty when review is off or no resolve matches.
33
+ */
34
+ export declare function reviewTerms(p: Person, terms: readonly Term[]): Term[];
35
+ /**
36
+ * Review now if it is due: at or after `at` on a day not yet reviewed, or when `closed` (the day just closed)
37
+ * was never reviewed. Reads the notes of `recent` (the latest closed day records) and the open day. Returns the
38
+ * record, or undefined when nothing ran. Distress for a ruminating reviewer is returned for the composite to feel.
39
+ */
40
+ export declare function reviewIfDue(p: Person, now: Minute, recent: readonly DayRecord[], closed?: DayRecord): {
41
+ record: ReviewRecord;
42
+ distress: number;
43
+ } | undefined;
44
+ /** The person's own words for their latest review, or undefined when they hold no review. */
45
+ export declare function reviewLine(p: Person): string | undefined;
46
+ /** Restore guard: drop malformed fields, keep valid resolves. */
47
+ export declare function sanitizeReview(x: unknown, now?: Minute): ReviewState | undefined;
@@ -0,0 +1,239 @@
1
+ /**
2
+ * SCOPE (evening self-review, muhasaba, 2.7.0): once a day, from `at` (default 21:00), a person looks back over
3
+ * the commitments the chronicle recorded as kept or broken since their last review, and for each one they broke
4
+ * forms or renews a *resolve*: an if–then intention for that commitment's next occasion (Gollwitzer & Sheeran
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
7
+ * act), scaled by its strength. Strength grows with diminishing gains at each further lapse, relaxes once the
8
+ * commitment is kept, and fades over `halfLifeDays` of disuse. Released and excused commitments (exemptions,
9
+ * sleep, necessity) form no resolve: what was beyond capacity is not held against the person (2:286). The review
10
+ * also runs when the day closes, if the evening passed without it or a lapse came after it.
11
+ *
12
+ * Held readings (`ReviewStyle`): `accounting` is self-accounting as the tradition describes it, naming the lapse
13
+ * with a view to tomorrow (Q 59:18, whose "tomorrow" most exegetes read as the hereafter, so a daily review is an
14
+ * inference; ʿUmar's saying reported by Tirmidhi after 2459, an athar, not a prophetic
15
+ * hadith) and turning back without despair (3:135, 39:53); it forms full resolves and adds no emotion. Reading accounting as
16
+ * non-punishing, like self-compassion after failure raising the motivation to repair (Breines & Chen 2012, E3),
17
+ * is an engineering mapping, not a finding about muhasaba. `ruminating` goes over
18
+ * the lapse abstractly: weaker resolves and a small distress emotion (Watkins 2008, E4: abstract, unconstructive
19
+ * repetitive thought). `none` never reviews. The review reads only chronicle notes and changes no other module's
20
+ * state except that one emotion.
21
+ *
22
+ * Every number is an engineering default, not fitted. Does not claim: divine reckoning or acceptance, a measure of
23
+ * piety, repentance as a ruling (tawba conditions are not modelled), effects on sleep onset (Harvey's model, E6, is
24
+ * not evidence enough), or that review improves outcomes beyond the borrowed if–then shape. It draws no randomness;
25
+ * without `enableReview` nothing here runs and existing runs replay byte for byte.
26
+ */
27
+ import { clamp01, dexp, isObj, minuteOfDay, round } from '../core/index.js';
28
+ export const REVIEW_DEFAULTS = {
29
+ /** Default evening review time, minute of day. */
30
+ at: 21 * 60,
31
+ /** Largest `review:resolve` term at full strength. */
32
+ resolveScale: 0.3,
33
+ /** Share of the remaining headroom a lapse adds, by style. */
34
+ gain: { accounting: 0.5, ruminating: 0.15, none: 0 },
35
+ /** Strength kept after the commitment is kept. */
36
+ keptKeep: 0.6,
37
+ halfLifeDays: 7,
38
+ /** Resolves weaker than this are dropped. */
39
+ dropBelow: 0.05,
40
+ maxResolves: 8,
41
+ maxSeen: 64,
42
+ /** Distress felt per lapse by a ruminating reviewer, capped at `ruminateMax`. */
43
+ ruminatePer: 0.15,
44
+ ruminateMax: 0.45,
45
+ };
46
+ const STYLES = ['accounting', 'ruminating', 'none'];
47
+ /** Turn on the evening review. Idempotent; a second call updates style, source and time but keeps resolves. */
48
+ export function enableReview(p, spec = {}) {
49
+ const at = spec.at !== undefined && Number.isInteger(spec.at) && spec.at >= 0 && spec.at < 1440
50
+ ? spec.at
51
+ : undefined;
52
+ const cur = p.review;
53
+ const st = cur ?? { style: 'accounting', at: REVIEW_DEFAULTS.at, resolves: [] };
54
+ if (spec.style && STYLES.includes(spec.style))
55
+ st.style = spec.style;
56
+ if (spec.styleSource !== undefined)
57
+ st.styleSource = spec.styleSource.slice(0, 96);
58
+ if (at !== undefined)
59
+ st.at = at;
60
+ p.review = st;
61
+ return st;
62
+ }
63
+ /** The key a commitment and its chronicle note share: kind, owed-to, and label (else first action). */
64
+ export const resolveKey = (c) => `${c.kind}:${c.toId ?? ''}:${c.label ?? c.action ?? c.actions?.[0] ?? ''}`;
65
+ /**
66
+ * The `review:resolve` terms for an option, read from its already-traced `commitment:*` and `abstain:*` terms.
67
+ * Pure; empty when review is off or no resolve matches.
68
+ */
69
+ export function reviewTerms(p, terms) {
70
+ const st = p.review;
71
+ if (!st || st.resolves.length === 0)
72
+ return [];
73
+ let value = 0;
74
+ const used = new Set();
75
+ for (const t of terms) {
76
+ const m = /^(commitment|abstain):(.+)$/.exec(t.source);
77
+ if (!m || t.value === 0)
78
+ continue;
79
+ const c = p.agenda.commitments.find((x) => x.id === m[2]);
80
+ if (!c)
81
+ continue;
82
+ const r = st.resolves.find((x) => x.key === resolveKey(c));
83
+ if (!r || used.has(r.key))
84
+ continue;
85
+ used.add(r.key);
86
+ value += Math.sign(t.value) * REVIEW_DEFAULTS.resolveScale * r.strength;
87
+ }
88
+ const max = REVIEW_DEFAULTS.resolveScale;
89
+ value = Math.max(-max, Math.min(max, value));
90
+ return value === 0 ? [] : [{ source: 'review:resolve', value: round(value) }];
91
+ }
92
+ const fade = (s, days) => days <= 0 ? s : s * dexp((-Math.LN2 * days) / REVIEW_DEFAULTS.halfLifeDays);
93
+ /**
94
+ * Review now if it is due: at or after `at` on a day not yet reviewed, or when `closed` (the day just closed)
95
+ * was never reviewed. Reads the notes of `recent` (the latest closed day records) and the open day. Returns the
96
+ * record, or undefined when nothing ran. Distress for a ruminating reviewer is returned for the composite to feel.
97
+ */
98
+ export function reviewIfDue(p, now, recent, closed) {
99
+ const st = p.review;
100
+ if (!st || st.style === 'none')
101
+ return undefined;
102
+ const today = Math.floor(now / 1440);
103
+ const seenNow = new Set(st.seen ?? []);
104
+ const lateLapse = closed?.broken.some((n) => !seenNow.has(n.id)) ?? false;
105
+ let day;
106
+ if (closed && (st.lastDay === undefined || st.lastDay < closed.day || lateLapse))
107
+ day = Math.max(closed.day, st.lastDay ?? closed.day);
108
+ else if (minuteOfDay(now) >= st.at && (st.lastDay === undefined || st.lastDay < today))
109
+ day = today;
110
+ else
111
+ return undefined;
112
+ const seen = new Set(st.seen ?? []);
113
+ const notes = (kind) => {
114
+ const out = [];
115
+ for (const r of closed && !recent.includes(closed) ? [...recent, closed] : recent)
116
+ for (const n of r[kind])
117
+ if (!seen.has(n.id))
118
+ out.push(n);
119
+ const open = p.chronicleDay;
120
+ if (open && open.day <= day)
121
+ for (const n of open[kind])
122
+ if (!seen.has(n.id))
123
+ out.push(n);
124
+ return out;
125
+ };
126
+ const broken = notes('broken');
127
+ const kept = notes('kept');
128
+ const elapsed = st.lastDay === undefined ? 0 : day - st.lastDay;
129
+ for (const r of st.resolves)
130
+ r.strength = round(fade(r.strength, elapsed));
131
+ const gain = REVIEW_DEFAULTS.gain[st.style];
132
+ const missed = [];
133
+ const resolved = [];
134
+ for (const n of broken) {
135
+ const key = resolveKey(n);
136
+ const name = n.label ?? n.action ?? n.kind;
137
+ if (!missed.includes(name))
138
+ missed.push(name);
139
+ let r = st.resolves.find((x) => x.key === key);
140
+ if (!r) {
141
+ r = { key, kind: n.kind, strength: 0, day };
142
+ if (n.label !== undefined)
143
+ r.label = n.label;
144
+ st.resolves.push(r);
145
+ }
146
+ r.strength = round(clamp01(r.strength + gain * (1 - r.strength)));
147
+ r.day = day;
148
+ if (!resolved.includes(name))
149
+ resolved.push(name);
150
+ }
151
+ const keptNames = [];
152
+ for (const n of kept) {
153
+ const key = resolveKey(n);
154
+ const name = n.label ?? n.action ?? n.kind;
155
+ if (!keptNames.includes(name))
156
+ keptNames.push(name);
157
+ const r = st.resolves.find((x) => x.key === key);
158
+ if (r && !broken.some((b) => resolveKey(b) === key))
159
+ r.strength = round(r.strength * REVIEW_DEFAULTS.keptKeep);
160
+ }
161
+ st.resolves = st.resolves
162
+ .filter((r) => r.strength >= REVIEW_DEFAULTS.dropBelow)
163
+ .sort((a, b) => b.strength - a.strength || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
164
+ .slice(0, REVIEW_DEFAULTS.maxResolves);
165
+ const ids = [...(st.seen ?? []), ...broken.map((n) => n.id), ...kept.map((n) => n.id)];
166
+ st.seen = ids.slice(-REVIEW_DEFAULTS.maxSeen);
167
+ st.lastDay = day;
168
+ const record = {
169
+ day,
170
+ missed,
171
+ kept: keptNames,
172
+ resolved: st.style === 'accounting' ? resolved : [],
173
+ };
174
+ st.last = record;
175
+ const distress = st.style === 'ruminating'
176
+ ? Math.min(REVIEW_DEFAULTS.ruminateMax, REVIEW_DEFAULTS.ruminatePer * missed.length)
177
+ : 0;
178
+ return { record, distress };
179
+ }
180
+ /** The person's own words for their latest review, or undefined when they hold no review. */
181
+ export function reviewLine(p) {
182
+ const st = p.review;
183
+ const r = st?.last;
184
+ if (!st || st.style === 'none' || !r)
185
+ return undefined;
186
+ const list = (xs) => xs.length <= 1 ? (xs[0] ?? '') : `${xs.slice(0, -1).join(', ')} and ${xs.at(-1)}`;
187
+ if (r.missed.length === 0)
188
+ return r.kept.length > 0 ? `Today I kept ${list(r.kept)}.` : undefined;
189
+ if (st.style === 'ruminating')
190
+ return `I keep going back over ${list(r.missed)}.`;
191
+ return `I didn't keep ${list(r.missed)}. Next time I'll be ready when it comes.`;
192
+ }
193
+ /** Restore guard: drop malformed fields, keep valid resolves. */
194
+ export function sanitizeReview(x, now) {
195
+ if (!isObj(x))
196
+ return undefined;
197
+ const style = STYLES.includes(x.style) ? x.style : 'accounting';
198
+ const at = Number.isInteger(x.at) && x.at >= 0 && x.at < 1440
199
+ ? x.at
200
+ : REVIEW_DEFAULTS.at;
201
+ const resolves = [];
202
+ if (Array.isArray(x.resolves))
203
+ for (const r of x.resolves) {
204
+ if (!isObj(r) || typeof r.key !== 'string' || typeof r.kind !== 'string')
205
+ continue;
206
+ if (typeof r.strength !== 'number' || !Number.isFinite(r.strength) || !Number.isInteger(r.day))
207
+ continue;
208
+ const out = {
209
+ key: r.key.slice(0, 96),
210
+ kind: r.kind.slice(0, 32),
211
+ strength: clamp01(r.strength),
212
+ day: r.day,
213
+ };
214
+ if (typeof r.label === 'string')
215
+ out.label = r.label.slice(0, 96);
216
+ if (!resolves.some((y) => y.key === out.key))
217
+ resolves.push(out);
218
+ if (resolves.length >= REVIEW_DEFAULTS.maxResolves)
219
+ break;
220
+ }
221
+ const st = { style, at, resolves };
222
+ if (typeof x.styleSource === 'string')
223
+ st.styleSource = x.styleSource.slice(0, 96);
224
+ if (Number.isInteger(x.lastDay) && (now === undefined || x.lastDay <= Math.floor(now / 1440)))
225
+ st.lastDay = x.lastDay;
226
+ if (Array.isArray(x.seen))
227
+ st.seen = x.seen.filter((s) => typeof s === 'string').slice(-REVIEW_DEFAULTS.maxSeen);
228
+ const l = x.last;
229
+ if (isObj(l) && Number.isInteger(l.day)) {
230
+ const strs = (v) => Array.isArray(v) ? v.filter((s) => typeof s === 'string').slice(0, 24) : [];
231
+ st.last = {
232
+ day: l.day,
233
+ missed: strs(l.missed),
234
+ kept: strs(l.kept),
235
+ resolved: strs(l.resolved),
236
+ };
237
+ }
238
+ return st;
239
+ }
package/dist/types.d.ts CHANGED
@@ -832,7 +832,7 @@ export declare const PERSON_SCHEMA = "human/person@1";
832
832
  * and an activity that would cover a closing stretch is reviewed when the stretch begins (`isUnderWay`,
833
833
  * `dutyReviewAt`). Person shape unchanged.
834
834
  */
835
- export declare const ENGINE_VERSION = "2.6.0";
835
+ export declare const ENGINE_VERSION = "2.7.0";
836
836
  /** A pregnancy carried by this person (host-driven; see `family/`). */
837
837
  export interface Pregnancy {
838
838
  fatherId: PersonId;
@@ -999,6 +999,8 @@ export interface Person {
999
999
  prospection?: ProspectionState;
1000
1000
  /** Noticed promptings and practised turnings-away (2.4.0, opt-in via `enablePromptings`). Owned by `promptings/`. */
1001
1001
  promptings?: PromptingsState;
1002
+ /** Evening self-review and its resolves (2.7.0, opt-in via `enableReview`). Owned by `review/`. */
1003
+ review?: ReviewState;
1002
1004
  /** Practice consolidation (1.8.0, opt-in via `enableSkillRetention`). Owned by `skills/`. */
1003
1005
  skillRetention?: {
1004
1006
  consolidationHours: number;
@@ -1402,6 +1404,8 @@ export interface PersonSpec {
1402
1404
  fatigueStore?: boolean;
1403
1405
  /** Promptings and turning away (2.4.0): the same as `enablePromptings(p, spec)` after creation. */
1404
1406
  promptings?: PromptingsSpec;
1407
+ /** Evening self-review (2.7.0): the same as `enableReview(p, spec)` after creation. */
1408
+ review?: ReviewSpec;
1405
1409
  };
1406
1410
  }
1407
1411
  /** A known span of exertion after an act (see `Affordance.followedBy`). */
@@ -1454,6 +1458,47 @@ export interface PromptingsState {
1454
1458
  /** Echo gained per prompt under `fault`. Default 0: the recurrence term is a host-set assumption (P3 contested). */
1455
1459
  echoRate: number;
1456
1460
  }
1461
+ /**
1462
+ * How a person reviews a day (2.7.0, a held reading): `accounting` (muhasaba: name the lapse and resolve for its
1463
+ * next occasion), `ruminating` (go over it abstractly: weaker resolve, some distress) or `none` (no review).
1464
+ */
1465
+ export type ReviewStyle = 'accounting' | 'ruminating' | 'none';
1466
+ /** A resolve formed in review: an if-then intention for the next occasion of a missed commitment. */
1467
+ export interface Resolve {
1468
+ /** The commitment's label, else its first action, else its kind. */
1469
+ key: string;
1470
+ label?: string;
1471
+ kind: string;
1472
+ strength: Unit;
1473
+ /** Day the resolve was formed or last renewed. */
1474
+ day: number;
1475
+ }
1476
+ /** What the person concluded in their latest review. */
1477
+ export interface ReviewRecord {
1478
+ day: number;
1479
+ missed: string[];
1480
+ kept: string[];
1481
+ resolved: string[];
1482
+ }
1483
+ /** Owned by `review/`. */
1484
+ export interface ReviewState {
1485
+ style: ReviewStyle;
1486
+ styleSource?: string;
1487
+ /** Minute of the day from which the evening review can run (default 21:00). */
1488
+ at: number;
1489
+ /** Last day reviewed. */
1490
+ lastDay?: number;
1491
+ resolves: Resolve[];
1492
+ last?: ReviewRecord;
1493
+ /** Commitment ids already reviewed (bounded). */
1494
+ seen?: string[];
1495
+ }
1496
+ /** What a host hands `enableReview`. */
1497
+ export interface ReviewSpec {
1498
+ style?: ReviewStyle;
1499
+ styleSource?: string;
1500
+ at?: number;
1501
+ }
1457
1502
  /** What a host hands `enablePromptings`. */
1458
1503
  export interface PromptingsSpec {
1459
1504
  reading?: PromptingReading;
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.6.0';
123
+ export const ENGINE_VERSION = '2.7.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adam0white/human-framework",
3
- "version": "2.6.0",
3
+ "version": "2.7.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",