@staix/agent-mem 0.5.0 → 0.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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, mkdirSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseEvalSet, runEval, type EvalQuestion, type EvalReport, type EvalSet, MAX_SET_BYTES } from "./eval.ts";
|
|
6
|
+
import { MemoryStore, type View } from "./store.ts";
|
|
7
|
+
import { realPath } from "./platform.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The committed synthetic question set: no person's memory, only invented entries about a made-up project. It lives under `src/` because the package ships
|
|
11
|
+
* `src/` and not `test/`, so a plain `amem eval` works from an installed copy. A file URL, converted with `fileURLToPath` wherever a path is needed: a plain
|
|
12
|
+
* `.pathname` stays percent-encoded and breaks under an install path with a space or non-ASCII characters.
|
|
13
|
+
*/
|
|
14
|
+
export const FIXTURE_PATH = new URL("./eval-set.json", import.meta.url);
|
|
15
|
+
const PROJECT = "example.test/eval/fixture";
|
|
16
|
+
const DAY = 86400_000;
|
|
17
|
+
/** A fixed clock: entry ids hash their `at`, and recency weights the ranking, so a clock that only moves when the seeder moves it makes every run identical. */
|
|
18
|
+
const EPOCH = Date.parse("2026-01-01T00:00:00.000Z");
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Read and parse a set file. One descriptor does everything: it must be a regular file, its size is bounded before and after the read, and the bytes read are bounded
|
|
22
|
+
* too, so a file that grows or is swapped between a size check and a read cannot slip a larger set through. A symlink is followed (the person named the path).
|
|
23
|
+
*/
|
|
24
|
+
export function readEvalSet(path: string | URL): EvalSet {
|
|
25
|
+
const file = typeof path === "string" ? path : fileURLToPath(path), label = typeof path === "string" ? path : "the built-in fixture";
|
|
26
|
+
const tooBig = () => new Error(`invalid eval set: ${label} is not a regular file within ${MAX_SET_BYTES} bytes`);
|
|
27
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
28
|
+
let bytes: Buffer;
|
|
29
|
+
try {
|
|
30
|
+
const before = fstatSync(fd);
|
|
31
|
+
if (!before.isFile() || before.size > MAX_SET_BYTES) throw tooBig();
|
|
32
|
+
const buffer = Buffer.alloc(MAX_SET_BYTES + 1);
|
|
33
|
+
let length = 0;
|
|
34
|
+
while (length < buffer.length) {
|
|
35
|
+
const n = readSync(fd, buffer, length, buffer.length - length, length);
|
|
36
|
+
if (n === 0) break;
|
|
37
|
+
length += n;
|
|
38
|
+
}
|
|
39
|
+
const after = fstatSync(fd);
|
|
40
|
+
if (length > MAX_SET_BYTES || after.size !== before.size || length !== before.size) throw tooBig();
|
|
41
|
+
bytes = buffer.subarray(0, length);
|
|
42
|
+
} finally { closeSync(fd); }
|
|
43
|
+
let raw: unknown;
|
|
44
|
+
try { raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { throw new Error("invalid eval set: not valid JSON"); }
|
|
45
|
+
return parseEvalSet(raw);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The ranked ids of `store.search` for one question, curated tier only (a question set names notes, and the capture tier is machine-local recall, never an answer key). */
|
|
49
|
+
function ranker(store: MemoryStore, project: string, window: number): { rank: (question: string) => string[]; engine: () => string } {
|
|
50
|
+
let engine = "unknown";
|
|
51
|
+
return {
|
|
52
|
+
rank: question => {
|
|
53
|
+
const result = store.search(question, { project, limit: window, includeCapture: false });
|
|
54
|
+
engine = result.engine;
|
|
55
|
+
return result.hits.flatMap(hit => hit.view ? [hit.view.id] : []);
|
|
56
|
+
},
|
|
57
|
+
engine: () => engine,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A ranking under test: the ids it returns for a question, best first, up to `window` of them. `amem eval` scores `store.search`; F14 candidates are scored by passing another one. */
|
|
62
|
+
export type RankFn = (store: MemoryStore, question: string, window: number) => string[];
|
|
63
|
+
|
|
64
|
+
/** Score a self-contained set: seed its synthetic entries into a throwaway store, search it, and delete it. Touches no memory home. `rank` replaces `store.search` (an experiment, never the command). */
|
|
65
|
+
export function evalSeeded(set: EvalSet, ks?: readonly number[], rank?: RankFn): EvalReport {
|
|
66
|
+
if (!set.entries?.length) throw new Error("this set has no entries; it must be run against a store");
|
|
67
|
+
const dir = realPath(mkdtempSync(join(tmpdir(), "amem-eval-")));
|
|
68
|
+
// The cleanup is armed the moment the directory exists, so a failure while building the store cannot leak it.
|
|
69
|
+
let store: MemoryStore | undefined;
|
|
70
|
+
try {
|
|
71
|
+
const base = join(dir, "memory");
|
|
72
|
+
mkdirSync(base, { recursive: true });
|
|
73
|
+
// One entry per day, so the recency weight (a 30-day scale) is exercised, long-tier entries first and short-tier ones last: a short-term note leaves the index 14 days after it is
|
|
74
|
+
// written, and the clock stops a day after the last entry, so the short ones written in that window are live when the set is searched (a set with more short entries than that
|
|
75
|
+
// window holds loses its oldest, and a question naming one is then reported as not in the store, like any other missing entry).
|
|
76
|
+
let current = EPOCH;
|
|
77
|
+
store = new MemoryStore({ memoryDir: base, now: () => current, screen: () => "keep" });
|
|
78
|
+
const ids = new Map<string, string>();
|
|
79
|
+
const ordered = [...set.entries.filter(entry => entry.tier !== "short"), ...set.entries.filter(entry => entry.tier === "short")];
|
|
80
|
+
for (const [index, entry] of ordered.entries()) {
|
|
81
|
+
current = EPOCH + index * DAY;
|
|
82
|
+
ids.set(entry.key, store.note({ op: "note", project: PROJECT, scope: "project", tier: entry.tier ?? "long", kind: entry.kind ?? "finding", peer: "eval", source: "person",
|
|
83
|
+
...(entry.title ? { title: entry.title } : {}), text: entry.text, about: entry.about ?? [] }, entry.tier === "short"));
|
|
84
|
+
}
|
|
85
|
+
current = EPOCH + ordered.length * DAY;
|
|
86
|
+
const window = Math.max(...(ks ?? [10]));
|
|
87
|
+
const search = rank ? { rank: (question: string) => rank(store!, question, window), engine: () => "custom" } : ranker(store, PROJECT, window);
|
|
88
|
+
// Resolved through `store.get`, so a note that has already expired is absent, not silently present: coverage stays separate from ranking.
|
|
89
|
+
const live = (name: string): string | undefined => { const id = ids.get(name); return id !== undefined && store!.get(id, PROJECT) ? id : undefined; };
|
|
90
|
+
const report = runEval(search.rank, question => question.expect.map(live), set, "pending", ks);
|
|
91
|
+
return { ...report, engine: search.engine() };
|
|
92
|
+
} finally { store?.close(); rmSync(dir, { recursive: true, force: true }); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Score a set of real entry ids against the bound project's store. Each expected name is resolved through `store.get` (so a promoted short-term id, a `mem:` prefix and an id older
|
|
97
|
+
* than any list cap resolve like they do for `amem show`), then followed to the entry that is LIVE: a superseded entry points at its replacement, and `store.search` only ever returns the
|
|
98
|
+
* replacement, so an expected id that was superseded stands for the head of its chain (a broken or cyclic chain is "not in the store"). A prefix that `get` cannot place is checked against
|
|
99
|
+
* every visible id: more than one distinct live entry is refused with an error, because crediting whichever entry matched would let the ranking pick its own answer key.
|
|
100
|
+
*
|
|
101
|
+
* ponytail: `store.get` rebuilds the live view on every call, so the cost is names x store size. Names repeated across questions are resolved once (memoized for the run); the ceiling is
|
|
102
|
+
* 500 questions x 20 names on a very large store, tens of seconds. Upgrade: one snapshot of the visible views (`list` with `includeSuperseded`) indexed by id, `promotedFrom` and
|
|
103
|
+
* `supersededBy`, if a real set ever meets that.
|
|
104
|
+
*/
|
|
105
|
+
export function evalStore(store: MemoryStore, project: string, set: EvalSet, ks?: readonly number[]): EvalReport {
|
|
106
|
+
let visible: View[] | undefined;
|
|
107
|
+
const all = () => visible ??= store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true });
|
|
108
|
+
/** The id of the live entry `view` stands for: itself, or the head of its supersede chain. */
|
|
109
|
+
const live = (view: View): string | undefined => {
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
for (let current: View | undefined = view; current; current = store.get(current.supersededBy, project)) {
|
|
112
|
+
if (!current.supersededBy) return current.id;
|
|
113
|
+
if (seen.has(current.id)) return undefined;
|
|
114
|
+
seen.add(current.id);
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
};
|
|
118
|
+
const known = new Map<string, string | undefined>();
|
|
119
|
+
const resolve = (name: string): string | undefined => {
|
|
120
|
+
if (known.has(name)) return known.get(name);
|
|
121
|
+
let id: string | undefined;
|
|
122
|
+
const found = store.get(name, project);
|
|
123
|
+
if (found) id = live(found);
|
|
124
|
+
else {
|
|
125
|
+
const bare = name.replace(/^mem:/, "");
|
|
126
|
+
const heads = new Set<string>(), seenAny = all().filter(view => view.id.startsWith(bare));
|
|
127
|
+
for (const view of seenAny) { const head = live(view); if (head !== undefined) heads.add(head); }
|
|
128
|
+
if (heads.size > 1) throw new Error(`invalid eval set: "${name.slice(0, 40)}" is the start of ${heads.size} entries; use more characters`);
|
|
129
|
+
id = [...heads][0];
|
|
130
|
+
}
|
|
131
|
+
known.set(name, id);
|
|
132
|
+
return id;
|
|
133
|
+
};
|
|
134
|
+
const search = ranker(store, project, Math.max(...(ks ?? [10])));
|
|
135
|
+
const report = runEval(search.rank, (question: EvalQuestion) => question.expect.map(resolve), set, "pending", ks);
|
|
136
|
+
return { ...report, engine: search.engine() };
|
|
137
|
+
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "amem.eval/v1",
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"key": "shift-handover-old",
|
|
6
|
+
"kind": "decision",
|
|
7
|
+
"tier": "long",
|
|
8
|
+
"title": "Shift handover happens at the gate office",
|
|
9
|
+
"text": "The harbour shift handover is done at the gate office with a signed paper log.",
|
|
10
|
+
"about": [
|
|
11
|
+
"path:src/shift/handover.ts"
|
|
12
|
+
]
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"key": "harbor-retry",
|
|
16
|
+
"kind": "decision",
|
|
17
|
+
"title": "Harbor client retries idempotent calls three times",
|
|
18
|
+
"text": "The Harbor ferry client retries an idempotent call three times with exponential backoff, and never retries a booking write.",
|
|
19
|
+
"about": [
|
|
20
|
+
"path:src/ferry/client.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"key": "harbor-timeout",
|
|
25
|
+
"kind": "finding",
|
|
26
|
+
"title": "Dock sensor requests time out after eight seconds",
|
|
27
|
+
"text": "A dock sensor request that takes longer than eight seconds is abandoned and reported as stale, not as an error.",
|
|
28
|
+
"about": [
|
|
29
|
+
"path:src/dock/sensor.ts"
|
|
30
|
+
],
|
|
31
|
+
"tier": "short"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"key": "tide-cache",
|
|
35
|
+
"kind": "decision",
|
|
36
|
+
"title": "Tide tables are cached for one day",
|
|
37
|
+
"text": "Tide tables come from the almanac service and are cached on disk for one day, because the almanac rate-limits per hour.",
|
|
38
|
+
"about": [
|
|
39
|
+
"path:src/tide/cache.ts"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"key": "tide-rounding",
|
|
44
|
+
"kind": "finding",
|
|
45
|
+
"title": "Tide heights round half to even",
|
|
46
|
+
"text": "Tide heights are rounded half to even before display, so 2.5 metres shows as 2 and 3.5 as 4.",
|
|
47
|
+
"about": [
|
|
48
|
+
"path:src/tide/format.ts"
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"key": "crane-lock",
|
|
53
|
+
"kind": "contract",
|
|
54
|
+
"title": "Only one crane operator lock per berth",
|
|
55
|
+
"text": "A berth holds at most one crane operator lock. A second request waits, it does not steal the lock, and the lock expires after ninety seconds without a heartbeat.",
|
|
56
|
+
"about": [
|
|
57
|
+
"path:src/crane/lock.ts"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"key": "crane-heartbeat",
|
|
62
|
+
"kind": "finding",
|
|
63
|
+
"title": "Crane heartbeat drift on the night shift",
|
|
64
|
+
"text": "The night shift crane heartbeat drifted by up to four seconds because the controller clock was not synced; the lock expiry still held.",
|
|
65
|
+
"about": [
|
|
66
|
+
"path:src/crane/heartbeat.ts"
|
|
67
|
+
],
|
|
68
|
+
"tier": "short"
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"key": "manifest-schema",
|
|
72
|
+
"kind": "contract",
|
|
73
|
+
"title": "Cargo manifest rows carry a container prefix",
|
|
74
|
+
"text": "Every cargo manifest row carries a four-letter container prefix and a seven digit serial. A row without both is rejected at import.",
|
|
75
|
+
"about": [
|
|
76
|
+
"path:src/cargo/manifest.ts"
|
|
77
|
+
]
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"key": "manifest-import",
|
|
81
|
+
"kind": "fail",
|
|
82
|
+
"title": "Importing manifests in one transaction blew the memory budget",
|
|
83
|
+
"text": "Importing a whole cargo manifest in one transaction used more than a gigabyte. Stream it in batches of five hundred rows instead.",
|
|
84
|
+
"about": [
|
|
85
|
+
"path:src/cargo/import.ts"
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"key": "pilot-roster",
|
|
90
|
+
"kind": "decision",
|
|
91
|
+
"title": "Pilot roster is published every Friday",
|
|
92
|
+
"text": "The pilot roster for the coming week is published on Friday at noon local time and is frozen afterwards except for sick cover.",
|
|
93
|
+
"about": [
|
|
94
|
+
"path:src/pilot/roster.ts"
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"key": "pilot-fatigue",
|
|
99
|
+
"kind": "contract",
|
|
100
|
+
"title": "A pilot may not exceed twelve hours on duty",
|
|
101
|
+
"text": "The scheduler refuses an assignment that takes a pilot past twelve hours on duty in any rolling twenty-four hours.",
|
|
102
|
+
"about": [
|
|
103
|
+
"path:src/pilot/fatigue.ts"
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"key": "fuel-ledger",
|
|
108
|
+
"kind": "decision",
|
|
109
|
+
"title": "Fuel is recorded in litres, converted once",
|
|
110
|
+
"text": "Bunker fuel is recorded in litres. Conversion from tonnes happens once at the edge, using the density on the delivery note.",
|
|
111
|
+
"about": [
|
|
112
|
+
"path:src/fuel/ledger.ts"
|
|
113
|
+
]
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
"key": "fuel-density",
|
|
117
|
+
"kind": "finding",
|
|
118
|
+
"title": "Delivery note density is sometimes missing",
|
|
119
|
+
"text": "About one delivery note in forty has no fuel density. The ledger falls back to the supplier default and flags the row for review.",
|
|
120
|
+
"about": [
|
|
121
|
+
"path:src/fuel/density.ts"
|
|
122
|
+
],
|
|
123
|
+
"tier": "short"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"key": "berth-clash",
|
|
127
|
+
"kind": "fail",
|
|
128
|
+
"title": "Two vessels were booked into the same berth window",
|
|
129
|
+
"text": "Two vessels were booked into one berth window because the overlap check compared local times across a daylight saving change. Compare in UTC.",
|
|
130
|
+
"about": [
|
|
131
|
+
"path:src/berth/overlap.ts"
|
|
132
|
+
]
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"key": "berth-priority",
|
|
136
|
+
"kind": "decision",
|
|
137
|
+
"title": "Passenger ferries outrank freight at the quay",
|
|
138
|
+
"text": "When a quay berth is contested the passenger ferry keeps it and the freight vessel moves to the outer berth.",
|
|
139
|
+
"about": [
|
|
140
|
+
"path:src/berth/priority.ts"
|
|
141
|
+
]
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
"key": "alarm-quiet",
|
|
145
|
+
"kind": "decision",
|
|
146
|
+
"title": "Fog alarms are quiet between midnight and five",
|
|
147
|
+
"text": "The fog horn test alarm is silenced between midnight and five in the morning, the real fog alarm is never silenced.",
|
|
148
|
+
"about": [
|
|
149
|
+
"path:src/alarm/fog.ts"
|
|
150
|
+
],
|
|
151
|
+
"tier": "short"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
"key": "alarm-escalate",
|
|
155
|
+
"kind": "contract",
|
|
156
|
+
"title": "Unacknowledged alarms escalate after ten minutes",
|
|
157
|
+
"text": "An alarm nobody acknowledges within ten minutes escalates to the harbour master's phone, then to the duty manager after another ten.",
|
|
158
|
+
"about": [
|
|
159
|
+
"path:src/alarm/escalate.ts"
|
|
160
|
+
]
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
"key": "radio-channel",
|
|
164
|
+
"kind": "finding",
|
|
165
|
+
"title": "Channel sixteen is monitored, not used for routine traffic",
|
|
166
|
+
"text": "Channel sixteen is monitored for distress calls only. Routine traffic moves to the working channel named in the berth assignment.",
|
|
167
|
+
"about": [
|
|
168
|
+
"path:src/radio/channels.ts"
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"key": "radio-log",
|
|
173
|
+
"kind": "decision",
|
|
174
|
+
"title": "Radio calls are logged with a vessel callsign",
|
|
175
|
+
"text": "Every radio call is logged with the vessel callsign and a UTC timestamp, never with the master's name.",
|
|
176
|
+
"about": [
|
|
177
|
+
"path:src/radio/log.ts"
|
|
178
|
+
]
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
"key": "gate-badge",
|
|
182
|
+
"kind": "contract",
|
|
183
|
+
"title": "Gate badges expire at end of shift",
|
|
184
|
+
"text": "A gate badge is valid until the end of the holder's shift and a contractor badge never outlives the contract end date.",
|
|
185
|
+
"about": [
|
|
186
|
+
"path:src/gate/badge.ts"
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
"key": "gate-camera",
|
|
191
|
+
"kind": "finding",
|
|
192
|
+
"title": "Gate camera plate reader fails in heavy rain",
|
|
193
|
+
"text": "The gate camera plate reader misreads plates in heavy rain; the guard confirms by hand when confidence is below ninety percent.",
|
|
194
|
+
"about": [
|
|
195
|
+
"path:src/gate/camera.ts"
|
|
196
|
+
],
|
|
197
|
+
"tier": "short"
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
"key": "report-monthly",
|
|
201
|
+
"kind": "decision",
|
|
202
|
+
"title": "Throughput report closes on the third working day",
|
|
203
|
+
"text": "The monthly throughput report closes on the third working day, late manifests are counted in the next month.",
|
|
204
|
+
"about": [
|
|
205
|
+
"path:src/report/monthly.ts"
|
|
206
|
+
]
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
"key": "report-units",
|
|
210
|
+
"kind": "finding",
|
|
211
|
+
"title": "Throughput is reported in twenty-foot equivalents",
|
|
212
|
+
"text": "Throughput is reported in twenty-foot equivalent units, a forty-foot container counts as two.",
|
|
213
|
+
"about": [
|
|
214
|
+
"path:src/report/units.ts"
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
"key": "backup-nightly",
|
|
219
|
+
"kind": "decision",
|
|
220
|
+
"title": "Database backup runs at two in the morning",
|
|
221
|
+
"text": "The operations database is backed up at two in the morning and the copy is verified by a test restore every Sunday.",
|
|
222
|
+
"about": [
|
|
223
|
+
"path:ops/backup.sh"
|
|
224
|
+
]
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
"key": "backup-restore",
|
|
228
|
+
"kind": "fail",
|
|
229
|
+
"title": "A backup restored without the audit table",
|
|
230
|
+
"text": "A restore test passed while the audit table was missing, because the verification only counted the cargo tables. Count every table.",
|
|
231
|
+
"about": [
|
|
232
|
+
"path:ops/restore-check.sh"
|
|
233
|
+
]
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"key": "shift-handover-new",
|
|
237
|
+
"kind": "finding",
|
|
238
|
+
"tier": "short",
|
|
239
|
+
"title": "Shift handover happens at the gate office",
|
|
240
|
+
"text": "The harbour shift handover is done at the gate office, and the signed paper log is being replaced by a tablet.",
|
|
241
|
+
"about": [
|
|
242
|
+
"path:src/shift/handover.ts"
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
],
|
|
246
|
+
"questions": [
|
|
247
|
+
{
|
|
248
|
+
"q": "how many times does the ferry client retry",
|
|
249
|
+
"expect": [
|
|
250
|
+
"harbor-retry"
|
|
251
|
+
]
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
"q": "retries backoff idempotent",
|
|
255
|
+
"expect": [
|
|
256
|
+
"harbor-retry"
|
|
257
|
+
]
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
"q": "dock sensor timeout",
|
|
261
|
+
"expect": [
|
|
262
|
+
"harbor-timeout"
|
|
263
|
+
]
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
"q": "why are tide tables cached",
|
|
267
|
+
"expect": [
|
|
268
|
+
"tide-cache"
|
|
269
|
+
]
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
"q": "almanac rate limit",
|
|
273
|
+
"expect": [
|
|
274
|
+
"tide-cache"
|
|
275
|
+
]
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
"q": "round half to even tide height",
|
|
279
|
+
"expect": [
|
|
280
|
+
"tide-rounding"
|
|
281
|
+
]
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
"q": "crane lock expiry without heartbeat",
|
|
285
|
+
"expect": [
|
|
286
|
+
"crane-lock",
|
|
287
|
+
"crane-heartbeat"
|
|
288
|
+
]
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"q": "night shift clock drift",
|
|
292
|
+
"expect": [
|
|
293
|
+
"crane-heartbeat"
|
|
294
|
+
]
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
"q": "container prefix serial number manifest",
|
|
298
|
+
"expect": [
|
|
299
|
+
"manifest-schema"
|
|
300
|
+
]
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"q": "manifest import memory too large",
|
|
304
|
+
"expect": [
|
|
305
|
+
"manifest-import"
|
|
306
|
+
]
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
"q": "when is the pilot roster published",
|
|
310
|
+
"expect": [
|
|
311
|
+
"pilot-roster"
|
|
312
|
+
]
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
"q": "pilot twelve hours on duty",
|
|
316
|
+
"expect": [
|
|
317
|
+
"pilot-fatigue"
|
|
318
|
+
]
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"q": "fuel density litres tonnes",
|
|
322
|
+
"expect": [
|
|
323
|
+
"fuel-ledger",
|
|
324
|
+
"fuel-density"
|
|
325
|
+
]
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
"q": "missing density on delivery note",
|
|
329
|
+
"expect": [
|
|
330
|
+
"fuel-density"
|
|
331
|
+
]
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
"q": "berth double booking daylight saving",
|
|
335
|
+
"expect": [
|
|
336
|
+
"berth-clash"
|
|
337
|
+
]
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
"q": "who wins a contested quay berth",
|
|
341
|
+
"expect": [
|
|
342
|
+
"berth-priority"
|
|
343
|
+
]
|
|
344
|
+
},
|
|
345
|
+
{
|
|
346
|
+
"q": "fog horn alarm silenced at night",
|
|
347
|
+
"expect": [
|
|
348
|
+
"alarm-quiet"
|
|
349
|
+
]
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
"q": "unacknowledged alarm escalates",
|
|
353
|
+
"expect": [
|
|
354
|
+
"alarm-escalate"
|
|
355
|
+
]
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
"q": "distress channel sixteen",
|
|
359
|
+
"expect": [
|
|
360
|
+
"radio-channel"
|
|
361
|
+
]
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
"q": "radio call log callsign",
|
|
365
|
+
"expect": [
|
|
366
|
+
"radio-log"
|
|
367
|
+
]
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
"q": "contractor badge expiry",
|
|
371
|
+
"expect": [
|
|
372
|
+
"gate-badge"
|
|
373
|
+
]
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
"q": "plate reader rain",
|
|
377
|
+
"expect": [
|
|
378
|
+
"gate-camera"
|
|
379
|
+
]
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
"q": "monthly throughput report close date",
|
|
383
|
+
"expect": [
|
|
384
|
+
"report-monthly"
|
|
385
|
+
]
|
|
386
|
+
},
|
|
387
|
+
{
|
|
388
|
+
"q": "forty foot container counts as two",
|
|
389
|
+
"expect": [
|
|
390
|
+
"report-units"
|
|
391
|
+
]
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
"q": "database backup schedule test restore",
|
|
395
|
+
"expect": [
|
|
396
|
+
"backup-nightly",
|
|
397
|
+
"backup-restore"
|
|
398
|
+
]
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
"q": "restore check missed the audit table",
|
|
402
|
+
"expect": [
|
|
403
|
+
"backup-restore"
|
|
404
|
+
]
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
"q": "what stops two tugs sharing a mooring",
|
|
408
|
+
"expect": [
|
|
409
|
+
"berth-clash"
|
|
410
|
+
]
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
"q": "staff badge validity period",
|
|
414
|
+
"expect": [
|
|
415
|
+
"gate-badge"
|
|
416
|
+
]
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
"q": "vessel arrival prediction model",
|
|
420
|
+
"expect": [
|
|
421
|
+
"tide-cache"
|
|
422
|
+
]
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
"q": "who approves overtime",
|
|
426
|
+
"expect": [
|
|
427
|
+
"pilot-fatigue"
|
|
428
|
+
]
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
"q": "where is the shift handover done",
|
|
432
|
+
"expect": [
|
|
433
|
+
"shift-handover-new"
|
|
434
|
+
]
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
"q": "paper log shift handover gate office",
|
|
438
|
+
"expect": [
|
|
439
|
+
"shift-handover-old"
|
|
440
|
+
]
|
|
441
|
+
}
|
|
442
|
+
]
|
|
443
|
+
}
|