@latysh-development/monolith 0.1.0-alpha.2 → 0.1.0-alpha.4

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.
Files changed (90) hide show
  1. package/README.md +24 -1
  2. package/dist/brief/cli.d.ts.map +1 -1
  3. package/dist/brief/cli.js +8 -2
  4. package/dist/brief/collect.d.ts.map +1 -1
  5. package/dist/brief/collect.js +17 -5
  6. package/dist/brief/freshness.d.ts +50 -7
  7. package/dist/brief/freshness.d.ts.map +1 -1
  8. package/dist/brief/freshness.js +89 -17
  9. package/dist/brief/git-signals.d.ts +32 -5
  10. package/dist/brief/git-signals.d.ts.map +1 -1
  11. package/dist/brief/git-signals.js +169 -39
  12. package/dist/brief/knowledge.d.ts +62 -0
  13. package/dist/brief/knowledge.d.ts.map +1 -0
  14. package/dist/brief/knowledge.js +123 -0
  15. package/dist/brief/markdown-context.d.ts +22 -0
  16. package/dist/brief/markdown-context.d.ts.map +1 -0
  17. package/dist/brief/markdown-context.js +66 -0
  18. package/dist/brief/reconcile.d.ts +7 -1
  19. package/dist/brief/reconcile.d.ts.map +1 -1
  20. package/dist/brief/reconcile.js +42 -2
  21. package/dist/brief/story.d.ts +2 -10
  22. package/dist/brief/story.d.ts.map +1 -1
  23. package/dist/brief/story.js +21 -7
  24. package/dist/cli/index.js +1 -1
  25. package/dist/cli/style.d.ts +26 -0
  26. package/dist/cli/style.d.ts.map +1 -0
  27. package/dist/cli/style.js +76 -0
  28. package/dist/cli/ui.d.ts.map +1 -1
  29. package/dist/cli/ui.js +33 -6
  30. package/dist/coordinatorium/cli.d.ts.map +1 -1
  31. package/dist/coordinatorium/cli.js +13 -0
  32. package/dist/finalize/cli.d.ts.map +1 -1
  33. package/dist/finalize/cli.js +30 -13
  34. package/dist/finalize/consistency.d.ts +51 -0
  35. package/dist/finalize/consistency.d.ts.map +1 -0
  36. package/dist/finalize/consistency.js +57 -0
  37. package/dist/finalize/core.d.ts +28 -0
  38. package/dist/finalize/core.d.ts.map +1 -1
  39. package/dist/finalize/core.js +186 -41
  40. package/dist/ideas/cli.d.ts +1 -0
  41. package/dist/ideas/cli.d.ts.map +1 -1
  42. package/dist/ideas/cli.js +14 -3
  43. package/dist/ideas/format.d.ts +31 -1
  44. package/dist/ideas/format.d.ts.map +1 -1
  45. package/dist/ideas/format.js +109 -1
  46. package/dist/ideas/store.d.ts +2 -0
  47. package/dist/ideas/store.d.ts.map +1 -1
  48. package/dist/ideas/store.js +4 -1
  49. package/dist/ideas/types.d.ts +10 -0
  50. package/dist/ideas/types.d.ts.map +1 -1
  51. package/dist/ideas/types.js +3 -0
  52. package/dist/readmodel/dashboard.d.ts.map +1 -1
  53. package/dist/readmodel/dashboard.js +19 -1
  54. package/dist/readmodel/index.d.ts +2 -1
  55. package/dist/readmodel/index.d.ts.map +1 -1
  56. package/dist/readmodel/index.js +1 -0
  57. package/dist/readmodel/roadmap.d.ts +34 -0
  58. package/dist/readmodel/roadmap.d.ts.map +1 -0
  59. package/dist/readmodel/roadmap.js +254 -0
  60. package/dist/readmodel/types.d.ts +88 -0
  61. package/dist/readmodel/types.d.ts.map +1 -1
  62. package/dist/root/safe-write.d.ts +15 -4
  63. package/dist/root/safe-write.d.ts.map +1 -1
  64. package/dist/root/safe-write.js +264 -50
  65. package/dist/ui/apple-touch-icon.png +0 -0
  66. package/dist/ui/assets/index-BJKxUDPD.css +1 -0
  67. package/dist/ui/assets/index-ioC9DPHQ.js +28 -0
  68. package/dist/ui/assets/monolith-mark-Ct0ammrU.webp +0 -0
  69. package/dist/ui/favicon-16x16.png +0 -0
  70. package/dist/ui/favicon-32x32.png +0 -0
  71. package/dist/ui/favicon.ico +0 -0
  72. package/dist/ui/index.html +6 -2
  73. package/dist/update/cli.d.ts +1 -1
  74. package/dist/update/cli.d.ts.map +1 -1
  75. package/dist/update/cli.js +10 -3
  76. package/dist/update/index.d.ts +9 -0
  77. package/dist/update/index.d.ts.map +1 -1
  78. package/dist/update/index.js +50 -1
  79. package/dist/update/legacy-skills.d.ts +15 -0
  80. package/dist/update/legacy-skills.d.ts.map +1 -0
  81. package/dist/update/legacy-skills.js +20 -0
  82. package/dist/validate/index.d.ts.map +1 -1
  83. package/dist/validate/index.js +4 -3
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/package.json +5 -2
  87. package/templates/default-instance/monolith/context/MONOLITH-CHAT-CONTEXT.md +183 -0
  88. package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +19 -3
  89. package/dist/ui/assets/index-C4NhMaq7.js +0 -28
  90. package/dist/ui/assets/index-DMZ7ZvZY.css +0 -1
@@ -1,14 +1,32 @@
1
1
  /**
2
- * Read-only git signals for the freshness check (SELF-AWARENESS-001).
2
+ * Read-only, local, offline git signals for the freshness check
3
+ * (SELF-AWARENESS-001, hardened in alpha.4 by A4-SEC-01/02).
3
4
  *
4
- * Runs only `git log` and `git rev-parse`, never a command that writes, never
5
- * through a shell, with config that could launch helper programs switched off.
6
- * Any failure degrades to `{ status: 'unavailable' }` — git being absent,
7
- * broken or refusing the directory must never fail the brief.
5
+ * Contract:
6
+ * - Only `git log`, `git rev-list`, `git diff-tree`, `git rev-parse` and
7
+ * `git cat-file` are run; never a command that writes, never through a shell.
8
+ * - Offline: lazy fetch of missing objects (partial clones) is disabled
9
+ * (`GIT_NO_LAZY_FETCH=1`) and every transport is refused
10
+ * (`GIT_ALLOW_PROTOCOL=none`), so a missing object is an error here, never a
11
+ * download, an object-store write, or a program started from remote config.
12
+ * (`-c protocol.allow=never` alone was tested and does NOT stop a `file`
13
+ * promisor remote; it is kept only as an additional layer.)
14
+ * - Structural parsing: output that carries attacker-influenced text (commit
15
+ * subjects, file names) is never split on characters that text can contain.
16
+ * Records use NUL (`-z`), which neither a subject nor a path can contain;
17
+ * commit metadata and file lists come from separate commands; shas and dates
18
+ * are validated against strict patterns; the commit list is cross-checked
19
+ * against `git rev-list`. Anything unexpected is `GIT_OUTPUT_INVALID`.
20
+ * - Fail closed: every failure degrades to `{ status: 'unavailable' }`, which
21
+ * freshness turns into UNKNOWN. Git being absent, broken, refusing the
22
+ * directory or returning something unparseable never fails the brief and is
23
+ * never evidence of FRESH.
8
24
  */
9
25
  import { execFileSync } from 'child_process';
10
26
  /** Upper bound on commits read after the last state update. Hitting it marks the result truncated. */
11
27
  export const MAX_COMMITS_SINCE = 500;
28
+ /** Largest blob `readBlobAt` will read; checked with `git cat-file -s` before the content is requested. */
29
+ export const MAX_BLOB_BYTES = 256 * 1024;
12
30
  const GIT_TIMEOUT_MS = 10_000;
13
31
  const GIT_MAX_BUFFER = 8 * 1024 * 1024;
14
32
  const SAFE_CONFIG = [
@@ -16,18 +34,37 @@ const SAFE_CONFIG = [
16
34
  '-c', 'core.pager=cat',
17
35
  '-c', 'log.showSignature=false',
18
36
  '-c', 'diff.external=',
37
+ '-c', 'core.quotepath=off',
38
+ '-c', 'protocol.allow=never',
39
+ '-c', 'protocol.file.allow=never',
19
40
  ];
41
+ const SAFE_ENV = {
42
+ GIT_OPTIONAL_LOCKS: '0',
43
+ GIT_TERMINAL_PROMPT: '0',
44
+ GIT_EXTERNAL_DIFF: '',
45
+ /** git >= 2.44: a missing object is an error instead of a fetch from the promisor remote. */
46
+ GIT_NO_LAZY_FETCH: '1',
47
+ /** Older git has no GIT_NO_LAZY_FETCH; refusing every transport stops the fetch itself. */
48
+ GIT_ALLOW_PROTOCOL: 'none',
49
+ /** State-document names come from config: never git pathspec magic. */
50
+ GIT_LITERAL_PATHSPECS: '1',
51
+ /** Replace refs would change the history this check reports. */
52
+ GIT_NO_REPLACE_OBJECTS: '1',
53
+ };
20
54
  class GitMissingError extends Error {
21
55
  }
22
- function git(cwd, args) {
56
+ class GitOutputError extends Error {
57
+ }
58
+ function git(cwd, args, opts = {}) {
23
59
  try {
24
60
  return execFileSync('git', [...SAFE_CONFIG, ...args], {
25
61
  cwd,
26
62
  encoding: 'utf8',
27
63
  timeout: GIT_TIMEOUT_MS,
28
- maxBuffer: GIT_MAX_BUFFER,
29
- stdio: ['ignore', 'pipe', 'pipe'],
30
- env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0', GIT_EXTERNAL_DIFF: '' },
64
+ maxBuffer: opts.maxBuffer ?? GIT_MAX_BUFFER,
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ input: opts.input ?? '',
67
+ env: { ...process.env, ...SAFE_ENV },
31
68
  });
32
69
  }
33
70
  catch (err) {
@@ -36,8 +73,76 @@ function git(cwd, args) {
36
73
  throw err;
37
74
  }
38
75
  }
39
- const RS = '\x1e'; // record separator, starts each commit
40
- const US = '\x1f'; // unit separator, between header fields
76
+ const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
77
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/;
78
+ /** `:oldmode newmode sha sha STATUS` (and the combined-diff form `::m m m sha sha sha SS`). Never a file name: names are consumed as the token after one. */
79
+ const RAW_HEADER_RE = /^:+[0-7]{6}(?: [0-7]{6})* [0-9a-f]{4,64}(?: [0-9a-f]{4,64})* [A-Z]+[0-9]*$/;
80
+ export function isGitSha(s) {
81
+ return SHA_RE.test(s);
82
+ }
83
+ /** Tokens of NUL-terminated output. The output must be empty or end with NUL. */
84
+ function nulTokens(out) {
85
+ if (out === '')
86
+ return [];
87
+ if (!out.endsWith('\0'))
88
+ throw new GitOutputError('output is not NUL-terminated');
89
+ return out.slice(0, -1).split('\0');
90
+ }
91
+ /** `git log -z --no-patch --format=%H%x00%cI%x00%s`: strict triples. */
92
+ function parseCommitTriples(out) {
93
+ const t = nulTokens(out);
94
+ if (t.length % 3 !== 0)
95
+ throw new GitOutputError('commit records are not complete triples');
96
+ const seen = new Set();
97
+ const commits = [];
98
+ for (let i = 0; i < t.length; i += 3) {
99
+ const [sha, date, subject] = [t[i], t[i + 1], t[i + 2]];
100
+ if (!SHA_RE.test(sha))
101
+ throw new GitOutputError('a commit id is not a valid object name');
102
+ if (!DATE_RE.test(date))
103
+ throw new GitOutputError('a commit date is not an ISO-8601 date');
104
+ if (seen.has(sha))
105
+ throw new GitOutputError('a commit appears twice');
106
+ seen.add(sha);
107
+ commits.push({ sha, date, subject });
108
+ }
109
+ return commits;
110
+ }
111
+ /**
112
+ * `git diff-tree --stdin -z --raw`: `<sha> NUL` then zero or more
113
+ * `<:raw-header> NUL <path> NUL`. A path is only ever read as the token after a
114
+ * raw header, so no file name (spaces, newlines, control characters, even a
115
+ * 40-hex string) can be mistaken for a commit id or a header. Commits without
116
+ * changes (clean merges, empty commits) may be absent; order must be preserved.
117
+ */
118
+ function parseFileLists(out, expected, prefix) {
119
+ const t = nulTokens(out);
120
+ const files = new Map();
121
+ let next = 0; // index into `expected` the next sha must be at or after
122
+ let i = 0;
123
+ while (i < t.length) {
124
+ const sha = t[i++];
125
+ const at = SHA_RE.test(sha) ? expected.indexOf(sha, next) : -1;
126
+ if (at === -1)
127
+ throw new GitOutputError('a file list belongs to an unexpected or out-of-order commit');
128
+ next = at + 1;
129
+ const list = [];
130
+ while (i < t.length && t[i].startsWith(':')) {
131
+ if (!RAW_HEADER_RE.test(t[i]) || i + 1 >= t.length)
132
+ throw new GitOutputError('a raw diff entry is malformed');
133
+ // `--relative` is ignored for combined (merge) entries, which start with `::`: make them project-relative here.
134
+ if (t[i].startsWith('::')) {
135
+ if (t[i + 1].startsWith(prefix))
136
+ list.push(t[i + 1].slice(prefix.length));
137
+ }
138
+ else
139
+ list.push(t[i + 1]);
140
+ i += 2;
141
+ }
142
+ files.set(sha, list);
143
+ }
144
+ return files;
145
+ }
41
146
  /**
42
147
  * @param targetDir project directory (git is run from here; paths are relative to it)
43
148
  * @param stateFiles state documents, relative to `targetDir`. Only existing ones need be passed.
@@ -56,46 +161,71 @@ export function readGitSignals(targetDir, stateFiles) {
56
161
  }
57
162
  try {
58
163
  // A repository without commits has no HEAD: nothing to compare with.
164
+ let head;
59
165
  try {
60
- git(targetDir, ['rev-parse', '--verify', '--quiet', 'HEAD']);
166
+ head = git(targetDir, ['rev-parse', '--verify', '--quiet', 'HEAD']).trim();
61
167
  }
62
168
  catch {
63
169
  return { status: 'ok', lastStateUpdate: null, commitsSince: [], truncated: false };
64
170
  }
171
+ if (!SHA_RE.test(head))
172
+ throw new GitOutputError('HEAD did not resolve to an object name');
173
+ // A shallow clone has no history before its boundary: "no later commit" proves nothing.
174
+ const shallowOut = git(targetDir, ['rev-parse', '--is-shallow-repository']).trim();
175
+ if (shallowOut !== 'true' && shallowOut !== 'false')
176
+ throw new GitOutputError('unexpected shallow-repository answer');
177
+ const shallow = shallowOut === 'true';
65
178
  let lastStateUpdate = null;
66
179
  if (stateFiles.length > 0) {
67
- const out = git(targetDir, ['log', '-1', `--format=%H${US}%cI`, '--', ...stateFiles]).trim();
68
- if (out) {
69
- const [sha, date] = out.split(US);
70
- lastStateUpdate = { sha, date };
180
+ const t = nulTokens(git(targetDir, ['log', '-1', '-z', '--no-patch', '--format=%H%x00%cI', head, '--', ...stateFiles]));
181
+ if (t.length !== 0) {
182
+ if (t.length !== 2 || !SHA_RE.test(t[0]) || !DATE_RE.test(t[1]))
183
+ throw new GitOutputError('the last state update is not a valid record');
184
+ lastStateUpdate = { sha: t[0], date: t[1] };
71
185
  }
72
186
  }
73
187
  if (!lastStateUpdate)
74
- return { status: 'ok', lastStateUpdate: null, commitsSince: [], truncated: false };
75
- const raw = git(targetDir, [
76
- 'log',
77
- `-n${MAX_COMMITS_SINCE + 1}`,
78
- '--no-ext-diff',
79
- '--no-textconv',
80
- '--relative',
81
- '--name-only',
82
- `--format=${RS}%H${US}%cI${US}%s`,
83
- `${lastStateUpdate.sha}..HEAD`,
84
- ]);
85
- const commits = [];
86
- for (const rec of raw.split(RS)) {
87
- if (!rec.trim())
88
- continue;
89
- const nl = rec.indexOf('\n');
90
- const header = nl === -1 ? rec : rec.slice(0, nl);
91
- const [sha, date, subject] = header.split(US);
92
- const files = (nl === -1 ? '' : rec.slice(nl + 1)).split('\n').filter((l) => l.trim() !== '');
93
- commits.push({ sha, date, subject: subject ?? '', files });
188
+ return { status: 'ok', lastStateUpdate: null, commitsSince: [], truncated: false, shallow };
189
+ const range = `${lastStateUpdate.sha}..${head}`;
190
+ const limit = `-n${MAX_COMMITS_SINCE + 1}`;
191
+ const commits = parseCommitTriples(git(targetDir, ['log', '-z', '--no-patch', limit, '--format=%H%x00%cI%x00%s', range]));
192
+ // Independent cross-check of the commit list: same range, no free text in the output.
193
+ const listed = nulTokens(git(targetDir, ['rev-list', '-z', limit, range]));
194
+ if (listed.length !== commits.length || listed.some((s, k) => s !== commits[k].sha)) {
195
+ throw new GitOutputError('the commit list disagrees with git rev-list');
196
+ }
197
+ let fileLists = new Map();
198
+ if (commits.length > 0) {
199
+ const prefix = git(targetDir, ['rev-parse', '--show-prefix']).replace(/\n$/, '');
200
+ const raw = git(targetDir, ['diff-tree', '--stdin', '-z', '-r', '--raw', '-c', '--root', '--no-renames', '--no-ext-diff', '--no-textconv', '--ignore-submodules=none', '--relative'], { input: `${commits.map((c) => c.sha).join('\n')}\n` });
201
+ fileLists = parseFileLists(raw, commits.map((c) => c.sha), prefix);
94
202
  }
95
- const truncated = commits.length > MAX_COMMITS_SINCE;
96
- return { status: 'ok', lastStateUpdate, commitsSince: commits.slice(0, MAX_COMMITS_SINCE), truncated };
203
+ const signals = commits.map((c) => ({ sha: c.sha, date: c.date, subject: c.subject, files: fileLists.get(c.sha) ?? [] }));
204
+ const truncated = signals.length > MAX_COMMITS_SINCE;
205
+ return { status: 'ok', lastStateUpdate, commitsSince: signals.slice(0, MAX_COMMITS_SINCE), truncated, shallow };
206
+ }
207
+ catch (err) {
208
+ return { status: 'unavailable', reason: err instanceof GitOutputError ? 'GIT_OUTPUT_INVALID' : 'GIT_FAILED' };
209
+ }
210
+ }
211
+ /**
212
+ * Contents of `relPath` (relative to `targetDir`) as of commit `sha`, or null
213
+ * when the file does not exist there, is larger than `MAX_BLOB_BYTES`, is not
214
+ * available locally (a partial clone's missing blob is NOT fetched) or cannot be
215
+ * read. `git cat-file` only: read-only, no textconv, no shell. Used by KA-001 to
216
+ * judge an idea registration as it was when the commit was made.
217
+ */
218
+ export function readBlobAt(targetDir, sha, relPath) {
219
+ if (!SHA_RE.test(sha) || relPath.includes('\0') || relPath.startsWith('-'))
220
+ return null;
221
+ const spec = `${sha}:./${relPath}`;
222
+ try {
223
+ const size = git(targetDir, ['cat-file', '-s', spec]).trim();
224
+ if (!/^\d{1,9}$/.test(size) || Number(size) > MAX_BLOB_BYTES)
225
+ return null;
226
+ return git(targetDir, ['cat-file', 'blob', spec], { maxBuffer: MAX_BLOB_BYTES + 1024 });
97
227
  }
98
228
  catch {
99
- return { status: 'unavailable', reason: 'GIT_FAILED' };
229
+ return null;
100
230
  }
101
231
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Independent knowledge attribution (KA-001).
3
+ *
4
+ * A project may keep knowledge that belongs to no work item — a strategy
5
+ * paper, a vision note — without that being "unattributed activity" and
6
+ * without pretending it is part of the active story. The registration is an
7
+ * idea of the Idea Store (`IDEA-NNN`) that declares the artifact's `paths`.
8
+ *
9
+ * A commit is attributed to independent knowledge only when ALL hold:
10
+ * 1. its subject names no work item (a work item keeps its own semantics);
11
+ * 2. it names at least one `IDEA-NNN` that is a valid idea file **in the
12
+ * tree of that very commit** (so registering an idea later never
13
+ * legalises older commits, and a commit that registers or edits the idea
14
+ * is itself never attributed — its own files are not covered);
15
+ * 3. the ideas it names declare `paths`, every declared path passes the
16
+ * attribution policy (exact document files only — no directory prefixes, no
17
+ * source code, nothing inside the MONOLITH root; `attributionPathProblem`),
18
+ * and EVERY file the commit changed (other than state documents) is covered
19
+ * by those paths;
20
+ * 4. no changed file lies inside the MONOLITH root;
21
+ * 5. it names at most MAX_IDEA_IDS_PER_COMMIT ideas, and the run stays inside a
22
+ * fixed budget of blob reads (each read is a git subprocess).
23
+ * Attribution only EXPLAINS a commit. It is not approval: a registered, correctly
24
+ * attributed change may still be wrong or unreviewed.
25
+ * Anything else stays exactly what it was: unattributed activity. Nothing here
26
+ * activates a story, touches the roadmap or marks anything done; it only
27
+ * decides whether a commit is *explained*. No LLM, no network, read-only git.
28
+ */
29
+ import type { GitSignals } from './freshness.js';
30
+ export interface KnowledgeAttribution {
31
+ /** Idea ids named in the subject that were valid in the commit's tree. */
32
+ ideaIds: string[];
33
+ /** The paths those ideas declared (what the attribution rests on), in declaration order, without repeats. */
34
+ paths: string[];
35
+ }
36
+ export interface KnowledgeRejection {
37
+ /** One short reason a person can check. */
38
+ reason: string;
39
+ }
40
+ export interface KnowledgeEvidence {
41
+ /** Commit sha → attribution. Only commits that satisfied every rule. */
42
+ attributed: ReadonlyMap<string, KnowledgeAttribution>;
43
+ /** Commit sha → why a commit that NAMED an idea was not attributed. */
44
+ rejected: ReadonlyMap<string, KnowledgeRejection>;
45
+ }
46
+ export declare const EMPTY_KNOWLEDGE: KnowledgeEvidence;
47
+ /** Upper bound of commits examined; beyond it the rest simply stay unattributed. */
48
+ export declare const MAX_KNOWLEDGE_COMMITS = 200;
49
+ /** A subject naming more ideas than this is refused without any git call (A4-SEC-05). */
50
+ export declare const MAX_IDEA_IDS_PER_COMMIT = 8;
51
+ /** Total blob reads (git subprocesses) per run; beyond it commits stay unattributed. */
52
+ export declare const MAX_KNOWLEDGE_BLOB_READS = 64;
53
+ export declare function ideaIdsInSubject(subject: string): string[];
54
+ /** Reads `relPath` as of commit `sha`; null when it does not exist there (or cannot be read). */
55
+ export type BlobReader = (sha: string, relPath: string) => string | null;
56
+ export declare function attributeKnowledge(git: GitSignals, opts: {
57
+ monolithRoot: string;
58
+ stateFiles: ReadonlySet<string>;
59
+ readBlob: BlobReader;
60
+ hasWorkItem: (subject: string) => boolean;
61
+ }): KnowledgeEvidence;
62
+ //# sourceMappingURL=knowledge.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"knowledge.d.ts","sourceRoot":"","sources":["../../src/brief/knowledge.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAGH,OAAO,KAAK,EAAmB,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAElE,MAAM,WAAW,oBAAoB;IACnC,0EAA0E;IAC1E,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,6GAA6G;IAC7G,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,kBAAkB;IACjC,2CAA2C;IAC3C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,iBAAiB;IAChC,wEAAwE;IACxE,UAAU,EAAE,WAAW,CAAC,MAAM,EAAE,oBAAoB,CAAC,CAAC;IACtD,uEAAuE;IACvE,QAAQ,EAAE,WAAW,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;CACnD;AAED,eAAO,MAAM,eAAe,EAAE,iBAAkE,CAAC;AAEjG,oFAAoF;AACpF,eAAO,MAAM,qBAAqB,MAAM,CAAC;AACzC,yFAAyF;AACzF,eAAO,MAAM,uBAAuB,IAAI,CAAC;AACzC,wFAAwF;AACxF,eAAO,MAAM,wBAAwB,KAAK,CAAC;AAI3C,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE,CAE1D;AAED,iGAAiG;AACjG,MAAM,MAAM,UAAU,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;AAEzE,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,UAAU,EACf,IAAI,EAAE;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IAAC,QAAQ,EAAE,UAAU,CAAC;IAAC,WAAW,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAA;CAAE,GAC/H,iBAAiB,CAyBnB"}
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Independent knowledge attribution (KA-001).
3
+ *
4
+ * A project may keep knowledge that belongs to no work item — a strategy
5
+ * paper, a vision note — without that being "unattributed activity" and
6
+ * without pretending it is part of the active story. The registration is an
7
+ * idea of the Idea Store (`IDEA-NNN`) that declares the artifact's `paths`.
8
+ *
9
+ * A commit is attributed to independent knowledge only when ALL hold:
10
+ * 1. its subject names no work item (a work item keeps its own semantics);
11
+ * 2. it names at least one `IDEA-NNN` that is a valid idea file **in the
12
+ * tree of that very commit** (so registering an idea later never
13
+ * legalises older commits, and a commit that registers or edits the idea
14
+ * is itself never attributed — its own files are not covered);
15
+ * 3. the ideas it names declare `paths`, every declared path passes the
16
+ * attribution policy (exact document files only — no directory prefixes, no
17
+ * source code, nothing inside the MONOLITH root; `attributionPathProblem`),
18
+ * and EVERY file the commit changed (other than state documents) is covered
19
+ * by those paths;
20
+ * 4. no changed file lies inside the MONOLITH root;
21
+ * 5. it names at most MAX_IDEA_IDS_PER_COMMIT ideas, and the run stays inside a
22
+ * fixed budget of blob reads (each read is a git subprocess).
23
+ * Attribution only EXPLAINS a commit. It is not approval: a registered, correctly
24
+ * attributed change may still be wrong or unreviewed.
25
+ * Anything else stays exactly what it was: unattributed activity. Nothing here
26
+ * activates a story, touches the roadmap or marks anything done; it only
27
+ * decides whether a commit is *explained*. No LLM, no network, read-only git.
28
+ */
29
+ import { artifactPathsCover, attributionPathProblem, isIdeaId, parseIdea } from '../ideas/format.js';
30
+ export const EMPTY_KNOWLEDGE = { attributed: new Map(), rejected: new Map() };
31
+ /** Upper bound of commits examined; beyond it the rest simply stay unattributed. */
32
+ export const MAX_KNOWLEDGE_COMMITS = 200;
33
+ /** A subject naming more ideas than this is refused without any git call (A4-SEC-05). */
34
+ export const MAX_IDEA_IDS_PER_COMMIT = 8;
35
+ /** Total blob reads (git subprocesses) per run; beyond it commits stay unattributed. */
36
+ export const MAX_KNOWLEDGE_BLOB_READS = 64;
37
+ const IDEA_REF_RE = /(?<![A-Za-z0-9-])IDEA-\d{3,9}(?![A-Za-z0-9]|\.\d|-[A-Z0-9])/g;
38
+ export function ideaIdsInSubject(subject) {
39
+ return [...new Set(subject.match(IDEA_REF_RE) ?? [])].filter(isIdeaId);
40
+ }
41
+ export function attributeKnowledge(git, opts) {
42
+ if (git.status !== 'ok')
43
+ return EMPTY_KNOWLEDGE;
44
+ const attributed = new Map();
45
+ const rejected = new Map();
46
+ const root = opts.monolithRoot.replace(/^(\.\/)+/, '').replace(/\/+$/, '');
47
+ const rootPrefix = root === '' || root === '.' ? '' : `${root}/`;
48
+ let examined = 0;
49
+ const budget = { reads: MAX_KNOWLEDGE_BLOB_READS };
50
+ for (const c of git.commitsSince) {
51
+ const files = c.files.filter((f) => !opts.stateFiles.has(f));
52
+ if (files.length === 0)
53
+ continue; // not activity
54
+ if (opts.hasWorkItem(c.subject))
55
+ continue; // work items keep their own semantics
56
+ const named = ideaIdsInSubject(c.subject);
57
+ if (named.length === 0)
58
+ continue;
59
+ if (++examined > MAX_KNOWLEDGE_COMMITS)
60
+ break;
61
+ if (named.length > MAX_IDEA_IDS_PER_COMMIT) {
62
+ rejected.set(c.sha, { reason: `it names ${named.length} ideas (more than ${MAX_IDEA_IDS_PER_COMMIT}); no registration was read` });
63
+ continue;
64
+ }
65
+ const verdict = judge(c, files, named, rootPrefix, root, opts.readBlob, budget);
66
+ if ('ideaIds' in verdict)
67
+ attributed.set(c.sha, verdict);
68
+ else
69
+ rejected.set(c.sha, verdict);
70
+ }
71
+ return { attributed, rejected };
72
+ }
73
+ function judge(c, files, named, rootPrefix, monolithRoot, readBlob, budget) {
74
+ const unjudgeable = files.find((f) => /^\.?\.?\//.test(f));
75
+ if (unjudgeable !== undefined)
76
+ return { reason: `a changed path (${unjudgeable.slice(0, 40)}) is not project-relative and cannot be judged` };
77
+ const inRoot = (rootPrefix === '' ? files[0] : files.find((f) => f.toLowerCase().startsWith(rootPrefix.toLowerCase())));
78
+ if (inRoot !== undefined)
79
+ return { reason: `it changes ${inRoot}, inside the MONOLITH root; knowledge attribution never covers that` };
80
+ const valid = [];
81
+ const paths = [];
82
+ const problems = [];
83
+ for (const id of named) {
84
+ if (budget.reads <= 0) {
85
+ problems.push(`${id} was not checked: the per-run limit of ${MAX_KNOWLEDGE_BLOB_READS} registration reads was reached`);
86
+ continue;
87
+ }
88
+ budget.reads--;
89
+ const text = readBlob(c.sha, `${monolithRoot}/memory/long-term/ideas/${id}.md`);
90
+ if (text === null) {
91
+ problems.push(`${id} is not a registered idea in this commit (or its content is not available locally)`);
92
+ continue;
93
+ }
94
+ try {
95
+ const idea = parseIdea(id, text);
96
+ if (idea.paths === undefined || idea.paths.length === 0)
97
+ problems.push(`${id} declares no paths`);
98
+ else {
99
+ const refused = idea.paths.map((p) => ({ p, why: attributionPathProblem(p, monolithRoot) })).find((x) => x.why !== null);
100
+ if (refused !== undefined) {
101
+ problems.push(`${id} declares a path that cannot attribute knowledge (${refused.p.slice(0, 60)}): ${refused.why}`);
102
+ continue;
103
+ }
104
+ valid.push(id);
105
+ for (const p of idea.paths)
106
+ if (!paths.includes(p))
107
+ paths.push(p);
108
+ }
109
+ }
110
+ catch {
111
+ problems.push(`${id} is not a readable idea in this commit`);
112
+ }
113
+ }
114
+ // Strict: every idea the subject names must be valid; a bad id is never silently ignored.
115
+ if (problems.length > 0)
116
+ return { reason: problems.join('; ') };
117
+ const outside = files.filter((f) => !artifactPathsCover(paths, f));
118
+ if (outside.length > 0) {
119
+ const shown = outside.slice(0, 3).join(', ');
120
+ return { reason: `${shown}${outside.length > 3 ? ` and ${outside.length - 3} more` : ''} not covered by the declared paths of ${valid.join(', ')}` };
121
+ }
122
+ return { ideaIds: valid, paths };
123
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Markdown context shared by the Brief, Freshness, Reconcile and DOCSYNC
3
+ * readers of the project state documents. Pure, deterministic, no I/O.
4
+ */
5
+ /**
6
+ * True when the first visible line of a Current Story excerpt (outside code
7
+ * fences and HTML comments) declares that there is no active story. Shared by
8
+ * the Brief and DOCSYNC (PSC-004A): such a declaration next to a structural
9
+ * `**Story:**` field is a contradiction, never a confirmed "none" and never
10
+ * a confirmed identity.
11
+ */
12
+ export declare function declaresNoActiveStory(excerpt: string): boolean;
13
+ /**
14
+ * Blanks every line (or part of a line) that sits inside a fenced code block
15
+ * or an HTML comment, keeping the line count. Fence grammar matches DOCSYNC's
16
+ * `activeStoryIdentity()`: a line that starts with ``` or ~~~ opens a fence,
17
+ * and only a line made of the same character, at least as long, closes it.
18
+ * An unterminated fence or comment hides the rest of the text — an example
19
+ * that cannot be delimited is never read as a claim.
20
+ */
21
+ export declare function maskNonContent(text: string): string;
22
+ //# sourceMappingURL=markdown-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-context.d.ts","sourceRoot":"","sources":["../../src/brief/markdown-context.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAuCnD"}
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Markdown context shared by the Brief, Freshness, Reconcile and DOCSYNC
3
+ * readers of the project state documents. Pure, deterministic, no I/O.
4
+ */
5
+ /**
6
+ * True when the first visible line of a Current Story excerpt (outside code
7
+ * fences and HTML comments) declares that there is no active story. Shared by
8
+ * the Brief and DOCSYNC (PSC-004A): such a declaration next to a structural
9
+ * `**Story:**` field is a contradiction, never a confirmed "none" and never
10
+ * a confirmed identity.
11
+ */
12
+ export function declaresNoActiveStory(excerpt) {
13
+ const first = maskNonContent(excerpt.replace(/\r\n/g, '\n')).split('\n').find((l) => l.trim() !== '') ?? '';
14
+ return /^\W*no active story\b/i.test(first);
15
+ }
16
+ /**
17
+ * Blanks every line (or part of a line) that sits inside a fenced code block
18
+ * or an HTML comment, keeping the line count. Fence grammar matches DOCSYNC's
19
+ * `activeStoryIdentity()`: a line that starts with ``` or ~~~ opens a fence,
20
+ * and only a line made of the same character, at least as long, closes it.
21
+ * An unterminated fence or comment hides the rest of the text — an example
22
+ * that cannot be delimited is never read as a claim.
23
+ */
24
+ export function maskNonContent(text) {
25
+ let fence = null;
26
+ let comment = false;
27
+ const out = [];
28
+ for (const raw of text.split('\n')) {
29
+ const line = raw.trim();
30
+ const f = /^(`{3,}|~{3,})/.exec(line);
31
+ if (fence !== null) {
32
+ if (f !== null && f[1][0] === fence[0] && f[1].length >= fence.length && line === f[1])
33
+ fence = null;
34
+ out.push('');
35
+ continue;
36
+ }
37
+ if (!comment && f !== null) {
38
+ fence = f[1];
39
+ out.push('');
40
+ continue;
41
+ }
42
+ let kept = '';
43
+ let i = 0;
44
+ while (i < raw.length) {
45
+ if (comment) {
46
+ const end = raw.indexOf('-->', i);
47
+ if (end === -1)
48
+ break;
49
+ comment = false;
50
+ i = end + 3;
51
+ }
52
+ else {
53
+ const start = raw.indexOf('<!--', i);
54
+ if (start === -1) {
55
+ kept += raw.slice(i);
56
+ break;
57
+ }
58
+ kept += raw.slice(i, start);
59
+ comment = true;
60
+ i = start + 4;
61
+ }
62
+ }
63
+ out.push(kept);
64
+ }
65
+ return out.join('\n');
66
+ }
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import { type FreshnessEvidence, type FreshnessResult, type FreshnessStatus } from './freshness.js';
24
24
  export type FindingKind = 'confirmed' | 'suspected' | 'unknown';
25
- export type FindingCode = 'STATE_CONTRADICTION' | 'UNRECORDED_WORK' | 'UNATTRIBUTED_ACTIVITY' | 'ACTIVITY_WITHOUT_ACTIVE_STORY' | 'GIT_UNAVAILABLE' | 'STATE_NOT_IN_GIT' | 'GIT_HISTORY_TRUNCATED' | 'ACTIVE_STORY_UNKNOWN';
25
+ export type FindingCode = 'STATE_CONTRADICTION' | 'UNRECORDED_WORK' | 'UNATTRIBUTED_ACTIVITY' | 'ACTIVITY_WITHOUT_ACTIVE_STORY' | 'GIT_UNAVAILABLE' | 'STATE_NOT_IN_GIT' | 'GIT_HISTORY_TRUNCATED' | 'GIT_HISTORY_SHALLOW' | 'ACTIVE_STORY_UNKNOWN';
26
26
  export interface ReconciliationFinding {
27
27
  kind: FindingKind;
28
28
  code: FindingCode;
@@ -54,6 +54,12 @@ export interface ReconciliationReport {
54
54
  untrackedWork: UntrackedWorkItem[];
55
55
  /** Ids git references that some state document mentions. Mentioned is not correct. Sorted. */
56
56
  mentionedWork: string[];
57
+ /** KA-001: commits explained by registered independent knowledge (not part of any work item). */
58
+ independentKnowledge: {
59
+ ideaIds: string[];
60
+ paths: string[];
61
+ commits: string[];
62
+ }[];
57
63
  documents: ReviewDocument[];
58
64
  limitations: string[];
59
65
  actions: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/brief/reconcile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,eAAe,EAErB,MAAM,gBAAgB,CAAC;AAGxB,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,SAAS,CAAC;AAEhE,MAAM,MAAM,WAAW,GACnB,qBAAqB,GACrB,iBAAiB,GACjB,uBAAuB,GACvB,+BAA+B,GAC/B,iBAAiB,GACjB,kBAAkB,GAClB,uBAAuB,GACvB,sBAAsB,CAAC;AAE3B,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,WAAW,CAAC;IAClB,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,oDAAoD;IACpD,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC5C,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,eAAe,CAAC;IAC3B,eAAe,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACtD,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAClC,wGAAwG;IACxG,aAAa,EAAE,iBAAiB,EAAE,CAAC;IACnC,8FAA8F;IAC9F,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAiBtC,wBAAgB,SAAS,CAAC,EAAE,EAAE,iBAAiB,EAAE,MAAM,EAAE,eAAe,GAAG,oBAAoB,CAmI9F;AAiBD,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,EAAE,QAAQ,EAAE,MAAM,GAAG,sBAAsB,CAoF3G"}
1
+ {"version":3,"file":"reconcile.d.ts","sourceRoot":"","sources":["../../src/brief/reconcile.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACpB,KAAK,eAAe,EAErB,MAAM,gBAAgB,CAAC;AAGxB,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,SAAS,CAAC;AAEhE,MAAM,MAAM,WAAW,GACnB,qBAAqB,GACrB,iBAAiB,GACjB,uBAAuB,GACvB,+BAA+B,GAC/B,iBAAiB,GACjB,kBAAkB,GAClB,uBAAuB,GACvB,qBAAqB,GACrB,sBAAsB,CAAC;AAE3B,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,WAAW,CAAC;IAClB,IAAI,EAAE,WAAW,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,oDAAoD;IACpD,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC5C,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,eAAe,CAAC;IAC3B,eAAe,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IACtD,QAAQ,EAAE,qBAAqB,EAAE,CAAC;IAClC,wGAAwG;IACxG,aAAa,EAAE,iBAAiB,EAAE,CAAC;IACnC,8FAA8F;IAC9F,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,iGAAiG;IACjG,oBAAoB,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,EAAE,CAAC;QAAC,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,EAAE,CAAC;IAClF,SAAS,EAAE,cAAc,EAAE,CAAC;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAiBtC,wBAAgB,SAAS,CAAC,EAAE,EAAE,iBAAiB,EAAE,MAAM,EAAE,eAAe,GAAG,oBAAoB,CAkK9F;AAiBD,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,SAAS,EAAE,OAAO,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,oBAAoB,EAAE,QAAQ,EAAE,MAAM,GAAG,sBAAsB,CA+F3G"}