honestweek 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/README.md +252 -197
- package/SKILL.md +25 -74
- package/bin/honestweek.mjs +81 -24
- package/flows/client.md +19 -0
- package/flows/digest.md +11 -0
- package/flows/mine.md +18 -0
- package/flows/view.md +45 -0
- package/flows/weekly.md +29 -0
- package/lib/ask.mjs +1142 -0
- package/lib/build.mjs +12 -3
- package/lib/config-lookup.mjs +179 -0
- package/lib/config.mjs +20 -0
- package/lib/demo/week.mjs +3 -0
- package/lib/digest-carry.mjs +3 -2
- package/lib/digest-store.mjs +3 -2
- package/lib/digest.mjs +21 -14
- package/lib/discover.mjs +28 -22
- package/lib/emit/index.mjs +18 -6
- package/lib/harvest.mjs +19 -1
- package/lib/history.mjs +10 -2
- package/lib/init.mjs +241 -55
- package/lib/mine.mjs +18 -8
- package/lib/preview.mjs +66 -13
- package/lib/private-words.mjs +25 -5
- package/lib/problems/index.mjs +60 -7
- package/lib/prompt-lane.mjs +14 -13
- package/lib/prompt-store.mjs +2 -1
- package/lib/prompts.mjs +13 -7
- package/lib/replay/assemble.mjs +25 -8
- package/lib/replay/index.mjs +200 -12
- package/lib/replay/lookup.mjs +6 -3
- package/lib/replay/saved-sessions.mjs +315 -0
- package/lib/replay/views.mjs +12 -1
- package/lib/{view → replay}/word-index.mjs +3 -3
- package/lib/replay/words.mjs +122 -0
- package/lib/repo-identity.mjs +81 -13
- package/lib/saved/checks.mjs +381 -0
- package/lib/saved/history.mjs +235 -0
- package/lib/saved/saver.mjs +111 -0
- package/lib/saved/store.mjs +256 -0
- package/lib/status.mjs +288 -0
- package/lib/validate.mjs +12 -3
- package/lib/view/assets/common.css +2 -0
- package/lib/view/assets/common.js +20 -2
- package/lib/view/assets/problems.js +4 -3
- package/lib/view/assets/replay.js +3 -1
- package/lib/view/assets/search.js +1 -1
- package/lib/view/assets/sessions.js +1 -0
- package/lib/view/assets/settings.html +16 -0
- package/lib/view/assets/settings.js +108 -4
- package/lib/view/assets/setup.html +7 -0
- package/lib/view/assets/setup.js +9 -0
- package/lib/view/codex-judge.mjs +1 -1
- package/lib/view/data.mjs +160 -76
- package/lib/view/own-week.mjs +119 -0
- package/lib/view/page-link.mjs +84 -0
- package/lib/view/problems-route.mjs +29 -3
- package/lib/view/replay-export.mjs +1 -1
- package/lib/view/selftest/clickthrough.js +9 -5
- package/lib/view/server.mjs +3 -1
- package/lib/view/settings.mjs +102 -34
- package/lib/view/setup.mjs +27 -14
- package/lib/view/suggest-words.mjs +67 -0
- package/lib/view.mjs +82 -133
- package/lib/worktrees.mjs +31 -18
- package/package.json +2 -1
|
@@ -310,7 +310,7 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
|
|
|
310
310
|
const s = ctx.sessionByKey.get(f.session);
|
|
311
311
|
const label = labelOf?.(ctx, f.session) ?? null;
|
|
312
312
|
// The repository is the configured label, already redacted with the rest of the history.
|
|
313
|
-
out[f.session] = { title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, tool: s?.tool ?? null, repo: typeof s?.repo === 'string' ? s.repo : null };
|
|
313
|
+
out[f.session] = { title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, tool: s?.tool ?? null, repo: typeof s?.repo === 'string' ? s.repo : null, ...(s?.saved ? { saved: s.saved } : {}) };
|
|
314
314
|
}
|
|
315
315
|
return out;
|
|
316
316
|
};
|
|
@@ -358,6 +358,26 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
|
|
|
358
358
|
return { window: base.window, finding: redactAnswer(findingOut(ctx, hit.f, goalsOf), ctx.redact), pattern: { id: p.id, name: p.name, group: p.group, status: p.status, strength: p.strength, priority: p.priority, fix: p.draft?.title ?? p.mitigation?.[0]?.action ?? null }, catalogIds: base.patterns.map((x) => x.id), evidenceKey };
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
// One pattern, whole: its catalog side and every finding in the window (not just the page's
|
|
362
|
+
// newest), in time order, each with the steps its check recorded described as this build
|
|
363
|
+
// shows them. `honestweek problems --pattern` reads it.
|
|
364
|
+
const one = params.get('pattern');
|
|
365
|
+
if (one !== null) {
|
|
366
|
+
const src = result.patterns.find((x) => x.id === one);
|
|
367
|
+
if (!src) return { status: 404, body: { error: 'No pattern in the catalog has that id.' } };
|
|
368
|
+
const p = base.patterns.find((x) => x.id === one);
|
|
369
|
+
const all = [...src.findings].sort((a, b) => String(a.at ?? '').localeCompare(String(b.at ?? '')));
|
|
370
|
+
const findings = all.map((f) => findingOut(ctx, f, goalsOf));
|
|
371
|
+
const steps = {};
|
|
372
|
+
for (const f of findings) {
|
|
373
|
+
for (const id of f.zoom?.events ?? []) {
|
|
374
|
+
const e = ctx.b.eventsById?.get(id);
|
|
375
|
+
if (e && !steps[id]) steps[id] = { at: e.at ?? null, session: e.session ?? null, kind: e.kind ?? null, evidence: e.evidence ?? null, text: describe(e) };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return { window: base.window, pattern: { ...p, findings: redactAnswer(findings, ctx.redact), findingsListed: all.length }, steps: redactAnswer(steps, ctx.redact), sessions: redactAnswer(sessionsOf(ctx, all, labelOf), ctx.redact), rules: base.rules, evidenceKey };
|
|
379
|
+
}
|
|
380
|
+
|
|
361
381
|
// The trend: each pattern's findings worth a look in this window against the window just
|
|
362
382
|
// before it, of the same length. The earlier window is read only for these counts, once. A
|
|
363
383
|
// list, like the rules: a pattern id such as secret-exposure reads as a credential's field
|
|
@@ -367,7 +387,7 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
|
|
|
367
387
|
const now = trendCounts(result);
|
|
368
388
|
return Promise.resolve(earlier()).then((before) => ({
|
|
369
389
|
window: base.window,
|
|
370
|
-
...redactAnswer({ earlier: before?.window ? { from: before.window.from, to: before.window.to, days: before.window.days, sessions: before.error ? null : before.sessions, ...(before.skipped ? { skipped: before.skipped, note: before.note } : {}), ...(before.partial ? { partial: before.partial, note: before.note } : {}), ...(before.loaded && !before.skipped ? { loaded: before.loaded, note: before.note } : {}) } : null, trend: Object.entries(trendOf(now, before)).map(([id, t]) => ({ id, ...t })) }, ctx.redact),
|
|
390
|
+
...redactAnswer({ earlier: before?.window ? { from: before.window.from, to: before.window.to, days: before.window.days, sessions: before.error ? null : before.sessions, ...(before.skipped ? { skipped: before.skipped, note: before.note } : {}), ...(before.partial ? { partial: before.partial, note: before.note } : {}), ...(before.loaded && !before.skipped ? { loaded: before.loaded, note: before.note } : {}), ...(before.saved ? { saved: true } : {}) } : null, trend: Object.entries(trendOf(now, before)).map(([id, t]) => ({ id, ...t })) }, ctx.redact),
|
|
371
391
|
evidenceKey,
|
|
372
392
|
}));
|
|
373
393
|
}
|
|
@@ -411,7 +431,7 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
|
|
|
411
431
|
const s = ctx.sessionByKey.get(session);
|
|
412
432
|
const all = result.patterns.flatMap((p) => p.findings.filter((f) => f.session === session));
|
|
413
433
|
const label = labelOf?.(ctx, session) ?? null;
|
|
414
|
-
focus = { session, title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, known: !!s, findings: all.length, look: all.filter((f) => f.severity === 'look').length, patterns: new Set(all.map((f) => f.pattern)).size };
|
|
434
|
+
focus = { session, title: s?.title ?? null, ...(label ? { label } : {}), thread: s?.thread ?? null, known: !!s, findings: all.length, look: all.filter((f) => f.severity === 'look').length, patterns: new Set(all.map((f) => f.pattern)).size, ...(s?.saved ? { saved: s.saved } : {}) };
|
|
415
435
|
pick = (p) => newest(p.findings.filter((f) => f.session === session));
|
|
416
436
|
}
|
|
417
437
|
const shown = new Map(result.patterns.map((p) => [p.id, pick(p)]));
|
|
@@ -421,5 +441,11 @@ export function createProblemsRoute({ now = () => Date.now(), run = runProblems,
|
|
|
421
441
|
return { ...base, patterns, focus: focus ? redactAnswer(focus, ctx.redact) : null, sessions: redactAnswer(sessionsOf(ctx, all, labelOf), ctx.redact), examples: PROBLEM_EXAMPLES, evidenceKey };
|
|
422
442
|
}
|
|
423
443
|
problems.lookBySession = lookBySession;
|
|
444
|
+
/** The checks' result for `h` and each finding's key, run once and shared with every answer;
|
|
445
|
+
* null when the checks couldn't run. */
|
|
446
|
+
problems.checked = (h, builtT) => {
|
|
447
|
+
const c = resultFor(h, builtT);
|
|
448
|
+
return c.result ? { result: c.result, keys: c.keys } : null;
|
|
449
|
+
};
|
|
424
450
|
return problems;
|
|
425
451
|
}
|
|
@@ -241,7 +241,7 @@ export function exportThread(h, threadId, { focus = null, extraRules = {}, redac
|
|
|
241
241
|
related: th.related,
|
|
242
242
|
},
|
|
243
243
|
session: { key: first?.key ?? null, title: first?.title ?? th.title ?? null, turns },
|
|
244
|
-
sessions: th.sessions.map((s) => ({ key: s.key, tool: s.tool, repo: s.repo ?? null, group: sessionGroup(sessionByKey.get(s.key)), title: s.title ?? null, firstAt: s.firstAt ?? null, lastAt: s.lastAt ?? null, endState: s.endState ?? null, missing: s.missing ?? [] })),
|
|
244
|
+
sessions: th.sessions.map((s) => ({ key: s.key, tool: s.tool, repo: s.repo ?? null, group: sessionGroup(sessionByKey.get(s.key)), title: s.title ?? null, firstAt: s.firstAt ?? null, lastAt: s.lastAt ?? null, endState: s.endState ?? null, missing: s.missing ?? [], ...(sessionByKey.get(s.key)?.saved ? { saved: sessionByKey.get(s.key).saved } : {}) })),
|
|
245
245
|
agents,
|
|
246
246
|
events: events.map((e) => eventRow(h, e, agentsByKey, redact)),
|
|
247
247
|
frames,
|
|
@@ -2423,12 +2423,16 @@
|
|
|
2423
2423
|
if (!d.querySelectorAll('#repos .reporow select').length) throw new Error('no repository with a role menu');
|
|
2424
2424
|
if (!/@/.test($('emails').value)) throw new Error('the emails are not shown');
|
|
2425
2425
|
if (d.getElementById('wordsFold').open) throw new Error('the private words are not folded away');
|
|
2426
|
-
// Save works from the start
|
|
2426
|
+
// Save works from the start. With nothing changed it writes no config, says so and stays here.
|
|
2427
|
+
// It still adds the config's .gitignore line when that's missing, so it's clicked only on the
|
|
2428
|
+
// config this run wrote (setup's Save already ignored it): someone's own folder stays untouched.
|
|
2427
2429
|
if ($('saveBtn').disabled) throw new Error('Save is off before Review changes; it should work from the start');
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2430
|
+
if (env.setupDone) {
|
|
2431
|
+
click($('saveBtn'));
|
|
2432
|
+
await waitFor(() => !$('problem').hidden, { what: 'the answer to a Save with nothing changed' });
|
|
2433
|
+
if (!onPage(doc(), 'settings.html') || text($('problem')) !== 'Nothing changed.') throw new Error(`Save with nothing changed read "${text($('problem')).slice(0, 160)}"`);
|
|
2434
|
+
if ($('saveBtn').disabled) throw new Error('Save stays off after "Nothing changed."');
|
|
2435
|
+
}
|
|
2432
2436
|
choose($('histKind'), 'days');
|
|
2433
2437
|
typeInto($('histDays'), '14');
|
|
2434
2438
|
// The chosen window says which days it loads, and whether that's partial; the logs line how far back they go.
|
package/lib/view/server.mjs
CHANGED
|
@@ -320,7 +320,9 @@ export function startViewServer({ data, setup = null, settings = null, planner =
|
|
|
320
320
|
url,
|
|
321
321
|
port: boundPort,
|
|
322
322
|
issueCode,
|
|
323
|
-
|
|
323
|
+
// A page can carry its own part after # (replay's thread and step); key.js reads the
|
|
324
|
+
// code from among the &-separated parts there and puts the rest back.
|
|
325
|
+
address: (purpose = 'address', page = '', opts) => `${url}${page}${page.includes('#') ? '&' : '#'}c=${issueCode(purpose, opts)}`,
|
|
324
326
|
pendingCodes: () => codes.size,
|
|
325
327
|
setData: (d) => {
|
|
326
328
|
current = d;
|
package/lib/view/settings.mjs
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// lib/view/settings.mjs: the Settings page's side of `honestweek view`. It changes the config
|
|
2
2
|
// view started with, later, without the terminal: how far back to look, the repositories and
|
|
3
3
|
// their roles, the author emails, the private words, the goal list, whether the Problems page
|
|
4
|
-
// includes /insights,
|
|
4
|
+
// includes /insights, the long-session limit it checks against, and whether results are saved
|
|
5
|
+
// between runs and for how long (lib/saved/).
|
|
5
6
|
//
|
|
6
7
|
// It edits the file as it stands on disk, field by field: everything the page doesn't show,
|
|
7
8
|
// and everything it shows but wasn't changed, stays exactly as it was (a repository's other
|
|
8
9
|
// fields, the order of the redaction lists, curation, privacy, output). The checks are
|
|
9
|
-
// Setup's own. A save names what changes first (preview), writes only the config
|
|
10
|
-
//
|
|
10
|
+
// Setup's own. A save names what changes first (preview), writes only the config view read,
|
|
11
|
+
// wherever it sits, and only if the file is still the one the page was shown: a file
|
|
11
12
|
// changed meanwhile is never overwritten. Nothing is written when any check fails.
|
|
12
13
|
|
|
13
14
|
import { createHash } from 'node:crypto';
|
|
@@ -16,12 +17,13 @@ import { join, resolve } from 'node:path';
|
|
|
16
17
|
|
|
17
18
|
import { atomicWriteText } from '../atomic-json.mjs';
|
|
18
19
|
import { gitExec } from '../git.mjs';
|
|
19
|
-
import { HISTORY_LIMIT_MB, LONG_SESSION_MAX, LONG_SESSION_MIN, normalizeConfig, normalizeHistoryLimit, resolveRepoPath, ROLES } from '../config.mjs';
|
|
20
|
-
import { authorHasCommits, checkNestedRoles, displayTest, existingDisplayRepos, findRepos, repoLastCommitAt } from '../init.mjs';
|
|
20
|
+
import { HISTORY_LIMIT_MB, LONG_SESSION_MAX, LONG_SESSION_MIN, normalizeConfig, normalizeHistoryLimit, resolveRepoPath, ROLES, SAVE_KEEP_DAYS } from '../config.mjs';
|
|
21
|
+
import { authorHasCommits, checkNestedRoles, ensureGitignore, checkoutOf, displayTest, existingDisplayRepos, findRepos, keptFromOldConfig, lookedUpConfig, nestsDisplay, reachesDisplay, repoLastCommitAt } from '../init.mjs';
|
|
21
22
|
import { privateWordCount } from '../private-words.mjs';
|
|
23
|
+
import { SAVED_GITIGNORE } from '../saved/store.mjs';
|
|
22
24
|
import { checkEmails, checkGoalsPath, checkHistory, checkRepo, checkWords, checkSameRepos, CONFIG_FILE, json, parseBody, plural, privateWordsText, refuse, roleMeanings } from './setup.mjs';
|
|
23
25
|
|
|
24
|
-
const FIELDS = new Set(['version', 'history', 'historyLimitMB', 'repos', 'authorEmails', 'names', 'terms', 'goalsFile', 'insights', 'longSessionTokens']);
|
|
26
|
+
const FIELDS = new Set(['version', 'history', 'historyLimitMB', 'repos', 'authorEmails', 'names', 'terms', 'goalsFile', 'insights', 'longSessionTokens', 'saveResults']);
|
|
25
27
|
const MAX_REPOS = 200;
|
|
26
28
|
|
|
27
29
|
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
@@ -106,39 +108,59 @@ function ignoreConfig(folder) {
|
|
|
106
108
|
return true;
|
|
107
109
|
}
|
|
108
110
|
|
|
109
|
-
/**
|
|
110
|
-
*
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
/** Whether git tracks the config in `folder`: 'tracked', 'untracked', or 'unchecked' when git
|
|
112
|
+
* isn't asked because it would reach a display-only folder (reachesDisplay; AGENTS.md invariant
|
|
113
|
+
* 4). Outside any checkout there's nothing for git to track, so git isn't run and it reads as
|
|
114
|
+
* untracked, as it does with no git. */
|
|
115
|
+
export function configTrackState(folder) {
|
|
116
|
+
if (checkoutOf(folder) === null) return 'untracked';
|
|
117
|
+
if (reachesDisplay(existingDisplayRepos(folder))(folder)) return 'unchecked';
|
|
113
118
|
try {
|
|
114
119
|
gitExec(['ls-files', '--error-unmatch', '--', CONFIG_FILE], { repo: folder, stdio: 'ignore' });
|
|
115
|
-
return
|
|
120
|
+
return 'tracked';
|
|
116
121
|
} catch {
|
|
117
|
-
return
|
|
122
|
+
return 'untracked';
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
/** True when git tracks the config in `folder` (configTrackState). */
|
|
127
|
+
export function configTracked(folder) {
|
|
128
|
+
return configTrackState(folder) === 'tracked';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Settings' note when it can't ask git whether the config is committed (issue 161). */
|
|
132
|
+
export const CANT_CHECK_CONFIG = `Check that ${CONFIG_FILE} was never committed to git. It holds your private words, and listing it in .gitignore doesn't remove it from git if it was committed before. honestweek can't check for you here because of your display-only setting. Run git ls-files ${CONFIG_FILE}. If it prints the file name, run git rm --cached ${CONFIG_FILE}.`;
|
|
133
|
+
|
|
121
134
|
/**
|
|
122
|
-
* createSettings({ cwd, editable, checkGoals, history, windowFor, onSaved }) -> { info, preview, save }
|
|
135
|
+
* createSettings({ cwd, configDir, editable, checkGoals, history, windowFor, onSaved }) -> { info, preview, save }
|
|
136
|
+
* `configDir()` answers the folder of the config this run reads (the folder view started in,
|
|
137
|
+
* `cwd`, unless the config came from --config, HONESTWEEK_CONFIG or the user-level file); the
|
|
138
|
+
* file, its .gitignore and its relative paths are that folder's. Repositories to add are still
|
|
139
|
+
* looked for next to `cwd`.
|
|
123
140
|
* `windowFor(history, limitMB)` answers which days that would load and an estimate of the cost.
|
|
124
141
|
* `editable()` answers null when this run's config can be changed here, or why it can't.
|
|
125
142
|
* `onSaved()` reloads the week from the new file; it throws with a plain message when it can't.
|
|
143
|
+
* `saved()` answers what the saved-results folder holds (lib/saved/saver.mjs), or null.
|
|
126
144
|
*/
|
|
127
|
-
export function createSettings({ cwd, editable = () => null, checkGoals = () => {}, history = () => null, windowFor = () => null, onSaved = async () => ({}), lastCommitAt = repoLastCommitAt, hasCommits = authorHasCommits }) {
|
|
128
|
-
const
|
|
129
|
-
const
|
|
145
|
+
export function createSettings({ cwd, configDir = () => cwd, editable = () => null, checkGoals = () => {}, history = () => null, windowFor = () => null, onSaved = async () => ({}), saved = () => null, lastCommitAt = repoLastCommitAt, hasCommits = authorHasCommits }) {
|
|
146
|
+
const here = resolve(cwd);
|
|
147
|
+
const where = () => {
|
|
148
|
+
const folder = resolve(configDir() ?? cwd);
|
|
149
|
+
return { folder, path: join(folder, CONFIG_FILE) };
|
|
150
|
+
};
|
|
130
151
|
const read = () => {
|
|
131
|
-
const text = readFileSync(path, 'utf8');
|
|
152
|
+
const text = readFileSync(where().path, 'utf8');
|
|
132
153
|
return { text, raw: JSON.parse(text), version: versionOf(text) };
|
|
133
154
|
};
|
|
134
155
|
const closed = () => {
|
|
135
156
|
const why = editable();
|
|
136
157
|
if (why) return why;
|
|
137
|
-
if (!existsSync(path)) return `There's no ${CONFIG_FILE} in this folder yet.`;
|
|
158
|
+
if (!existsSync(where().path)) return `There's no ${CONFIG_FILE} in ${where().folder === here ? 'this folder' : where().folder} yet.`;
|
|
138
159
|
return null;
|
|
139
160
|
};
|
|
140
161
|
|
|
141
162
|
function info() {
|
|
163
|
+
const { folder } = where();
|
|
142
164
|
const why = closed();
|
|
143
165
|
if (why) return { editable: false, note: why };
|
|
144
166
|
let cur;
|
|
@@ -164,6 +186,8 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
|
|
|
164
186
|
goalsFile: typeof r.goalsFile === 'string' ? r.goalsFile : '',
|
|
165
187
|
insights: r.insights === true,
|
|
166
188
|
longSessionTokens: Number.isInteger(r.longSessionTokens) ? r.longSessionTokens : null,
|
|
189
|
+
saveResults: { on: r.saveResults?.on === true, keepDays: Number.isInteger(r.saveResults?.keepDays) ? r.saveResults.keepDays : null, history: r.saveResults?.history !== false, otherSessions: r.saveResults?.otherSessions === true, defaultDays: SAVE_KEEP_DAYS.default, min: SAVE_KEEP_DAYS.min, max: SAVE_KEEP_DAYS.max },
|
|
190
|
+
saved: saved(),
|
|
167
191
|
privateWords: privateWordsText(),
|
|
168
192
|
};
|
|
169
193
|
}
|
|
@@ -174,6 +198,7 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
|
|
|
174
198
|
* the role Setup would suggest. A folder the config marks display-only is never passed to git.
|
|
175
199
|
*/
|
|
176
200
|
function found() {
|
|
201
|
+
const { folder } = where();
|
|
177
202
|
const why = closed();
|
|
178
203
|
if (why) return { editable: false, note: why };
|
|
179
204
|
let raw;
|
|
@@ -184,17 +209,27 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
|
|
|
184
209
|
}
|
|
185
210
|
const listed = displayTest((Array.isArray(raw.repos) ? raw.repos : []).filter((x) => typeof x?.path === 'string').map((x) => resolveRepoPath(x.path, folder)));
|
|
186
211
|
const email = Array.isArray(raw.identity?.authorEmails) ? raw.identity.authorEmails[0] ?? null : null;
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
const
|
|
212
|
+
// Repositories are looked for next to the folder view started in, so the display-only
|
|
213
|
+
// folders of a config there (or the one the lookup finds from there) are kept from git too,
|
|
214
|
+
// not only those of the config this run edits.
|
|
215
|
+
// One of those that can't be read can't say which folders it marks, so git runs nowhere.
|
|
216
|
+
const startKept = keptFromOldConfig(here);
|
|
217
|
+
const around = lookedUpConfig(here);
|
|
218
|
+
const blocked = startKept.unreadable ? join(here, CONFIG_FILE) : around?.unreadable ? around.path : null;
|
|
219
|
+
if (blocked) return { editable: false, note: `${blocked} can't be read, so Settings can't tell which folders it marks display-only, and doesn't look for repositories until it can.` };
|
|
220
|
+
const display = [...new Set([...existingDisplayRepos(folder), ...startKept.display.map((d) => resolveRepoPath(d.path, here)), ...(around?.display ?? [])])];
|
|
221
|
+
// findRepos never asks git about a repository holding a display-only folder, or inside one,
|
|
222
|
+
// since git reading it would read that folder's history too (nestsDisplay). It isn't
|
|
223
|
+
// offered either, since Save would refuse it.
|
|
224
|
+
const nested = nestsDisplay(display);
|
|
225
|
+
const { repos } = findRepos(here, email, { displayPaths: display, lastCommitAt, hasCommits });
|
|
226
|
+
const fresh = repos.filter((x) => x.role !== 'display' && !listed(x.path) && !nested(x.path));
|
|
193
227
|
return { editable: true, repos: fresh.map((x) => ({ path: x.path, label: x.label, role: x.role, lastAt: x.lastAt ?? null })) };
|
|
194
228
|
}
|
|
195
229
|
|
|
196
230
|
/** The new file from the answers, and the changes in plain words, or a refusal. */
|
|
197
231
|
function check(body) {
|
|
232
|
+
const { folder } = where();
|
|
198
233
|
const why = closed();
|
|
199
234
|
if (why) return { refusal: json(409, { error: 'not-editable', message: why }) };
|
|
200
235
|
const b = parseBody(body, FIELDS, folder);
|
|
@@ -332,17 +367,42 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
|
|
|
332
367
|
changes.push(v === null ? 'Long-session limit: off.' : `Long-session limit: ${v.toLocaleString('en-US')} tokens.`);
|
|
333
368
|
}
|
|
334
369
|
|
|
370
|
+
// Saved results: { on, keepDays, history } where keepDays null is the default and history is on
|
|
371
|
+
// unless it's false. Off removes the field, so a file that never had it gets back the same
|
|
372
|
+
// bytes; on keeps a chosen keepDays, and history only when it's turned off.
|
|
373
|
+
if (a.saveResults !== undefined) {
|
|
374
|
+
const v = a.saveResults;
|
|
375
|
+
if (!v || typeof v !== 'object' || Array.isArray(v) || typeof v.on !== 'boolean' || Object.keys(v).some((k) => !['on', 'keepDays', 'history', 'otherSessions'].includes(k)) || ['history', 'otherSessions'].some((k) => v[k] !== undefined && typeof v[k] !== 'boolean')) return { refusal: refuse('Save results must be on or off.', 'saveResults') };
|
|
376
|
+
const keep = v.keepDays ?? null;
|
|
377
|
+
if (keep !== null && !(Number.isInteger(keep) && keep >= SAVE_KEEP_DAYS.min && keep <= SAVE_KEEP_DAYS.max)) return { refusal: refuse(`Keep saved days for a whole number of days from ${SAVE_KEEP_DAYS.min} to ${SAVE_KEEP_DAYS.max.toLocaleString('en-US')}, or leave it empty for ${SAVE_KEEP_DAYS.default}.`, 'saveResults.keepDays') };
|
|
378
|
+
const wasOn = was.saveResults?.on === true;
|
|
379
|
+
const wasKeep = Number.isInteger(was.saveResults?.keepDays) ? was.saveResults.keepDays : null;
|
|
380
|
+
const history = v.history !== false;
|
|
381
|
+
const wasHistory = was.saveResults?.history !== false;
|
|
382
|
+
const others = v.otherSessions === true;
|
|
383
|
+
const wasOthers = was.saveResults?.otherSessions === true;
|
|
384
|
+
if (v.on !== wasOn || (v.on && (keep !== wasKeep || history !== wasHistory || others !== wasOthers))) {
|
|
385
|
+
if (v.on) next.saveResults = { on: true, ...(keep !== null ? { keepDays: keep } : {}), ...(history ? {} : { history: false }), ...(others ? { otherSessions: true } : {}) };
|
|
386
|
+
else delete next.saveResults;
|
|
387
|
+
changes.push(v.on ? `Save results between runs: on, ${history ? `with each day's history${others ? ', all sessions' : ''}` : 'check results only'}, kept ${(keep ?? SAVE_KEEP_DAYS.default).toLocaleString('en-US')} days.` : 'Save results between runs: off. What was saved stays until you press Forget saved results.');
|
|
388
|
+
if (v.on && !gitignoreIgnores(folder, SAVED_GITIGNORE[0])) notes.push(`Saving also adds ${SAVED_GITIGNORE[0]} to .gitignore.`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
335
392
|
try {
|
|
336
393
|
normalizeConfig(next, { configDir: folder });
|
|
337
394
|
} catch (err) {
|
|
338
395
|
return { refusal: refuse(String(err?.message ?? err)) };
|
|
339
396
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
397
|
+
// A tracked config is worth a warning once it lists private words, and where git can't be
|
|
398
|
+
// asked, so is checking by hand. Save adds the .gitignore line either way, and this makes the
|
|
399
|
+
// same check it does, so the note matches what Save does.
|
|
400
|
+
// Save repeats the warning (`warning`), since it can be pressed without Review.
|
|
401
|
+
const trackState = privateWordCount(next) > 0 ? configTrackState(folder) : null;
|
|
402
|
+
const warning = trackState === 'tracked' ? `${CONFIG_FILE} is tracked by git, so .gitignore won't hide it. It lists private words: run git rm --cached ${CONFIG_FILE} if it shouldn't be committed.` : trackState === 'unchecked' ? CANT_CHECK_CONFIG : null;
|
|
403
|
+
if (warning) notes.push(warning);
|
|
404
|
+
if (trackState !== 'tracked' && !gitignoreIgnores(folder, CONFIG_FILE)) notes.push(`Saving also adds ${CONFIG_FILE} to .gitignore, since it holds your email, folder paths and any private words.`);
|
|
405
|
+
return { next, changes, notes, cur, warning };
|
|
346
406
|
}
|
|
347
407
|
|
|
348
408
|
function preview(body) {
|
|
@@ -352,16 +412,24 @@ export function createSettings({ cwd, editable = () => null, checkGoals = () =>
|
|
|
352
412
|
}
|
|
353
413
|
|
|
354
414
|
async function save(body) {
|
|
415
|
+
const { folder, path } = where();
|
|
355
416
|
const r = check(body);
|
|
356
417
|
if (r.refusal) return r.refusal;
|
|
357
|
-
if (!r.changes.length)
|
|
418
|
+
if (!r.changes.length) {
|
|
419
|
+
// Save always adds the .gitignore line (Preview's note says so), so a save with nothing else to change still does.
|
|
420
|
+
const ignored = ignoreConfig(folder);
|
|
421
|
+
const message = ignored ? `Nothing in the config changed. ${CONFIG_FILE} is now in .gitignore.` : 'Nothing changed.';
|
|
422
|
+
return json(200, { saved: false, changes: [], message: r.warning ? `${message} ${r.warning}` : message });
|
|
423
|
+
}
|
|
358
424
|
atomicWriteText(path, serializeLike(r.next, r.cur.text));
|
|
359
|
-
|
|
425
|
+
ignoreConfig(folder);
|
|
426
|
+
if (r.next.saveResults?.on === true) ensureGitignore(folder, SAVED_GITIGNORE[0]);
|
|
427
|
+
const warning = r.warning ? { warning: r.warning } : {};
|
|
360
428
|
try {
|
|
361
429
|
await onSaved();
|
|
362
|
-
return json(200, { saved: true, changes: r.changes, next: 'problems.html' });
|
|
430
|
+
return json(200, { saved: true, changes: r.changes, next: 'problems.html', ...warning });
|
|
363
431
|
} catch (err) {
|
|
364
|
-
return json(200, { saved: true, changes: r.changes, restart: `${String(err?.message ?? err).replace(/^view: /, '')} Your settings are saved; stop honestweek view with Ctrl+C and start it again.` });
|
|
432
|
+
return json(200, { saved: true, changes: r.changes, ...warning, restart: `${String(err?.message ?? err).replace(/^view: /, '')} Your settings are saved; stop honestweek view with Ctrl+C and start it again.` });
|
|
365
433
|
}
|
|
366
434
|
}
|
|
367
435
|
|
package/lib/view/setup.mjs
CHANGED
|
@@ -7,16 +7,18 @@
|
|
|
7
7
|
// page sends its answers to this program only, in a POST body; nothing here logs them, puts
|
|
8
8
|
// them in an address, or writes them anywhere but the config file.
|
|
9
9
|
//
|
|
10
|
-
// Every write lands in the folder the command was started in,
|
|
11
|
-
//
|
|
10
|
+
// Every write lands in the folder the command was started in, or, when the person picks it, in
|
|
11
|
+
// the user-level folder (~/.honestweek), which a command run from any folder finds. No request
|
|
12
|
+
// can name another folder. A config already there, or one that appears before saving, means
|
|
13
|
+
// nothing is written. A
|
|
12
14
|
// repository marked display is never passed to git: discovery skips any the folder's config
|
|
13
15
|
// already lists that way, and a repository added by path is only looked at on disk.
|
|
14
16
|
|
|
15
|
-
import { existsSync, statSync } from 'node:fs';
|
|
16
|
-
import { basename, join, resolve } from 'node:path';
|
|
17
|
+
import { existsSync, mkdirSync, statSync } from 'node:fs';
|
|
18
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
17
19
|
|
|
18
20
|
import { hostTimezone, isEmailShaped, normalizeConfig, normalizeHistory, resolveRepoPath, ROLES } from '../config.mjs';
|
|
19
|
-
import { buildConfig, checkNestedRoles, displayTest, findRepos, foundLine, inferIdentity, isGitRepo, repoIdentity, NAMES_QUESTION, parseWordList, PRIVATE_WORDS_INTRO, ROLE_MEANINGS, TERMS_QUESTION, writeInitFiles } from '../init.mjs';
|
|
21
|
+
import { buildConfig, checkNestedRoles, displayTest, findRepos, foundLine, gitignoreHas, inferIdentity, isGitRepo, repoIdentity, NAMES_QUESTION, parseWordList, PRIVATE_WORDS_INTRO, ROLE_MEANINGS, TERMS_QUESTION, writeInitFiles } from '../init.mjs';
|
|
20
22
|
import { privateWordCount } from '../private-words.mjs';
|
|
21
23
|
|
|
22
24
|
export const CONFIG_FILE = 'honestweek.config.json';
|
|
@@ -24,7 +26,7 @@ export const CONFIG_FILE = 'honestweek.config.json';
|
|
|
24
26
|
export const SETUP_MAX_BODY = 64 * 1024;
|
|
25
27
|
/** The history choice Setup starts on: the last week, as view has always read. */
|
|
26
28
|
export const DEFAULT_HISTORY = Object.freeze({ days: 7 });
|
|
27
|
-
const FIELDS = new Set(['authorEmails', 'timezone', 'repos', 'names', 'terms', 'goalsFile', 'history']);
|
|
29
|
+
const FIELDS = new Set(['authorEmails', 'timezone', 'repos', 'names', 'terms', 'goalsFile', 'history', 'saveTo']);
|
|
28
30
|
const REPO_FIELDS = new Set(['path', 'label', 'role']);
|
|
29
31
|
const MAX_REPOS = 200;
|
|
30
32
|
const MAX_EMAILS = 20;
|
|
@@ -171,14 +173,18 @@ export function checkHistory(v) {
|
|
|
171
173
|
}
|
|
172
174
|
|
|
173
175
|
/**
|
|
174
|
-
* createSetup({ cwd, command, inferEmail, checkGoals, history, onSaved }) -> { info, preview, save, pending }
|
|
176
|
+
* createSetup({ cwd, command, inferEmail, checkGoals, history, onSaved, userConfig, configFound }) -> { info, preview, save, pending }
|
|
177
|
+
* `userConfig` is the user-level config file the page may save to instead of this folder (null
|
|
178
|
+
* when this run wouldn't look there). `configFound()` answers whether a config this run would read
|
|
179
|
+
* is there now, from Setup or from init in another terminal (this folder's, by default).
|
|
175
180
|
* `checkGoals(path)` throws with a plain message when a goal list can't be read.
|
|
176
181
|
* `history()` describes the logs on this machine for the "how far back" choice.
|
|
177
182
|
* `onSaved()` loads the new config and starts the week; it answers { next } or throws.
|
|
178
183
|
*/
|
|
179
|
-
export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoals = () => {}, history = () => null, onSaved = async () => ({}) }) {
|
|
184
|
+
export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoals = () => {}, history = () => null, onSaved = async () => ({}), userConfig = null, configFound }) {
|
|
180
185
|
const folder = resolve(cwd);
|
|
181
186
|
const configPath = join(folder, CONFIG_FILE);
|
|
187
|
+
const userDir = userConfig ? dirname(resolve(userConfig)) : null;
|
|
182
188
|
let found = null;
|
|
183
189
|
let saved = false;
|
|
184
190
|
|
|
@@ -191,7 +197,7 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
|
|
|
191
197
|
return found;
|
|
192
198
|
}
|
|
193
199
|
|
|
194
|
-
const pending = () => !saved && !existsSync(configPath);
|
|
200
|
+
const pending = () => !saved && !(configFound ? configFound() : existsSync(configPath));
|
|
195
201
|
|
|
196
202
|
function info() {
|
|
197
203
|
if (!pending()) return { configured: true, folder, configFile: CONFIG_FILE, note: `${CONFIG_FILE} is already in this folder.` };
|
|
@@ -200,6 +206,7 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
|
|
|
200
206
|
configured: false,
|
|
201
207
|
folder,
|
|
202
208
|
configFile: CONFIG_FILE,
|
|
209
|
+
...(userConfig ? { userConfig: resolve(userConfig) } : {}),
|
|
203
210
|
command,
|
|
204
211
|
found: p.repos.length ? foundLine(p.repos, p.folded) : 'No git repositories found nearby. Add one by its folder path.',
|
|
205
212
|
repos: p.repos,
|
|
@@ -218,6 +225,8 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
|
|
|
218
225
|
const b = parseBody(body, FIELDS, folder);
|
|
219
226
|
if (b.refusal) return b;
|
|
220
227
|
const raw = b.value;
|
|
228
|
+
if (raw.saveTo !== undefined && raw.saveTo !== 'folder' && !(raw.saveTo === 'user' && userDir)) return { refusal: refuse(userDir ? 'Save it in this folder or for every folder.' : 'This run saves in this folder only.', 'saveTo') };
|
|
229
|
+
const target = raw.saveTo === 'user' ? userDir : folder;
|
|
221
230
|
const emails = checkEmails(raw.authorEmails);
|
|
222
231
|
if (emails.refusal) return emails;
|
|
223
232
|
if (typeof raw.timezone !== 'string' || raw.timezone.length > 64 || !validTimezone(raw.timezone)) return { refusal: refuse(`${JSON.stringify(String(raw.timezone ?? '').slice(0, 64))} isn't a timezone this machine knows. Use an IANA name such as Europe/Paris or UTC.`, 'timezone') };
|
|
@@ -251,8 +260,10 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
|
|
|
251
260
|
} catch (err) {
|
|
252
261
|
return { refusal: refuse(String(err?.message ?? err)) };
|
|
253
262
|
}
|
|
254
|
-
|
|
255
|
-
|
|
263
|
+
// Save adds the line unless .gitignore already has it (init's ensureGitignore makes the same check).
|
|
264
|
+
if (!gitignoreHas(target, CONFIG_FILE)) notes.push(`Saving also adds ${CONFIG_FILE} to .gitignore, since it holds your email, folder paths and any private words.`);
|
|
265
|
+
if (target !== folder) notes.push(`It's saved as ${join(target, CONFIG_FILE)}, which honestweek reads from any folder without its own config.`);
|
|
266
|
+
return { config, notes, target };
|
|
256
267
|
}
|
|
257
268
|
|
|
258
269
|
function preview(body) {
|
|
@@ -268,12 +279,14 @@ export function createSetup({ cwd, command = 'honestweek', inferEmail, checkGoal
|
|
|
268
279
|
propose();
|
|
269
280
|
const r = check(body);
|
|
270
281
|
if (r.refusal) return r.refusal;
|
|
271
|
-
|
|
272
|
-
if (
|
|
282
|
+
// The user-level folder is made owner-only on POSIX, like the config in it.
|
|
283
|
+
if (r.target !== folder) mkdirSync(r.target, { recursive: true, mode: 0o700 });
|
|
284
|
+
const result = writeInitFiles(r.target, r.config, { onlyNew: true, example: r.target === folder });
|
|
285
|
+
if (!result.wrote.includes(CONFIG_FILE)) return json(409, { error: 'exists', message: `${CONFIG_FILE} appeared in ${r.target === folder ? 'this folder' : r.target} while saving, so nothing was written or changed. Reload the page to see your week.` });
|
|
273
286
|
saved = true;
|
|
274
287
|
const wrote = result.wrote.filter((w) => !w.startsWith('.gitignore ('));
|
|
275
288
|
const ignored = result.wrote.length - wrote.length;
|
|
276
|
-
const written = `Wrote ${wrote.join(' and ')}${ignored ? ` and ${plural(ignored, '.gitignore line')}` : ''}.`;
|
|
289
|
+
const written = `Wrote ${wrote.join(' and ')}${ignored ? ` and ${plural(ignored, '.gitignore line')}` : ''}${r.target !== folder ? ` in ${r.target}` : ''}.`;
|
|
277
290
|
try {
|
|
278
291
|
const next = await onSaved();
|
|
279
292
|
return json(200, { saved: true, written, next: next?.next ?? 'problems.html' });
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// lib/view/suggest-words.mjs: words in my own prompts and session titles that look like names,
|
|
2
|
+
// for Settings to offer as private words (issue 165).
|
|
3
|
+
//
|
|
4
|
+
// It reads the redacted build, so a word honestweek already hides never shows, and only the
|
|
5
|
+
// sessions in my configured repositories, the ones every lookup and goal uses. It writes nothing:
|
|
6
|
+
// the page shows the words, and nothing changes until I add some and press Save.
|
|
7
|
+
|
|
8
|
+
import { isNameShaped } from '../harvest.mjs';
|
|
9
|
+
|
|
10
|
+
/** Words common in coding sessions that look like names but aren't private. */
|
|
11
|
+
const TOOL_WORDS = new Set(
|
|
12
|
+
(
|
|
13
|
+
'claude codex github gitlab bitbucket readme changelog license api cli sdk mcp npm pnpm npx yarn node deno bun ' +
|
|
14
|
+
'json yaml toml html css url urls http https ssh tls jwt oauth sql csv pdf png svg ui ux ci cd pr prs ' +
|
|
15
|
+
'windows linux macos ubuntu debian chrome firefox safari vscode cursor typescript javascript python rust golang java ' +
|
|
16
|
+
'markdown docker kubernetes aws gcp azure honestweek ok todo fixme'
|
|
17
|
+
).split(/\s+/)
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/** At most this many suggestions, so the list stays short. */
|
|
21
|
+
export const MAX_SUGGESTIONS = 20;
|
|
22
|
+
|
|
23
|
+
/** A capitalized word that only ever starts a sentence is usually just that, so it counts only
|
|
24
|
+
* where it doesn't: mid-sentence. A sentence also starts a line, a list item ("- Check",
|
|
25
|
+
* "2. Update"), and a quote or bracket opening one. CamelCase and all-capitals words count
|
|
26
|
+
* anywhere. */
|
|
27
|
+
const startsSentence = (text, at) =>
|
|
28
|
+
/(?:^|[.!?:;]["'”’)\]]*\s+|\n)\s*(?:(?:[-*+>#]+|\d+[.)])\s+)?["'“‘([]*$/u.test(text.slice(Math.max(0, at - 16), at));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* suggestWords({ sessions, events, include, exclude }) -> [{ word, count }]
|
|
32
|
+
* Name-shaped words in any alphabet (a capitalized word mid-sentence, CamelCase starting with a
|
|
33
|
+
* capital, or all capitals), outside code, in the titles and prompts of the sessions `include`
|
|
34
|
+
* keeps, seen at least twice, most frequent first, at most MAX_SUGGESTIONS. `exclude` holds
|
|
35
|
+
* whole words to leave out, any case: listed private words and the parts of repository labels.
|
|
36
|
+
* A listed name of two words leaves its parts in, since the redactor hides only the whole name
|
|
37
|
+
* and a first name alone still shows.
|
|
38
|
+
*/
|
|
39
|
+
export function suggestWords({ sessions, events, include, exclude = [], promptText = (e) => e.facts?.text }) {
|
|
40
|
+
const skip = new Set([...TOOL_WORDS, ...exclude.map((w) => String(w).trim().toLowerCase()).filter(Boolean)]);
|
|
41
|
+
const counts = new Map();
|
|
42
|
+
const add = (raw) => {
|
|
43
|
+
// Code isn't a name: leave out code blocks and `code`, an identifier with an underscore
|
|
44
|
+
// (GITHUB_TOKEN), and a name starting lowercase (filterSince).
|
|
45
|
+
const text = String(raw).replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ');
|
|
46
|
+
for (const m of text.matchAll(/\p{L}[\p{L}\p{N}_]+/gu)) {
|
|
47
|
+
const w = m[0];
|
|
48
|
+
if (w.includes('_') || /^\p{Ll}/u.test(w)) continue;
|
|
49
|
+
if (!isNameShaped(w) || skip.has(w.toLowerCase())) continue;
|
|
50
|
+
if (/^\p{Lu}\p{Ll}+$/u.test(w) && startsSentence(text, m.index)) continue;
|
|
51
|
+
counts.set(w, (counts.get(w) ?? 0) + 1);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
const kept = sessions.filter(include);
|
|
55
|
+
const keys = new Set(kept.map((s) => s.key));
|
|
56
|
+
for (const s of kept) if (typeof s.title === 'string') add(s.title);
|
|
57
|
+
for (const e of events) {
|
|
58
|
+
if (e.kind !== 'prompt' || !keys.has(e.session)) continue;
|
|
59
|
+
const text = promptText(e);
|
|
60
|
+
if (typeof text === 'string') add(text);
|
|
61
|
+
}
|
|
62
|
+
return [...counts]
|
|
63
|
+
.filter(([, n]) => n >= 2)
|
|
64
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
65
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
66
|
+
.map(([word, count]) => ({ word, count }));
|
|
67
|
+
}
|